@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.
- package/next-env.d.ts +5 -0
- package/next.config.js +10 -0
- package/package.json +62 -0
- package/postcss.config.mjs +5 -0
- package/src/app/globals.css +45 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/page.tsx +1520 -0
- package/src/components/FileDetails.tsx +442 -0
- package/src/components/FileTree.tsx +490 -0
- package/src/components/WorkspaceSelector.tsx +127 -0
- package/src/hooks/useFileEvents.ts +267 -0
- package/src/hooks/useFileTree.ts +124 -0
- package/src/lib/relayfile-client.ts +459 -0
- package/tsconfig.json +23 -0
- package/wrangler.file-observer.toml +10 -0
package/src/app/page.tsx
ADDED
|
@@ -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
|
+
}
|