@relayfile/file-observer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1520 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { startTransition, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
5
+ import {
6
+ AlertCircle,
7
+ ChevronDown,
8
+ ChevronRight,
9
+ FileCode2,
10
+ FileText,
11
+ Folder,
12
+ FolderOpen,
13
+ Loader2,
14
+ Maximize2,
15
+ RefreshCcw,
16
+ Search,
17
+ Shield,
18
+ SlidersHorizontal,
19
+ Sparkles,
20
+ X
21
+ } from 'lucide-react';
22
+
23
+ type FileNodeType = 'file' | 'dir';
24
+
25
+ interface TreeEntry {
26
+ path: string;
27
+ type: FileNodeType;
28
+ revision: string;
29
+ provider?: string;
30
+ providerObjectId?: string;
31
+ size?: number;
32
+ updatedAt?: string;
33
+ propertyCount?: number;
34
+ relationCount?: number;
35
+ permissionCount?: number;
36
+ commentCount?: number;
37
+ }
38
+
39
+ interface TreeResponse {
40
+ path: string;
41
+ entries: TreeEntry[];
42
+ nextCursor: string | null;
43
+ }
44
+
45
+ interface FileSemantics {
46
+ properties?: Record<string, string>;
47
+ relations?: string[];
48
+ permissions?: string[];
49
+ comments?: string[];
50
+ }
51
+
52
+ interface FileReadResponse {
53
+ path: string;
54
+ revision: string;
55
+ contentType: string;
56
+ content: string;
57
+ encoding?: 'utf-8' | 'base64';
58
+ provider?: string;
59
+ providerObjectId?: string;
60
+ lastEditedAt?: string;
61
+ semantics?: FileSemantics;
62
+ }
63
+
64
+ interface FileQueryItem {
65
+ path: string;
66
+ revision: string;
67
+ contentType: string;
68
+ provider?: string;
69
+ providerObjectId?: string;
70
+ lastEditedAt?: string;
71
+ size: number;
72
+ properties?: Record<string, string>;
73
+ relations?: string[];
74
+ permissions?: string[];
75
+ comments?: string[];
76
+ }
77
+
78
+ type SyncProviderStatusState = 'healthy' | 'lagging' | 'error' | 'paused';
79
+
80
+ interface SyncProviderStatus {
81
+ provider: string;
82
+ status: SyncProviderStatusState;
83
+ lagSeconds?: number;
84
+ lastError?: string | null;
85
+ deadLetteredEnvelopes?: number;
86
+ deadLetteredOps?: number;
87
+ }
88
+
89
+ interface SyncStatusResponse {
90
+ workspaceId: string;
91
+ providers: SyncProviderStatus[];
92
+ }
93
+
94
+ interface WriteFileResponse {
95
+ opId: string;
96
+ status: string;
97
+ targetRevision: string;
98
+ writeback?: {
99
+ provider?: string;
100
+ state?: string;
101
+ };
102
+ }
103
+
104
+ interface ApiErrorBody {
105
+ code?: string;
106
+ message?: string;
107
+ currentRevision?: string;
108
+ expectedRevision?: string;
109
+ }
110
+
111
+ interface SelectedEntry {
112
+ path: string;
113
+ type: FileNodeType;
114
+ revision?: string;
115
+ provider?: string;
116
+ providerObjectId?: string;
117
+ size?: number;
118
+ updatedAt?: string;
119
+ contentType?: string;
120
+ properties?: Record<string, string>;
121
+ relations?: string[];
122
+ permissions?: string[];
123
+ comments?: string[];
124
+ }
125
+
126
+ type TreeCache = Record<string, TreeResponse>;
127
+
128
+ const DEFAULT_BASE_URL = process.env.NEXT_PUBLIC_RELAYFILE_BASE_URL ?? 'https://api.relayfile.dev';
129
+ const PUBLIC_TOKEN = process.env.NEXT_PUBLIC_RELAYFILE_TOKEN ?? '';
130
+ const WORKSPACE_ENV =
131
+ process.env.NEXT_PUBLIC_RELAYFILE_WORKSPACE_IDS ?? process.env.NEXT_PUBLIC_RELAYFILE_WORKSPACE_ID ?? '';
132
+
133
+ function parseWorkspaceIds(raw: string): string[] {
134
+ return Array.from(
135
+ new Set(
136
+ raw
137
+ .split(/[,\n]/)
138
+ .map((value) => value.trim())
139
+ .filter(Boolean)
140
+ )
141
+ );
142
+ }
143
+
144
+ function buildQuery(params: Record<string, string | number | undefined>): string {
145
+ const query = new URLSearchParams();
146
+ for (const [key, value] of Object.entries(params)) {
147
+ if (value !== undefined && value !== '') {
148
+ query.set(key, String(value));
149
+ }
150
+ }
151
+ const encoded = query.toString();
152
+ return encoded ? `?${encoded}` : '';
153
+ }
154
+
155
+ function createCorrelationId(): string {
156
+ return `rf_${typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now()}`;
157
+ }
158
+
159
+ function normalizeError(error: unknown): string {
160
+ if (error instanceof Error && error.message) {
161
+ return error.message;
162
+ }
163
+ return 'Unexpected relayfile request failure.';
164
+ }
165
+
166
+ function formatDate(value?: string): string {
167
+ if (!value) {
168
+ return 'Unknown';
169
+ }
170
+ const date = new Date(value);
171
+ if (Number.isNaN(date.getTime())) {
172
+ return value;
173
+ }
174
+ return new Intl.DateTimeFormat(undefined, {
175
+ dateStyle: 'medium',
176
+ timeStyle: 'short'
177
+ }).format(date);
178
+ }
179
+
180
+ function formatBytes(value?: number): string {
181
+ if (value === undefined || Number.isNaN(value)) {
182
+ return 'Unknown';
183
+ }
184
+ if (value < 1024) {
185
+ return `${value} B`;
186
+ }
187
+ const units = ['KB', 'MB', 'GB', 'TB'];
188
+ let size = value / 1024;
189
+ let unitIndex = 0;
190
+ while (size >= 1024 && unitIndex < units.length - 1) {
191
+ size /= 1024;
192
+ unitIndex += 1;
193
+ }
194
+ return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
195
+ }
196
+
197
+ function truncateContentPreview(content: string, maxLength?: number): string {
198
+ if (!maxLength || content.length <= maxLength) {
199
+ return content;
200
+ }
201
+ return `${content.slice(0, maxLength)}\n\n… truncated …`;
202
+ }
203
+
204
+ function isJsonContent(contentType?: string, path?: string): boolean {
205
+ const normalizedContentType = contentType?.toLowerCase() ?? '';
206
+ const normalizedPath = path?.toLowerCase() ?? '';
207
+ return (
208
+ normalizedContentType.includes('application/json') ||
209
+ normalizedContentType.includes('+json') ||
210
+ normalizedPath.endsWith('.json')
211
+ );
212
+ }
213
+
214
+ function inferProviderFromPath(path?: string, type?: FileNodeType): string | undefined {
215
+ const segments = path?.split('/').filter(Boolean) ?? [];
216
+ if (segments.length === 0) {
217
+ return undefined;
218
+ }
219
+ if (segments.length === 1 && type !== 'dir') {
220
+ return undefined;
221
+ }
222
+ return segments[0];
223
+ }
224
+
225
+ function formatContentPreview(
226
+ content: string,
227
+ encoding?: 'utf-8' | 'base64',
228
+ contentType?: string,
229
+ path?: string,
230
+ maxLength?: number
231
+ ): string {
232
+ if (encoding === 'base64') {
233
+ return 'Binary or base64-encoded content preview is not rendered in the dashboard.';
234
+ }
235
+
236
+ if (!isJsonContent(contentType, path)) {
237
+ return truncateContentPreview(content, maxLength);
238
+ }
239
+
240
+ try {
241
+ return truncateContentPreview(JSON.stringify(JSON.parse(content), null, 2), maxLength);
242
+ } catch {
243
+ return truncateContentPreview(content, maxLength);
244
+ }
245
+ }
246
+
247
+ function createRelayFileClient(baseUrl: string, token: string) {
248
+ async function request<T>(
249
+ path: string,
250
+ options: {
251
+ method?: 'GET' | 'PUT';
252
+ body?: unknown;
253
+ headers?: Record<string, string>;
254
+ signal?: AbortSignal;
255
+ } = {}
256
+ ): Promise<T> {
257
+ const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
258
+ method: options.method ?? 'GET',
259
+ signal: options.signal,
260
+ headers: {
261
+ Accept: 'application/json',
262
+ Authorization: token ? `Bearer ${token}` : '',
263
+ 'X-Correlation-Id': createCorrelationId(),
264
+ ...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
265
+ ...options.headers
266
+ },
267
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined
268
+ });
269
+
270
+ if (!response.ok) {
271
+ let payload: ApiErrorBody | undefined;
272
+ try {
273
+ payload = (await response.json()) as ApiErrorBody;
274
+ } catch {
275
+ payload = undefined;
276
+ }
277
+ const revisionDetail = payload?.currentRevision ? ` Current revision: ${payload.currentRevision}.` : '';
278
+ const detail = `${payload?.message ?? `${response.status} ${response.statusText}`}${revisionDetail}`;
279
+ throw new Error(detail);
280
+ }
281
+
282
+ return (await response.json()) as T;
283
+ }
284
+
285
+ return {
286
+ listTree(workspaceId: string, options: { path?: string; depth?: number; cursor?: string; signal?: AbortSignal } = {}) {
287
+ const query = buildQuery({
288
+ path: options.path ?? '/',
289
+ depth: options.depth,
290
+ cursor: options.cursor
291
+ });
292
+ return request<TreeResponse>(`/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/tree${query}`, { signal: options.signal });
293
+ },
294
+ readFile(workspaceId: string, path: string, signal?: AbortSignal) {
295
+ const query = buildQuery({ path });
296
+ return request<FileReadResponse>(`/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/file${query}`, { signal });
297
+ },
298
+ writeFile(
299
+ workspaceId: string,
300
+ path: string,
301
+ options: { contentType: string; content: string; ifMatch: string; signal?: AbortSignal }
302
+ ) {
303
+ const query = buildQuery({ path });
304
+ return request<WriteFileResponse>(`/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/file${query}`, {
305
+ method: 'PUT',
306
+ signal: options.signal,
307
+ headers: {
308
+ 'If-Match': options.ifMatch
309
+ },
310
+ body: {
311
+ contentType: options.contentType,
312
+ content: options.content,
313
+ encoding: 'utf-8'
314
+ }
315
+ });
316
+ },
317
+ queryFiles(
318
+ workspaceId: string,
319
+ options: { path?: string; provider?: string; limit?: number; signal?: AbortSignal } = {}
320
+ ) {
321
+ const query = buildQuery({
322
+ path: options.path,
323
+ provider: options.provider,
324
+ limit: options.limit
325
+ });
326
+ return request<{ items: FileQueryItem[]; nextCursor: string | null }>(
327
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/query${query}`,
328
+ { signal: options.signal }
329
+ );
330
+ },
331
+ getSyncStatus(workspaceId: string, signal?: AbortSignal) {
332
+ return request<SyncStatusResponse>(`/v1/workspaces/${encodeURIComponent(workspaceId)}/sync/status`, { signal });
333
+ }
334
+ };
335
+ }
336
+
337
+ function DirectorySkeleton() {
338
+ return (
339
+ <div className="space-y-2 p-4">
340
+ {Array.from({ length: 6 }, (_, index) => (
341
+ <div
342
+ key={index}
343
+ className="h-10 animate-pulse rounded-xl border border-[#27272a] bg-[#111113]"
344
+ />
345
+ ))}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ function DetailSkeleton() {
351
+ return (
352
+ <div className="space-y-4 p-5">
353
+ <div className="h-7 w-2/3 animate-pulse rounded bg-[#1f1f23]" />
354
+ <div className="grid gap-3 sm:grid-cols-2">
355
+ {Array.from({ length: 4 }, (_, index) => (
356
+ <div
357
+ key={index}
358
+ className="h-20 animate-pulse rounded-2xl border border-[#27272a] bg-[#111113]"
359
+ />
360
+ ))}
361
+ </div>
362
+ <div className="h-72 animate-pulse rounded-2xl border border-[#27272a] bg-[#111113]" />
363
+ </div>
364
+ );
365
+ }
366
+
367
+ function ErrorBanner({
368
+ title,
369
+ message,
370
+ actionLabel,
371
+ onAction
372
+ }: {
373
+ title: string;
374
+ message: string;
375
+ actionLabel?: string;
376
+ onAction?: () => void;
377
+ }) {
378
+ return (
379
+ <div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100">
380
+ <div className="flex items-start gap-3">
381
+ <AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
382
+ <div className="min-w-0 flex-1">
383
+ <p className="font-medium text-red-50">{title}</p>
384
+ <p className="mt-1 text-red-100/85">{message}</p>
385
+ {actionLabel && onAction ? (
386
+ <button
387
+ type="button"
388
+ onClick={onAction}
389
+ className="mt-3 inline-flex items-center rounded-full border border-red-300/40 px-3 py-1.5 text-xs font-medium text-red-50 transition hover:border-red-200/60 hover:bg-red-400/10"
390
+ >
391
+ {actionLabel}
392
+ </button>
393
+ ) : null}
394
+ </div>
395
+ </div>
396
+ </div>
397
+ );
398
+ }
399
+
400
+ export default function Page() {
401
+ const workspaceIds = useMemo(() => parseWorkspaceIds(WORKSPACE_ENV), []);
402
+ const configReady = Boolean(DEFAULT_BASE_URL && PUBLIC_TOKEN && workspaceIds.length > 0);
403
+ const client = useMemo(() => createRelayFileClient(DEFAULT_BASE_URL, PUBLIC_TOKEN), []);
404
+ const [workspaceId, setWorkspaceId] = useState<string>(workspaceIds[0] ?? '');
405
+ const [selectedEntry, setSelectedEntry] = useState<SelectedEntry | null>(null);
406
+ const [fullViewEntry, setFullViewEntry] = useState<SelectedEntry | null>(null);
407
+ const [fullViewMode, setFullViewMode] = useState<'view' | 'edit'>('view');
408
+ const [editContent, setEditContent] = useState('');
409
+ const [editSaving, setEditSaving] = useState(false);
410
+ const [editError, setEditError] = useState<string | null>(null);
411
+ const [editMessage, setEditMessage] = useState<string | null>(null);
412
+ const [searchInput, setSearchInput] = useState('');
413
+ const [searchRequestVersion, setSearchRequestVersion] = useState(0);
414
+ const deferredSearch = useDeferredValue(searchInput);
415
+ const [providerFilter, setProviderFilter] = useState('all');
416
+
417
+ const [treeCache, setTreeCache] = useState<TreeCache>({});
418
+ const treeCacheRef = useRef<TreeCache>({});
419
+ const [loadingPaths, setLoadingPaths] = useState<Record<string, boolean>>({});
420
+ const [treeErrors, setTreeErrors] = useState<Record<string, string>>({});
421
+ const [expandedPaths, setExpandedPaths] = useState<Record<string, boolean>>({ '/': true });
422
+
423
+ const [searchResults, setSearchResults] = useState<FileQueryItem[]>([]);
424
+ const [searchLoading, setSearchLoading] = useState(false);
425
+ const [searchError, setSearchError] = useState<string | null>(null);
426
+
427
+ const [fileDetails, setFileDetails] = useState<FileReadResponse | null>(null);
428
+ const [fileLoading, setFileLoading] = useState(false);
429
+ const [fileError, setFileError] = useState<string | null>(null);
430
+
431
+ const [syncStatus, setSyncStatus] = useState<SyncStatusResponse | null>(null);
432
+ const [syncLoading, setSyncLoading] = useState(false);
433
+ const [syncError, setSyncError] = useState<string | null>(null);
434
+
435
+ const activeSearch = deferredSearch.trim();
436
+
437
+ useEffect(() => {
438
+ treeCacheRef.current = treeCache;
439
+ }, [treeCache]);
440
+
441
+ const loadTreePath = useCallback(
442
+ async (path: string, append = false) => {
443
+ if (!configReady || !workspaceId) {
444
+ return;
445
+ }
446
+
447
+ const controller = new AbortController();
448
+ setLoadingPaths((current) => ({ ...current, [path]: true }));
449
+ setTreeErrors((current) => {
450
+ const next = { ...current };
451
+ delete next[path];
452
+ return next;
453
+ });
454
+
455
+ try {
456
+ const cached = treeCacheRef.current[path];
457
+ const response = await client.listTree(workspaceId, {
458
+ path,
459
+ depth: 1,
460
+ cursor: append ? cached?.nextCursor ?? undefined : undefined,
461
+ signal: controller.signal
462
+ });
463
+
464
+ setTreeCache((current) => {
465
+ const previous = current[path];
466
+ const entries = append && previous ? [...previous.entries, ...response.entries] : response.entries;
467
+ return {
468
+ ...current,
469
+ [path]: {
470
+ path: response.path,
471
+ entries,
472
+ nextCursor: response.nextCursor
473
+ }
474
+ };
475
+ });
476
+ } catch (error) {
477
+ setTreeErrors((current) => ({ ...current, [path]: normalizeError(error) }));
478
+ } finally {
479
+ setLoadingPaths((current) => ({ ...current, [path]: false }));
480
+ }
481
+ },
482
+ [client, configReady, workspaceId]
483
+ );
484
+
485
+ const loadSyncStatus = useCallback(async () => {
486
+ if (!configReady || !workspaceId) {
487
+ return;
488
+ }
489
+
490
+ const controller = new AbortController();
491
+ setSyncLoading(true);
492
+ setSyncError(null);
493
+
494
+ try {
495
+ const response = await client.getSyncStatus(workspaceId, controller.signal);
496
+ setSyncStatus(response);
497
+ } catch (error) {
498
+ setSyncError(normalizeError(error));
499
+ } finally {
500
+ setSyncLoading(false);
501
+ }
502
+ }, [client, configReady, workspaceId]);
503
+
504
+ useEffect(() => {
505
+ if (!configReady || !workspaceId) {
506
+ return;
507
+ }
508
+
509
+ setTreeCache({});
510
+ setTreeErrors({});
511
+ setExpandedPaths({ '/': true });
512
+ setSelectedEntry(null);
513
+ setFullViewEntry(null);
514
+ setFileDetails(null);
515
+ setFileError(null);
516
+ setSearchResults([]);
517
+ setSearchError(null);
518
+
519
+ void loadTreePath('/');
520
+ void loadSyncStatus();
521
+ }, [configReady, loadSyncStatus, loadTreePath, workspaceId]);
522
+
523
+ useEffect(() => {
524
+ if (!configReady || !workspaceId) {
525
+ return;
526
+ }
527
+
528
+ const interval = window.setInterval(() => {
529
+ void loadSyncStatus();
530
+ }, 15000);
531
+
532
+ return () => window.clearInterval(interval);
533
+ }, [configReady, loadSyncStatus, workspaceId]);
534
+
535
+ useEffect(() => {
536
+ if (!configReady || !workspaceId || !activeSearch) {
537
+ setSearchResults([]);
538
+ setSearchLoading(false);
539
+ setSearchError(null);
540
+ return;
541
+ }
542
+
543
+ const controller = new AbortController();
544
+ const timeout = window.setTimeout(async () => {
545
+ setSearchLoading(true);
546
+ setSearchError(null);
547
+ try {
548
+ const response = await client.queryFiles(workspaceId, {
549
+ path: activeSearch,
550
+ provider: providerFilter !== 'all' ? providerFilter : undefined,
551
+ limit: 50,
552
+ signal: controller.signal
553
+ });
554
+ setSearchResults(response.items);
555
+ } catch (error) {
556
+ setSearchError(normalizeError(error));
557
+ } finally {
558
+ setSearchLoading(false);
559
+ }
560
+ }, 300);
561
+
562
+ return () => {
563
+ controller.abort();
564
+ window.clearTimeout(timeout);
565
+ };
566
+ }, [activeSearch, client, configReady, providerFilter, searchRequestVersion, workspaceId]);
567
+
568
+ useEffect(() => {
569
+ if (!configReady || !workspaceId || !selectedEntry || selectedEntry.type !== 'file') {
570
+ setFileDetails(null);
571
+ setFileLoading(false);
572
+ setFileError(null);
573
+ return;
574
+ }
575
+
576
+ const controller = new AbortController();
577
+ setFileLoading(true);
578
+ setFileError(null);
579
+
580
+ client
581
+ .readFile(workspaceId, selectedEntry.path, controller.signal)
582
+ .then((response) => {
583
+ setFileDetails(response);
584
+ })
585
+ .catch((error) => {
586
+ if (controller.signal.aborted) {
587
+ return;
588
+ }
589
+ setFileError(normalizeError(error));
590
+ })
591
+ .finally(() => {
592
+ if (!controller.signal.aborted) {
593
+ setFileLoading(false);
594
+ }
595
+ });
596
+
597
+ return () => controller.abort();
598
+ }, [client, configReady, selectedEntry, workspaceId]);
599
+
600
+ useEffect(() => {
601
+ if (!fullViewEntry) {
602
+ return;
603
+ }
604
+
605
+ const previousOverflow = document.body.style.overflow;
606
+ const handleKeyDown = (event: KeyboardEvent) => {
607
+ if (event.key === 'Escape') {
608
+ setFullViewEntry(null);
609
+ }
610
+ };
611
+
612
+ document.body.style.overflow = 'hidden';
613
+ window.addEventListener('keydown', handleKeyDown);
614
+
615
+ return () => {
616
+ document.body.style.overflow = previousOverflow;
617
+ window.removeEventListener('keydown', handleKeyDown);
618
+ };
619
+ }, [fullViewEntry]);
620
+
621
+ const syncProviders = syncStatus?.providers ?? [];
622
+ const providerOptions = useMemo(() => {
623
+ const providers = new Set<string>();
624
+ for (const entry of Object.values(treeCache)) {
625
+ for (const item of entry.entries) {
626
+ const provider = item.provider ?? inferProviderFromPath(item.path, item.type);
627
+ if (provider) {
628
+ providers.add(provider);
629
+ }
630
+ }
631
+ }
632
+ for (const item of searchResults) {
633
+ const provider = item.provider ?? inferProviderFromPath(item.path, 'file');
634
+ if (provider) {
635
+ providers.add(provider);
636
+ }
637
+ }
638
+ for (const provider of syncProviders) {
639
+ providers.add(provider.provider);
640
+ }
641
+ return Array.from(providers).sort((a, b) => a.localeCompare(b));
642
+ }, [searchResults, syncProviders, treeCache]);
643
+
644
+ const healthyProviders = syncProviders.filter((provider) => provider.status === 'healthy').length;
645
+ const unhealthyProviders = syncProviders.length - healthyProviders;
646
+ const syncStatusLabel = syncLoading
647
+ ? 'Refreshing sync status'
648
+ : syncError
649
+ ? 'Sync status unavailable'
650
+ : syncProviders.length === 0
651
+ ? 'No sync metrics'
652
+ : unhealthyProviders === 0
653
+ ? 'All providers healthy'
654
+ : `${healthyProviders}/${syncProviders.length} providers healthy`;
655
+ const syncStatusTitle = syncError
656
+ ? syncError
657
+ : syncProviders.length > 0
658
+ ? syncProviders
659
+ .map((provider) => `${provider.provider}: ${provider.status}${provider.lagSeconds !== undefined ? `, ${provider.lagSeconds}s lag` : ''}`)
660
+ .join('\n')
661
+ : 'No provider health records were returned for this workspace.';
662
+ const syncStatusClassName = syncError
663
+ ? 'border-red-500/30 bg-red-500/10 text-red-100'
664
+ : syncProviders.length === 0
665
+ ? 'border-[#27272a] bg-[#121216] text-[#a1a1aa]'
666
+ : unhealthyProviders > 0
667
+ ? 'border-amber-500/30 bg-amber-500/10 text-amber-100'
668
+ : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100';
669
+ const selectedIsDirectory = selectedEntry?.type === 'dir';
670
+ const rootTree = treeCache['/'];
671
+ const fullViewFile = fullViewEntry && fileDetails?.path === fullViewEntry.path ? fileDetails : null;
672
+ const fullViewIsSelected = fullViewEntry?.path === selectedEntry?.path;
673
+ const fullViewError = fullViewEntry && fullViewIsSelected ? fileError : null;
674
+ const fullViewLoading = Boolean(fullViewEntry && fullViewIsSelected && fileLoading && !fullViewFile);
675
+ const fullViewContent = fullViewFile
676
+ ? formatContentPreview(fullViewFile.content, fullViewFile.encoding, fullViewFile.contentType, fullViewFile.path)
677
+ : '';
678
+
679
+ const closeFullView = useCallback(() => {
680
+ setFullViewEntry(null);
681
+ setFullViewMode('view');
682
+ setEditContent('');
683
+ setEditError(null);
684
+ setEditMessage(null);
685
+ }, []);
686
+
687
+ const openFullView = useCallback((entry: SelectedEntry) => {
688
+ if (entry.type !== 'file') {
689
+ return;
690
+ }
691
+ setSelectedEntry(entry);
692
+ setFullViewEntry(entry);
693
+ setFullViewMode('view');
694
+ setEditContent('');
695
+ setEditError(null);
696
+ setEditMessage(null);
697
+ }, []);
698
+
699
+ const enterEditMode = useCallback(() => {
700
+ if (!fullViewFile || fullViewFile.encoding === 'base64') {
701
+ return;
702
+ }
703
+ setEditContent(formatContentPreview(fullViewFile.content, fullViewFile.encoding, fullViewFile.contentType, fullViewFile.path));
704
+ setEditError(null);
705
+ setEditMessage(null);
706
+ setFullViewMode('edit');
707
+ }, [fullViewFile]);
708
+
709
+ const saveEdit = useCallback(async () => {
710
+ if (!fullViewFile || !fullViewEntry || editSaving) {
711
+ return;
712
+ }
713
+
714
+ let contentToSave = editContent;
715
+ if (isJsonContent(fullViewFile.contentType, fullViewFile.path)) {
716
+ try {
717
+ contentToSave = JSON.stringify(JSON.parse(editContent), null, 2);
718
+ } catch {
719
+ setEditError('This JSON is not valid. Fix the syntax before saving.');
720
+ return;
721
+ }
722
+ }
723
+
724
+ setEditSaving(true);
725
+ setEditError(null);
726
+ setEditMessage(null);
727
+
728
+ try {
729
+ const result = await client.writeFile(workspaceId, fullViewFile.path, {
730
+ contentType: fullViewFile.contentType,
731
+ content: contentToSave,
732
+ ifMatch: fullViewFile.revision
733
+ });
734
+ const nextRevision = result.targetRevision || fullViewFile.revision;
735
+ const nextEntry = { ...fullViewEntry, revision: nextRevision, updatedAt: new Date().toISOString() };
736
+ const writebackMessage = result.writeback?.provider
737
+ ? ` Writeback queued for ${result.writeback.provider}${result.writeback.state ? ` (${result.writeback.state})` : ''}.`
738
+ : '';
739
+ setEditContent(contentToSave);
740
+ setEditMessage(`Saved to RelayFile.${writebackMessage}`);
741
+ setFullViewMode('view');
742
+ setFullViewEntry(nextEntry);
743
+ setSelectedEntry(nextEntry);
744
+ setFileDetails({
745
+ ...fullViewFile,
746
+ content: contentToSave,
747
+ revision: nextRevision,
748
+ lastEditedAt: nextEntry.updatedAt
749
+ });
750
+ } catch (error) {
751
+ setEditError(normalizeError(error));
752
+ } finally {
753
+ setEditSaving(false);
754
+ }
755
+ }, [client, editContent, editSaving, fullViewEntry, fullViewFile, workspaceId]);
756
+
757
+ const filteredEntries = useCallback(
758
+ (entries: TreeEntry[]) =>
759
+ entries.filter((entry) => {
760
+ const provider = entry.provider ?? inferProviderFromPath(entry.path, entry.type);
761
+ return providerFilter === 'all' || provider === providerFilter;
762
+ }),
763
+ [providerFilter]
764
+ );
765
+
766
+ const refreshDashboard = useCallback(() => {
767
+ if (!workspaceId) {
768
+ return;
769
+ }
770
+ void loadTreePath('/');
771
+ void loadSyncStatus();
772
+ if (activeSearch) {
773
+ setSearchRequestVersion((current) => current + 1);
774
+ }
775
+ if (selectedEntry?.type === 'file') {
776
+ setSelectedEntry({ ...selectedEntry });
777
+ }
778
+ }, [activeSearch, loadSyncStatus, loadTreePath, selectedEntry, workspaceId]);
779
+
780
+ const handleWorkspaceChange = (nextWorkspaceId: string) => {
781
+ startTransition(() => {
782
+ setWorkspaceId(nextWorkspaceId);
783
+ });
784
+ };
785
+
786
+ const handleToggleDirectory = async (entry: TreeEntry) => {
787
+ const isExpanded = Boolean(expandedPaths[entry.path]);
788
+ setExpandedPaths((current) => ({
789
+ ...current,
790
+ [entry.path]: !isExpanded
791
+ }));
792
+
793
+ if (!isExpanded && !treeCache[entry.path] && !loadingPaths[entry.path]) {
794
+ await loadTreePath(entry.path);
795
+ }
796
+ };
797
+
798
+ const renderTree = (path: string, depth = 0): ReactNode => {
799
+ const cached = treeCache[path];
800
+ if (!cached) {
801
+ if (loadingPaths[path]) {
802
+ return (
803
+ <div className="ml-3 border-l border-[#202024] pl-4">
804
+ <DirectorySkeleton />
805
+ </div>
806
+ );
807
+ }
808
+ return null;
809
+ }
810
+
811
+ return (
812
+ <div className={depth > 0 ? 'ml-3 border-l border-[#202024] pl-4' : ''}>
813
+ {filteredEntries(cached.entries).map((entry) => {
814
+ const expanded = Boolean(expandedPaths[entry.path]);
815
+ const selected = selectedEntry?.path === entry.path;
816
+ const isLoading = Boolean(loadingPaths[entry.path]);
817
+
818
+ return (
819
+ <div key={entry.path} className="py-1">
820
+ <button
821
+ type="button"
822
+ onClick={() => {
823
+ if (entry.type === 'dir') {
824
+ void handleToggleDirectory(entry);
825
+ }
826
+ setSelectedEntry(entry);
827
+ }}
828
+ onDoubleClick={(event) => {
829
+ if (entry.type === 'file') {
830
+ event.preventDefault();
831
+ openFullView(entry);
832
+ }
833
+ }}
834
+ className={`flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${
835
+ selected
836
+ ? 'bg-[#1a1a22] text-white ring-1 ring-[#3f3f46]'
837
+ : 'text-[#d4d4d8] hover:bg-[#141418]'
838
+ }`}
839
+ style={{ paddingLeft: `${depth * 14 + 12}px` }}
840
+ >
841
+ {entry.type === 'dir' ? (
842
+ expanded ? (
843
+ <ChevronDown className="h-4 w-4 shrink-0 text-[#a1a1aa]" />
844
+ ) : (
845
+ <ChevronRight className="h-4 w-4 shrink-0 text-[#a1a1aa]" />
846
+ )
847
+ ) : (
848
+ <span className="inline-block h-4 w-4 shrink-0" />
849
+ )}
850
+ {entry.type === 'dir' ? (
851
+ expanded ? (
852
+ <FolderOpen className="h-4 w-4 shrink-0 text-sky-300" />
853
+ ) : (
854
+ <Folder className="h-4 w-4 shrink-0 text-sky-300" />
855
+ )
856
+ ) : (
857
+ <FileText className="h-4 w-4 shrink-0 text-zinc-300" />
858
+ )}
859
+ <div className="min-w-0 flex-1">
860
+ <div className="truncate text-sm font-medium">
861
+ {entry.path === '/' ? workspaceId : entry.path.split('/').filter(Boolean).pop()}
862
+ </div>
863
+ <div className="truncate text-xs text-[#71717a]">{entry.path}</div>
864
+ </div>
865
+ {entry.provider ? (
866
+ <span className="rounded-full border border-[#3f3f46] px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-[#a1a1aa]">
867
+ {entry.provider}
868
+ </span>
869
+ ) : null}
870
+ {isLoading ? <Loader2 className="h-4 w-4 animate-spin text-[#71717a]" /> : null}
871
+ </button>
872
+
873
+ {entry.type === 'dir' && expanded ? (
874
+ <div className="mt-1">
875
+ {treeErrors[entry.path] ? (
876
+ <div className="ml-6">
877
+ <ErrorBanner
878
+ title="Directory load failed"
879
+ message={treeErrors[entry.path]}
880
+ actionLabel="Retry"
881
+ onAction={() => {
882
+ void loadTreePath(entry.path);
883
+ }}
884
+ />
885
+ </div>
886
+ ) : null}
887
+ {renderTree(entry.path, depth + 1)}
888
+ {treeCache[entry.path]?.nextCursor ? (
889
+ <div className="ml-6 pt-2">
890
+ <button
891
+ type="button"
892
+ onClick={() => {
893
+ void loadTreePath(entry.path, true);
894
+ }}
895
+ className="rounded-full border border-[#3f3f46] px-3 py-1.5 text-xs font-medium text-[#d4d4d8] transition hover:border-[#52525b] hover:bg-[#16161a]"
896
+ >
897
+ Load more
898
+ </button>
899
+ </div>
900
+ ) : null}
901
+ </div>
902
+ ) : null}
903
+ </div>
904
+ );
905
+ })}
906
+ </div>
907
+ );
908
+ };
909
+
910
+ if (!configReady) {
911
+ return (
912
+ <main className="brand-grid min-h-[calc(100vh-73px)] px-6 py-8">
913
+ <div className="mx-auto max-w-4xl">
914
+ <div className="brand-glass overflow-hidden">
915
+ <div className="border-b border-[#27272a] px-6 py-5">
916
+ <div className="flex items-center gap-3">
917
+ <Sparkles className="h-5 w-5 text-[#8b9bff]" />
918
+ <div>
919
+ <h2 className="text-lg font-semibold text-white">Relayfile dashboard configuration required</h2>
920
+ <p className="mt-1 text-sm text-[#a1a1aa]">
921
+ This page expects public relayfile connection settings so it can load workspace data from the browser.
922
+ </p>
923
+ </div>
924
+ </div>
925
+ </div>
926
+ <div className="space-y-5 px-6 py-6">
927
+ <ErrorBanner
928
+ title="Missing environment variables"
929
+ message="Set a public base URL, token, and at least one workspace id before loading the dashboard."
930
+ />
931
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-5">
932
+ <p className="text-sm font-medium text-white">Expected variables</p>
933
+ <pre className="mt-3 overflow-x-auto rounded-xl border border-[#27272a] bg-[#09090b] p-4 text-xs text-[#d4d4d8]">
934
+ {`NEXT_PUBLIC_RELAYFILE_BASE_URL=http://localhost:8080
935
+ NEXT_PUBLIC_RELAYFILE_TOKEN=dev-token
936
+ NEXT_PUBLIC_RELAYFILE_WORKSPACE_ID=default
937
+ # Optional: comma-separated list for the selector
938
+ NEXT_PUBLIC_RELAYFILE_WORKSPACE_IDS=default,staging,production`}
939
+ </pre>
940
+ </div>
941
+ </div>
942
+ </div>
943
+ </div>
944
+ </main>
945
+ );
946
+ }
947
+
948
+ return (
949
+ <main className="brand-grid min-h-[calc(100vh-73px)] px-4 py-4 sm:px-6 sm:py-6">
950
+ <div className="mx-auto flex max-w-[1600px] flex-col gap-4">
951
+ <section className="brand-glass overflow-hidden lg:flex lg:h-[calc(100vh-121px)] lg:flex-col">
952
+ <div className="flex flex-col gap-4 border-b border-[#27272a] px-5 py-5 lg:flex-none lg:flex-row lg:items-end lg:justify-between">
953
+ <div className="space-y-2">
954
+ <p className="text-xs uppercase tracking-[0.2em] text-[#8b9bff]">Workspace dashboard</p>
955
+ <div className="flex flex-wrap items-center gap-3">
956
+ <h2 className="text-2xl font-semibold text-white">{workspaceId}</h2>
957
+ <div
958
+ title={syncStatusTitle}
959
+ className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs ${syncStatusClassName}`}
960
+ >
961
+ {syncLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
962
+ <span>{syncStatusLabel}</span>
963
+ </div>
964
+ </div>
965
+ <p className="text-sm text-[#a1a1aa]">
966
+ Browse the workspace tree, search indexed files, and inspect relayfile metadata without leaving the dashboard.
967
+ </p>
968
+ </div>
969
+
970
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
971
+ <label className="flex min-w-[220px] flex-col gap-1 text-sm text-[#a1a1aa]">
972
+ <span className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Workspace</span>
973
+ <select
974
+ value={workspaceId}
975
+ onChange={(event) => handleWorkspaceChange(event.target.value)}
976
+ className="rounded-xl border border-[#3f3f46] bg-[#111113] px-3 py-2.5 text-sm text-white outline-none transition focus:border-[#6366f1]"
977
+ >
978
+ {workspaceIds.map((candidate) => (
979
+ <option key={candidate} value={candidate}>
980
+ {candidate}
981
+ </option>
982
+ ))}
983
+ </select>
984
+ </label>
985
+
986
+ <button
987
+ type="button"
988
+ onClick={refreshDashboard}
989
+ className="inline-flex items-center justify-center gap-2 rounded-xl border border-[#3f3f46] bg-[#111113] px-4 py-2.5 text-sm font-medium text-white transition hover:border-[#52525b] hover:bg-[#17171c]"
990
+ >
991
+ <RefreshCcw className="h-4 w-4" />
992
+ Refresh
993
+ </button>
994
+ </div>
995
+ </div>
996
+
997
+ <div className="grid items-start gap-4 px-5 py-5 lg:min-h-0 lg:flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(320px,420px)] lg:items-stretch">
998
+ <section className="brand-card flex min-h-0 flex-col overflow-hidden">
999
+ <div className="border-b border-[#27272a] px-4 py-4">
1000
+ <div className="flex items-start justify-between gap-3">
1001
+ <div>
1002
+ <h3 className="text-sm font-semibold text-white">Search and filter</h3>
1003
+ <p className="mt-1 text-xs text-[#71717a]">Remote query uses relayfile `queryFiles`; tree browsing uses lazy `listTree` calls.</p>
1004
+ </div>
1005
+ <SlidersHorizontal className="h-4 w-4 text-[#71717a]" />
1006
+ </div>
1007
+
1008
+ <div className="mt-4 space-y-3">
1009
+ <label className="relative block">
1010
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#71717a]" />
1011
+ <input
1012
+ value={searchInput}
1013
+ onChange={(event) => setSearchInput(event.target.value)}
1014
+ type="search"
1015
+ placeholder="Search by path or file name"
1016
+ className="w-full rounded-xl border border-[#3f3f46] bg-[#111113] py-2.5 pl-10 pr-4 text-sm text-white outline-none transition placeholder:text-[#52525b] focus:border-[#6366f1]"
1017
+ />
1018
+ </label>
1019
+
1020
+ <label className="flex flex-col gap-1 text-sm text-[#a1a1aa]">
1021
+ <span className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Provider filter</span>
1022
+ <select
1023
+ value={providerFilter}
1024
+ onChange={(event) => setProviderFilter(event.target.value)}
1025
+ className="rounded-xl border border-[#3f3f46] bg-[#111113] px-3 py-2.5 text-sm text-white outline-none transition focus:border-[#6366f1]"
1026
+ >
1027
+ <option value="all">All providers</option>
1028
+ {providerOptions.map((provider) => (
1029
+ <option key={provider} value={provider}>
1030
+ {provider}
1031
+ </option>
1032
+ ))}
1033
+ </select>
1034
+ </label>
1035
+ </div>
1036
+ </div>
1037
+
1038
+ <div className="min-h-[520px] lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
1039
+ {activeSearch ? (
1040
+ <div className="p-4">
1041
+ {searchError ? (
1042
+ <ErrorBanner
1043
+ title="Search failed"
1044
+ message={searchError}
1045
+ actionLabel="Retry"
1046
+ onAction={() => {
1047
+ setSearchRequestVersion((current) => current + 1);
1048
+ }}
1049
+ />
1050
+ ) : null}
1051
+
1052
+ {searchLoading ? (
1053
+ <DirectorySkeleton />
1054
+ ) : (
1055
+ <div className="space-y-2">
1056
+ <div className="flex items-center justify-between px-2 pb-2">
1057
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Search results</p>
1058
+ <p className="text-xs text-[#71717a]">{searchResults.length} files</p>
1059
+ </div>
1060
+
1061
+ {searchResults.length === 0 ? (
1062
+ <div className="rounded-2xl border border-dashed border-[#3f3f46] bg-[#0d0d11] p-6 text-sm text-[#a1a1aa]">
1063
+ No files matched the current query and provider filter.
1064
+ </div>
1065
+ ) : (
1066
+ searchResults.map((item) => {
1067
+ const selected = selectedEntry?.path === item.path;
1068
+ const fileEntry: SelectedEntry = {
1069
+ path: item.path,
1070
+ type: 'file',
1071
+ revision: item.revision,
1072
+ provider: item.provider,
1073
+ providerObjectId: item.providerObjectId,
1074
+ size: item.size,
1075
+ updatedAt: item.lastEditedAt,
1076
+ contentType: item.contentType,
1077
+ properties: item.properties,
1078
+ relations: item.relations,
1079
+ permissions: item.permissions,
1080
+ comments: item.comments
1081
+ };
1082
+ return (
1083
+ <button
1084
+ key={item.path}
1085
+ type="button"
1086
+ onClick={() => setSelectedEntry(fileEntry)}
1087
+ onDoubleClick={(event) => {
1088
+ event.preventDefault();
1089
+ openFullView(fileEntry);
1090
+ }}
1091
+ className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
1092
+ selected
1093
+ ? 'bg-[#1a1a22] text-white ring-1 ring-[#3f3f46]'
1094
+ : 'bg-[#0f0f12] text-[#d4d4d8] hover:bg-[#141418]'
1095
+ }`}
1096
+ >
1097
+ <FileCode2 className="h-4 w-4 shrink-0 text-[#c4b5fd]" />
1098
+ <div className="min-w-0 flex-1">
1099
+ <div className="truncate text-sm font-medium">{item.path.split('/').pop()}</div>
1100
+ <div className="truncate text-xs text-[#71717a]">{item.path}</div>
1101
+ </div>
1102
+ <div className="text-right text-xs text-[#71717a]">
1103
+ <div>{formatBytes(item.size)}</div>
1104
+ <div>{item.provider ?? 'local'}</div>
1105
+ </div>
1106
+ </button>
1107
+ );
1108
+ })
1109
+ )}
1110
+ </div>
1111
+ )}
1112
+ </div>
1113
+ ) : rootTree ? (
1114
+ <div className="p-3">
1115
+ {treeErrors['/'] ? (
1116
+ <div className="p-1">
1117
+ <ErrorBanner
1118
+ title="Workspace tree failed to load"
1119
+ message={treeErrors['/']}
1120
+ actionLabel="Retry"
1121
+ onAction={() => {
1122
+ void loadTreePath('/');
1123
+ }}
1124
+ />
1125
+ </div>
1126
+ ) : null}
1127
+
1128
+ <div className="mb-2 flex items-center justify-between px-2 pb-2">
1129
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">File tree</p>
1130
+ <p className="text-xs text-[#71717a]">{rootTree.entries.length} loaded entries</p>
1131
+ </div>
1132
+
1133
+ {renderTree('/')}
1134
+
1135
+ {rootTree.nextCursor ? (
1136
+ <div className="px-2 pt-3">
1137
+ <button
1138
+ type="button"
1139
+ onClick={() => {
1140
+ void loadTreePath('/', true);
1141
+ }}
1142
+ className="rounded-full border border-[#3f3f46] px-3 py-1.5 text-xs font-medium text-[#d4d4d8] transition hover:border-[#52525b] hover:bg-[#16161a]"
1143
+ >
1144
+ Load more root entries
1145
+ </button>
1146
+ </div>
1147
+ ) : null}
1148
+ </div>
1149
+ ) : (
1150
+ <DirectorySkeleton />
1151
+ )}
1152
+ </div>
1153
+ </section>
1154
+
1155
+ <aside className="brand-card overflow-hidden lg:min-h-0 lg:overflow-y-auto">
1156
+ <div className="border-b border-[#27272a] px-5 py-4">
1157
+ <h3 className="text-sm font-semibold text-white">File details</h3>
1158
+ <p className="mt-1 text-xs text-[#71717a]">Metadata and content preview are fetched on selection via `readFile`.</p>
1159
+ </div>
1160
+
1161
+ {!selectedEntry ? (
1162
+ <div className="flex min-h-[520px] items-center justify-center p-6">
1163
+ <div className="max-w-sm text-center">
1164
+ <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-[#27272a] bg-[#111113]">
1165
+ <FileText className="h-5 w-5 text-[#a1a1aa]" />
1166
+ </div>
1167
+ <h4 className="mt-4 text-base font-medium text-white">Select a file or directory</h4>
1168
+ <p className="mt-2 text-sm text-[#a1a1aa]">
1169
+ Choose an item from the tree or search results to inspect relayfile metadata, sync information, and content.
1170
+ </p>
1171
+ </div>
1172
+ </div>
1173
+ ) : fileLoading ? (
1174
+ <DetailSkeleton />
1175
+ ) : fileError ? (
1176
+ <div className="p-5">
1177
+ <ErrorBanner
1178
+ title="File details failed to load"
1179
+ message={fileError}
1180
+ actionLabel="Retry"
1181
+ onAction={() => {
1182
+ setSelectedEntry({ ...selectedEntry });
1183
+ }}
1184
+ />
1185
+ </div>
1186
+ ) : (
1187
+ <div className="space-y-5 p-5">
1188
+ <div>
1189
+ <div className="flex items-start gap-3">
1190
+ <div className="mt-1 rounded-xl border border-[#27272a] bg-[#111113] p-2">
1191
+ {selectedIsDirectory ? (
1192
+ <FolderOpen className="h-5 w-5 text-sky-300" />
1193
+ ) : (
1194
+ <FileCode2 className="h-5 w-5 text-[#c4b5fd]" />
1195
+ )}
1196
+ </div>
1197
+ <div className="min-w-0 flex-1">
1198
+ <h4 className="truncate text-lg font-semibold text-white">
1199
+ {selectedEntry.path.split('/').filter(Boolean).pop() || selectedEntry.path}
1200
+ </h4>
1201
+ <p className="mt-1 break-all text-sm text-[#71717a]">{selectedEntry.path}</p>
1202
+ </div>
1203
+ </div>
1204
+ </div>
1205
+
1206
+ <div className="grid gap-3 sm:grid-cols-2">
1207
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1208
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Revision</p>
1209
+ <p className="mt-2 break-all text-sm font-medium text-white">
1210
+ {fileDetails?.revision ?? selectedEntry.revision ?? 'Unknown'}
1211
+ </p>
1212
+ </div>
1213
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1214
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Provider</p>
1215
+ <p className="mt-2 text-sm font-medium text-white">
1216
+ {fileDetails?.provider ?? selectedEntry.provider ?? 'Unspecified'}
1217
+ </p>
1218
+ </div>
1219
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1220
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Last updated</p>
1221
+ <p className="mt-2 text-sm font-medium text-white">
1222
+ {formatDate(fileDetails?.lastEditedAt ?? selectedEntry.updatedAt)}
1223
+ </p>
1224
+ </div>
1225
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1226
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">
1227
+ {selectedIsDirectory ? 'Loaded children' : 'Size'}
1228
+ </p>
1229
+ <p className="mt-2 text-sm font-medium text-white">
1230
+ {selectedIsDirectory
1231
+ ? String(treeCache[selectedEntry.path]?.entries.length ?? 0)
1232
+ : formatBytes(selectedEntry.size)}
1233
+ </p>
1234
+ </div>
1235
+ </div>
1236
+
1237
+ {!selectedIsDirectory ? (
1238
+ <>
1239
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1240
+ <div className="flex items-center justify-between gap-3">
1241
+ <div>
1242
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Content preview</p>
1243
+ <p className="mt-1 text-xs text-[#71717a]">{fileDetails?.contentType ?? selectedEntry.contentType ?? 'Unknown content type'}</p>
1244
+ </div>
1245
+ {fileDetails?.encoding === 'base64' ? (
1246
+ <span className="rounded-full border border-[#3f3f46] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[#a1a1aa]">
1247
+ base64
1248
+ </span>
1249
+ ) : null}
1250
+ </div>
1251
+
1252
+ <pre className="mt-4 max-h-[360px] overflow-auto whitespace-pre rounded-2xl border border-[#27272a] bg-[#09090b] p-4 text-xs leading-6 text-[#d4d4d8]">
1253
+ {formatContentPreview(
1254
+ fileDetails?.content ?? '',
1255
+ fileDetails?.encoding,
1256
+ fileDetails?.contentType ?? selectedEntry.contentType,
1257
+ fileDetails?.path ?? selectedEntry.path,
1258
+ 4000
1259
+ )}
1260
+ </pre>
1261
+ </div>
1262
+
1263
+ <div className="grid gap-3">
1264
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1265
+ <div className="flex items-center gap-2">
1266
+ <Shield className="h-4 w-4 text-[#8b9bff]" />
1267
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Semantics</p>
1268
+ </div>
1269
+
1270
+ <div className="mt-4 space-y-4 text-sm">
1271
+ <div>
1272
+ <p className="text-[#71717a]">Properties</p>
1273
+ <div className="mt-2 flex flex-wrap gap-2">
1274
+ {Object.entries(fileDetails?.semantics?.properties ?? selectedEntry.properties ?? {}).length > 0 ? (
1275
+ Object.entries(fileDetails?.semantics?.properties ?? selectedEntry.properties ?? {}).map(([key, value]) => (
1276
+ <span
1277
+ key={key}
1278
+ className="rounded-full border border-[#3f3f46] bg-[#111113] px-3 py-1 text-xs text-[#d4d4d8]"
1279
+ >
1280
+ {key}: {value}
1281
+ </span>
1282
+ ))
1283
+ ) : (
1284
+ <span className="text-[#a1a1aa]">No properties</span>
1285
+ )}
1286
+ </div>
1287
+ </div>
1288
+
1289
+ <div>
1290
+ <p className="text-[#71717a]">Relations</p>
1291
+ <div className="mt-2 flex flex-wrap gap-2">
1292
+ {(fileDetails?.semantics?.relations ?? selectedEntry.relations ?? []).length > 0 ? (
1293
+ (fileDetails?.semantics?.relations ?? selectedEntry.relations ?? []).map((relation) => (
1294
+ <span
1295
+ key={relation}
1296
+ className="rounded-full border border-[#3f3f46] bg-[#111113] px-3 py-1 text-xs text-[#d4d4d8]"
1297
+ >
1298
+ {relation}
1299
+ </span>
1300
+ ))
1301
+ ) : (
1302
+ <span className="text-[#a1a1aa]">No relations</span>
1303
+ )}
1304
+ </div>
1305
+ </div>
1306
+
1307
+ <div>
1308
+ <p className="text-[#71717a]">Permissions</p>
1309
+ <div className="mt-2 flex flex-wrap gap-2">
1310
+ {(fileDetails?.semantics?.permissions ?? selectedEntry.permissions ?? []).length > 0 ? (
1311
+ (fileDetails?.semantics?.permissions ?? selectedEntry.permissions ?? []).map((permission) => (
1312
+ <span
1313
+ key={permission}
1314
+ className="rounded-full border border-[#3f3f46] bg-[#111113] px-3 py-1 text-xs text-[#d4d4d8]"
1315
+ >
1316
+ {permission}
1317
+ </span>
1318
+ ))
1319
+ ) : (
1320
+ <span className="text-[#a1a1aa]">No permissions</span>
1321
+ )}
1322
+ </div>
1323
+ </div>
1324
+ </div>
1325
+ </div>
1326
+ </div>
1327
+ </>
1328
+ ) : (
1329
+ <div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
1330
+ <p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Directory summary</p>
1331
+ <p className="mt-3 text-sm text-[#d4d4d8]">
1332
+ Expand this directory in the tree to lazy-load children with relayfile `listTree(workspaceId, {'{'} path, depth: 1 {'}'})`.
1333
+ </p>
1334
+ {treeErrors[selectedEntry.path] ? (
1335
+ <div className="mt-4">
1336
+ <ErrorBanner
1337
+ title="Children unavailable"
1338
+ message={treeErrors[selectedEntry.path]}
1339
+ actionLabel="Retry"
1340
+ onAction={() => {
1341
+ void loadTreePath(selectedEntry.path);
1342
+ }}
1343
+ />
1344
+ </div>
1345
+ ) : null}
1346
+ </div>
1347
+ )}
1348
+ </div>
1349
+ )}
1350
+ </aside>
1351
+ </div>
1352
+ </section>
1353
+
1354
+ {fullViewEntry ? (
1355
+ <div
1356
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6 backdrop-blur-md"
1357
+ role="dialog"
1358
+ aria-modal="true"
1359
+ aria-labelledby="file-viewer-title"
1360
+ onMouseDown={closeFullView}
1361
+ >
1362
+ <div
1363
+ className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-[#3f3f46] bg-[#111113] shadow-2xl shadow-black/50"
1364
+ onMouseDown={(event) => event.stopPropagation()}
1365
+ >
1366
+ <div className="border-b border-[#27272a] bg-[#18181b] px-5 py-4">
1367
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
1368
+ <div className="min-w-0">
1369
+ <div className="flex items-center gap-3">
1370
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[#3f3f46] bg-[#0f0f12]">
1371
+ <Maximize2 className="h-4 w-4 text-[#8b9bff]" />
1372
+ </div>
1373
+ <div className="min-w-0">
1374
+ <h3 id="file-viewer-title" className="truncate text-lg font-semibold text-white">
1375
+ {fullViewEntry.path.split('/').filter(Boolean).pop() || fullViewEntry.path}
1376
+ </h3>
1377
+ <p className="mt-1 break-all text-xs text-[#a1a1aa]">{fullViewEntry.path}</p>
1378
+ </div>
1379
+ </div>
1380
+
1381
+ <div className="mt-4 flex flex-wrap gap-2 text-xs">
1382
+ <span className="rounded-full border border-[#3f3f46] bg-[#0f0f12] px-3 py-1 text-[#d4d4d8]">
1383
+ {fullViewFile?.contentType ?? fullViewEntry.contentType ?? 'Unknown content type'}
1384
+ </span>
1385
+ <span className="rounded-full border border-[#3f3f46] bg-[#0f0f12] px-3 py-1 text-[#d4d4d8]">
1386
+ {fullViewFile?.revision ?? fullViewEntry.revision ?? 'Unknown revision'}
1387
+ </span>
1388
+ {fullViewFile?.provider ?? fullViewEntry.provider ? (
1389
+ <span className="rounded-full border border-[#3f3f46] bg-[#0f0f12] px-3 py-1 text-[#d4d4d8]">
1390
+ {fullViewFile?.provider ?? fullViewEntry.provider}
1391
+ </span>
1392
+ ) : null}
1393
+ </div>
1394
+ </div>
1395
+
1396
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
1397
+ {fullViewFile && fullViewFile.encoding !== 'base64' ? (
1398
+ <div className="inline-flex rounded-xl border border-[#3f3f46] bg-[#0f0f12] p-1">
1399
+ <button
1400
+ type="button"
1401
+ onClick={() => setFullViewMode('view')}
1402
+ className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
1403
+ fullViewMode === 'view' ? 'bg-[#27272a] text-white' : 'text-[#a1a1aa] hover:text-white'
1404
+ }`}
1405
+ >
1406
+ View
1407
+ </button>
1408
+ <button
1409
+ type="button"
1410
+ onClick={enterEditMode}
1411
+ className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
1412
+ fullViewMode === 'edit' ? 'bg-[#27272a] text-white' : 'text-[#a1a1aa] hover:text-white'
1413
+ }`}
1414
+ >
1415
+ Edit
1416
+ </button>
1417
+ </div>
1418
+ ) : null}
1419
+
1420
+ <button
1421
+ type="button"
1422
+ onClick={closeFullView}
1423
+ className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-[#3f3f46] bg-[#0f0f12] text-[#a1a1aa] transition hover:border-[#52525b] hover:text-white"
1424
+ aria-label="Close file viewer"
1425
+ >
1426
+ <X className="h-4 w-4" />
1427
+ </button>
1428
+ </div>
1429
+ </div>
1430
+ </div>
1431
+
1432
+ <div className="min-h-0 flex-1 overflow-hidden bg-[#09090b]">
1433
+ {fullViewLoading ? (
1434
+ <div className="flex h-[68vh] items-center justify-center">
1435
+ <Loader2 className="h-6 w-6 animate-spin text-[#71717a]" />
1436
+ </div>
1437
+ ) : fullViewError ? (
1438
+ <div className="p-6">
1439
+ <ErrorBanner
1440
+ title="File failed to load"
1441
+ message={fullViewError}
1442
+ actionLabel="Retry"
1443
+ onAction={() => {
1444
+ setSelectedEntry({ ...fullViewEntry });
1445
+ }}
1446
+ />
1447
+ </div>
1448
+ ) : fullViewFile ? (
1449
+ fullViewMode === 'edit' ? (
1450
+ <div className="flex h-[68vh] flex-col">
1451
+ <div className="flex items-center justify-between gap-3 border-b border-[#27272a] bg-[#0f0f12] px-5 py-3">
1452
+ <div className="min-w-0 text-xs text-[#a1a1aa]">
1453
+ <span className="font-medium text-[#d4d4d8]">Editing</span>
1454
+ <span className="ml-2">{fullViewFile.contentType}</span>
1455
+ </div>
1456
+ <div className="flex items-center gap-2">
1457
+ <button
1458
+ type="button"
1459
+ onClick={() => {
1460
+ setFullViewMode('view');
1461
+ setEditError(null);
1462
+ }}
1463
+ className="rounded-xl border border-[#3f3f46] px-3 py-2 text-xs font-medium text-[#d4d4d8] transition hover:border-[#52525b] hover:bg-[#17171c]"
1464
+ >
1465
+ Cancel
1466
+ </button>
1467
+ <button
1468
+ type="button"
1469
+ onClick={() => {
1470
+ void saveEdit();
1471
+ }}
1472
+ disabled={editSaving}
1473
+ className="inline-flex items-center gap-2 rounded-xl border border-[#6366f1]/50 bg-[#6366f1]/20 px-3 py-2 text-xs font-medium text-white transition hover:border-[#8b9bff] hover:bg-[#6366f1]/30 disabled:cursor-not-allowed disabled:opacity-60"
1474
+ >
1475
+ {editSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
1476
+ Save changes
1477
+ </button>
1478
+ </div>
1479
+ </div>
1480
+
1481
+ {editError ? (
1482
+ <div className="border-b border-red-500/20 bg-red-500/10 px-5 py-3 text-sm text-red-100">{editError}</div>
1483
+ ) : null}
1484
+
1485
+ <textarea
1486
+ value={editContent}
1487
+ onChange={(event) => {
1488
+ setEditContent(event.target.value);
1489
+ setEditError(null);
1490
+ setEditMessage(null);
1491
+ }}
1492
+ spellCheck={false}
1493
+ className="min-h-0 flex-1 resize-none bg-[#09090b] p-5 font-mono text-sm leading-6 text-[#e4e4e7] outline-none selection:bg-[#6366f1]/30"
1494
+ />
1495
+ </div>
1496
+ ) : (
1497
+ <div className="h-[68vh] overflow-auto p-5">
1498
+ {editMessage ? (
1499
+ <div className="mb-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
1500
+ {editMessage}
1501
+ </div>
1502
+ ) : null}
1503
+ <pre className="min-h-full rounded-2xl border border-[#27272a] bg-[#0d0d11] p-5 font-mono text-sm leading-6 text-[#e4e4e7]">
1504
+ {fullViewContent}
1505
+ </pre>
1506
+ </div>
1507
+ )
1508
+ ) : (
1509
+ <div className="flex h-[68vh] items-center justify-center text-sm text-[#a1a1aa]">
1510
+ Select a file to load its content.
1511
+ </div>
1512
+ )}
1513
+ </div>
1514
+ </div>
1515
+ </div>
1516
+ ) : null}
1517
+ </div>
1518
+ </main>
1519
+ );
1520
+ }