@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
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
ChevronDown,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
FileCode2,
|
|
8
|
+
FileText,
|
|
9
|
+
Folder,
|
|
10
|
+
FolderOpen
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
export type FileTreeNodeType = 'file' | 'folder';
|
|
14
|
+
|
|
15
|
+
export interface FileTreeNode {
|
|
16
|
+
path: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
type: FileTreeNodeType;
|
|
19
|
+
children?: FileTreeNode[];
|
|
20
|
+
size?: number;
|
|
21
|
+
extension?: string;
|
|
22
|
+
contentType?: string;
|
|
23
|
+
lastModified?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
metadata?: Record<string, string | number | boolean | null | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FileTreeProps {
|
|
29
|
+
nodes: FileTreeNode[];
|
|
30
|
+
className?: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
initialExpandedPaths?: string[];
|
|
34
|
+
initialSelectedPath?: string;
|
|
35
|
+
onSelect?: (node: FileTreeNode) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FileBadgeStyle {
|
|
39
|
+
badge: string;
|
|
40
|
+
badgeLabel: string;
|
|
41
|
+
color: string;
|
|
42
|
+
icon: typeof FileCode2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function joinClasses(...values: Array<string | false | null | undefined>): string {
|
|
46
|
+
return values.filter(Boolean).join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getBaseName(path: string): string {
|
|
50
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
51
|
+
if (!trimmed || trimmed === '/') {
|
|
52
|
+
return '/';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const segments = trimmed.split('/').filter(Boolean);
|
|
56
|
+
return segments[segments.length - 1] ?? trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getNodeLabel(node: FileTreeNode): string {
|
|
60
|
+
return node.name?.trim() || getBaseName(node.path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getFileExtension(node: FileTreeNode): string {
|
|
64
|
+
if (node.extension?.trim()) {
|
|
65
|
+
return node.extension.replace(/^\./, '').toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const label = getNodeLabel(node);
|
|
69
|
+
const parts = label.split('.');
|
|
70
|
+
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getBadgeStyle(node: FileTreeNode): FileBadgeStyle {
|
|
74
|
+
const extension = getFileExtension(node);
|
|
75
|
+
|
|
76
|
+
if (extension === 'ts' || extension === 'tsx') {
|
|
77
|
+
return {
|
|
78
|
+
badge: 'border-sky-400/30 bg-sky-400/10 text-sky-200',
|
|
79
|
+
badgeLabel: extension.toUpperCase(),
|
|
80
|
+
color: 'text-sky-300',
|
|
81
|
+
icon: FileCode2
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (extension === 'js' || extension === 'jsx' || extension === 'mjs' || extension === 'cjs') {
|
|
86
|
+
return {
|
|
87
|
+
badge: 'border-amber-400/30 bg-amber-400/10 text-amber-200',
|
|
88
|
+
badgeLabel: extension.toUpperCase(),
|
|
89
|
+
color: 'text-amber-300',
|
|
90
|
+
icon: FileCode2
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (extension === 'json') {
|
|
95
|
+
return {
|
|
96
|
+
badge: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200',
|
|
97
|
+
badgeLabel: '{}',
|
|
98
|
+
color: 'text-emerald-300',
|
|
99
|
+
icon: FileCode2
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (extension === 'md' || extension === 'mdx') {
|
|
104
|
+
return {
|
|
105
|
+
badge: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-200',
|
|
106
|
+
badgeLabel: 'MD',
|
|
107
|
+
color: 'text-fuchsia-300',
|
|
108
|
+
icon: FileText
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (extension === 'yml' || extension === 'yaml' || extension === 'toml') {
|
|
113
|
+
return {
|
|
114
|
+
badge: 'border-orange-400/30 bg-orange-400/10 text-orange-200',
|
|
115
|
+
badgeLabel: extension.toUpperCase(),
|
|
116
|
+
color: 'text-orange-300',
|
|
117
|
+
icon: FileText
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (extension === 'css' || extension === 'scss') {
|
|
122
|
+
return {
|
|
123
|
+
badge: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200',
|
|
124
|
+
badgeLabel: extension.toUpperCase(),
|
|
125
|
+
color: 'text-cyan-300',
|
|
126
|
+
icon: FileCode2
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
badge: 'border-zinc-400/20 bg-zinc-400/10 text-zinc-200',
|
|
132
|
+
badgeLabel: extension ? extension.toUpperCase().slice(0, 4) : 'FILE',
|
|
133
|
+
color: 'text-zinc-300',
|
|
134
|
+
icon: FileText
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatBytes(value?: number): string {
|
|
139
|
+
if (value === undefined || Number.isNaN(value)) {
|
|
140
|
+
return 'Unknown';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (value < 1024) {
|
|
144
|
+
return `${value} B`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
148
|
+
let size = value / 1024;
|
|
149
|
+
let unitIndex = 0;
|
|
150
|
+
|
|
151
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
152
|
+
size /= 1024;
|
|
153
|
+
unitIndex += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatDate(value?: string): string {
|
|
160
|
+
if (!value) {
|
|
161
|
+
return 'Unknown';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const date = new Date(value);
|
|
165
|
+
if (Number.isNaN(date.getTime())) {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
170
|
+
dateStyle: 'medium',
|
|
171
|
+
timeStyle: 'short'
|
|
172
|
+
}).format(date);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findNodeByPath(nodes: FileTreeNode[], path: string): FileTreeNode | null {
|
|
176
|
+
for (const node of nodes) {
|
|
177
|
+
if (node.path === path) {
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (node.children?.length) {
|
|
182
|
+
const match = findNodeByPath(node.children, path);
|
|
183
|
+
if (match) {
|
|
184
|
+
return match;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function findFirstNodePath(nodes: FileTreeNode[]): string | null {
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
if (node.type === 'file') {
|
|
195
|
+
return node.path;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (node.children?.length) {
|
|
199
|
+
const childPath = findFirstNodePath(node.children);
|
|
200
|
+
if (childPath) {
|
|
201
|
+
return childPath;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return nodes[0]?.path ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildAncestorPaths(path?: string): string[] {
|
|
210
|
+
if (!path) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const segments = path.split('/').filter(Boolean);
|
|
215
|
+
const ancestors: string[] = [];
|
|
216
|
+
|
|
217
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
218
|
+
ancestors.push(`/${segments.slice(0, index + 1).join('/')}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return ancestors;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildInitialExpandedState(nodes: FileTreeNode[], expandedPaths?: string[], selectedPath?: string): Record<string, boolean> {
|
|
225
|
+
const nextState: Record<string, boolean> = {};
|
|
226
|
+
|
|
227
|
+
for (const path of expandedPaths ?? []) {
|
|
228
|
+
nextState[path] = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const node of nodes) {
|
|
232
|
+
if (node.type === 'folder') {
|
|
233
|
+
nextState[node.path] = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const path of buildAncestorPaths(selectedPath)) {
|
|
238
|
+
nextState[path] = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return nextState;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function MetadataList({ metadata }: { metadata?: FileTreeNode['metadata'] }) {
|
|
245
|
+
const entries = Object.entries(metadata ?? {}).filter(([, value]) => value !== undefined && value !== null);
|
|
246
|
+
|
|
247
|
+
if (!entries.length) {
|
|
248
|
+
return <p className="text-sm text-[#71717a]">No extra metadata</p>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<dl className="space-y-3">
|
|
253
|
+
{entries.map(([key, value]) => (
|
|
254
|
+
<div key={key} className="rounded-xl border border-[#27272a] bg-[#0f0f12] px-3 py-2.5">
|
|
255
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">{key}</dt>
|
|
256
|
+
<dd className="mt-1 break-words text-sm text-[#f4f4f5]">{String(value)}</dd>
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</dl>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function FileTree({
|
|
264
|
+
nodes,
|
|
265
|
+
className,
|
|
266
|
+
title = 'Workspace files',
|
|
267
|
+
emptyMessage = 'No files available.',
|
|
268
|
+
initialExpandedPaths,
|
|
269
|
+
initialSelectedPath,
|
|
270
|
+
onSelect
|
|
271
|
+
}: FileTreeProps) {
|
|
272
|
+
const [expandedPaths, setExpandedPaths] = useState<Record<string, boolean>>(() =>
|
|
273
|
+
buildInitialExpandedState(nodes, initialExpandedPaths, initialSelectedPath)
|
|
274
|
+
);
|
|
275
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(() => initialSelectedPath ?? findFirstNodePath(nodes));
|
|
276
|
+
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
setExpandedPaths(buildInitialExpandedState(nodes, initialExpandedPaths, initialSelectedPath));
|
|
279
|
+
}, [initialExpandedPaths, initialSelectedPath, nodes]);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (selectedPath && findNodeByPath(nodes, selectedPath)) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setSelectedPath(initialSelectedPath ?? findFirstNodePath(nodes));
|
|
287
|
+
}, [initialSelectedPath, nodes, selectedPath]);
|
|
288
|
+
|
|
289
|
+
const selectedNode = useMemo(() => {
|
|
290
|
+
if (!selectedPath) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return findNodeByPath(nodes, selectedPath);
|
|
295
|
+
}, [nodes, selectedPath]);
|
|
296
|
+
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (selectedNode) {
|
|
299
|
+
onSelect?.(selectedNode);
|
|
300
|
+
}
|
|
301
|
+
}, [onSelect, selectedNode]);
|
|
302
|
+
|
|
303
|
+
const renderNode = (node: FileTreeNode, depth = 0) => {
|
|
304
|
+
const isSelected = selectedPath === node.path;
|
|
305
|
+
const isFolder = node.type === 'folder';
|
|
306
|
+
const isExpanded = Boolean(expandedPaths[node.path]);
|
|
307
|
+
const badgeStyle = !isFolder ? getBadgeStyle(node) : null;
|
|
308
|
+
const FileIcon = badgeStyle?.icon ?? FileText;
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div key={node.path}>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onClick={() => {
|
|
315
|
+
setSelectedPath(node.path);
|
|
316
|
+
|
|
317
|
+
if (isFolder) {
|
|
318
|
+
setExpandedPaths((current) => ({
|
|
319
|
+
...current,
|
|
320
|
+
[node.path]: !current[node.path]
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
}}
|
|
324
|
+
className={joinClasses(
|
|
325
|
+
'group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition',
|
|
326
|
+
isSelected
|
|
327
|
+
? 'bg-[#1a1a22] text-white ring-1 ring-[#3f3f46]'
|
|
328
|
+
: 'text-[#d4d4d8] hover:bg-[#141418]'
|
|
329
|
+
)}
|
|
330
|
+
style={{ paddingLeft: `${depth * 18 + 12}px` }}
|
|
331
|
+
>
|
|
332
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-[#71717a]">
|
|
333
|
+
{isFolder ? (
|
|
334
|
+
isExpanded ? (
|
|
335
|
+
<ChevronDown className="h-4 w-4" />
|
|
336
|
+
) : (
|
|
337
|
+
<ChevronRight className="h-4 w-4" />
|
|
338
|
+
)
|
|
339
|
+
) : null}
|
|
340
|
+
</span>
|
|
341
|
+
|
|
342
|
+
{isFolder ? (
|
|
343
|
+
isExpanded ? (
|
|
344
|
+
<FolderOpen className="h-4 w-4 shrink-0 text-sky-300" />
|
|
345
|
+
) : (
|
|
346
|
+
<Folder className="h-4 w-4 shrink-0 text-sky-300" />
|
|
347
|
+
)
|
|
348
|
+
) : (
|
|
349
|
+
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-[#2f2f35] bg-[#111113]">
|
|
350
|
+
<FileIcon className={joinClasses('h-4 w-4', badgeStyle?.color)} />
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
<div className="min-w-0 flex-1">
|
|
355
|
+
<div className="truncate text-sm font-medium">{getNodeLabel(node)}</div>
|
|
356
|
+
<div className="truncate text-xs text-[#71717a]">{node.path}</div>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
{!isFolder ? (
|
|
360
|
+
<span
|
|
361
|
+
className={joinClasses(
|
|
362
|
+
'rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]',
|
|
363
|
+
badgeStyle?.badge
|
|
364
|
+
)}
|
|
365
|
+
>
|
|
366
|
+
{badgeStyle?.badgeLabel}
|
|
367
|
+
</span>
|
|
368
|
+
) : (
|
|
369
|
+
<span className="rounded-full border border-[#3f3f46] px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-[#a1a1aa]">
|
|
370
|
+
{node.children?.length ?? 0} items
|
|
371
|
+
</span>
|
|
372
|
+
)}
|
|
373
|
+
</button>
|
|
374
|
+
|
|
375
|
+
{isFolder && isExpanded && node.children?.length ? (
|
|
376
|
+
<div className="mt-1 space-y-1 border-l border-[#202024] pl-3">
|
|
377
|
+
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
378
|
+
</div>
|
|
379
|
+
) : null}
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<section
|
|
386
|
+
className={joinClasses(
|
|
387
|
+
'grid gap-4 rounded-2xl border border-[#27272a] bg-[#101013] p-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]',
|
|
388
|
+
className
|
|
389
|
+
)}
|
|
390
|
+
>
|
|
391
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0b0b0d]">
|
|
392
|
+
<div className="border-b border-[#27272a] px-4 py-3">
|
|
393
|
+
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-[#a1a1aa]">{title}</h2>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div className="space-y-1 p-3">
|
|
397
|
+
{nodes.length ? (
|
|
398
|
+
nodes.map((node) => renderNode(node))
|
|
399
|
+
) : (
|
|
400
|
+
<div className="rounded-xl border border-dashed border-[#27272a] px-4 py-8 text-center text-sm text-[#71717a]">
|
|
401
|
+
{emptyMessage}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0b0b0d]">
|
|
408
|
+
<div className="border-b border-[#27272a] px-4 py-3">
|
|
409
|
+
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-[#a1a1aa]">Details</h2>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div className="space-y-4 p-4">
|
|
413
|
+
{selectedNode ? (
|
|
414
|
+
<>
|
|
415
|
+
<div className="space-y-2">
|
|
416
|
+
<div className="flex items-start gap-3">
|
|
417
|
+
{selectedNode.type === 'folder' ? (
|
|
418
|
+
<FolderOpen className="mt-0.5 h-5 w-5 shrink-0 text-sky-300" />
|
|
419
|
+
) : (
|
|
420
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-[#2f2f35] bg-[#111113]">
|
|
421
|
+
{(() => {
|
|
422
|
+
const badgeStyle = getBadgeStyle(selectedNode);
|
|
423
|
+
const Icon = badgeStyle.icon;
|
|
424
|
+
return <Icon className={joinClasses('h-4 w-4', badgeStyle.color)} />;
|
|
425
|
+
})()}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
<div className="min-w-0">
|
|
430
|
+
<p className="truncate text-lg font-semibold text-white">{getNodeLabel(selectedNode)}</p>
|
|
431
|
+
<p className="mt-1 break-all text-sm text-[#71717a]">{selectedNode.path}</p>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{selectedNode.description ? (
|
|
436
|
+
<p className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-2 text-sm text-[#d4d4d8]">
|
|
437
|
+
{selectedNode.description}
|
|
438
|
+
</p>
|
|
439
|
+
) : null}
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
443
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#111113] p-3">
|
|
444
|
+
<p className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Type</p>
|
|
445
|
+
<p className="mt-2 text-sm font-medium text-[#f4f4f5]">
|
|
446
|
+
{selectedNode.type === 'folder' ? 'Folder' : 'File'}
|
|
447
|
+
</p>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#111113] p-3">
|
|
451
|
+
<p className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Extension</p>
|
|
452
|
+
<p className="mt-2 text-sm font-medium text-[#f4f4f5]">
|
|
453
|
+
{selectedNode.type === 'folder' ? 'Folder' : getFileExtension(selectedNode) || 'Unknown'}
|
|
454
|
+
</p>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#111113] p-3">
|
|
458
|
+
<p className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Size</p>
|
|
459
|
+
<p className="mt-2 text-sm font-medium text-[#f4f4f5]">{formatBytes(selectedNode.size)}</p>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#111113] p-3">
|
|
463
|
+
<p className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">
|
|
464
|
+
{selectedNode.type === 'folder' ? 'Children' : 'Modified'}
|
|
465
|
+
</p>
|
|
466
|
+
<p className="mt-2 text-sm font-medium text-[#f4f4f5]">
|
|
467
|
+
{selectedNode.type === 'folder'
|
|
468
|
+
? `${selectedNode.children?.length ?? 0} items`
|
|
469
|
+
: formatDate(selectedNode.lastModified)}
|
|
470
|
+
</p>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div>
|
|
475
|
+
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#71717a]">Metadata</p>
|
|
476
|
+
<MetadataList metadata={selectedNode.metadata} />
|
|
477
|
+
</div>
|
|
478
|
+
</>
|
|
479
|
+
) : (
|
|
480
|
+
<div className="rounded-xl border border-dashed border-[#27272a] px-4 py-10 text-center text-sm text-[#71717a]">
|
|
481
|
+
Select a file or folder to inspect its details.
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</section>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export default FileTree;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, Loader2 } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface WorkspaceSelectorOption {
|
|
6
|
+
id: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WorkspaceSelectorProps {
|
|
12
|
+
workspaces: ReadonlyArray<string | WorkspaceSelectorOption>;
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (workspaceId: string) => void;
|
|
15
|
+
label?: string;
|
|
16
|
+
emptyLabel?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function joinClasses(...values: Array<string | false | null | undefined>): string {
|
|
23
|
+
return values.filter(Boolean).join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeWorkspaceOption(workspace: string | WorkspaceSelectorOption): WorkspaceSelectorOption {
|
|
27
|
+
if (typeof workspace === 'string') {
|
|
28
|
+
return {
|
|
29
|
+
id: workspace,
|
|
30
|
+
label: workspace
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return workspace;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getWorkspaceLabel(workspace: WorkspaceSelectorOption): string {
|
|
38
|
+
return workspace.label?.trim() || workspace.name?.trim() || workspace.id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function WorkspaceSelector({
|
|
42
|
+
workspaces,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
label = 'Workspace',
|
|
46
|
+
emptyLabel = 'No workspaces available',
|
|
47
|
+
disabled = false,
|
|
48
|
+
loading = false,
|
|
49
|
+
className
|
|
50
|
+
}: WorkspaceSelectorProps) {
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const options = workspaces.map(normalizeWorkspaceOption).filter((workspace) => {
|
|
53
|
+
if (!workspace.id || seen.has(workspace.id)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
seen.add(workspace.id);
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const selectedWorkspace = options.find((workspace) => workspace.id === value) ?? options[0] ?? null;
|
|
62
|
+
const selectValue = selectedWorkspace?.id ?? '';
|
|
63
|
+
const selectedLabel = selectedWorkspace ? getWorkspaceLabel(selectedWorkspace) : emptyLabel;
|
|
64
|
+
const selectedId = selectedWorkspace && selectedLabel !== selectedWorkspace.id ? selectedWorkspace.id : null;
|
|
65
|
+
const isDisabled = disabled || loading || options.length === 0;
|
|
66
|
+
|
|
67
|
+
const handleChange = (nextWorkspaceId: string) => {
|
|
68
|
+
if (!nextWorkspaceId || nextWorkspaceId === value) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onChange(nextWorkspaceId);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={joinClasses('flex min-w-[220px] flex-col gap-2', className)}>
|
|
77
|
+
<label className="flex flex-col gap-1 text-sm text-[#a1a1aa]">
|
|
78
|
+
<span className="text-xs uppercase tracking-[0.16em] text-[#71717a]">{label}</span>
|
|
79
|
+
|
|
80
|
+
<div className="relative">
|
|
81
|
+
<select
|
|
82
|
+
value={selectValue}
|
|
83
|
+
onChange={(event) => handleChange(event.target.value)}
|
|
84
|
+
disabled={isDisabled}
|
|
85
|
+
className={joinClasses(
|
|
86
|
+
'w-full appearance-none rounded-xl border border-[#3f3f46] bg-[#111113] px-3 py-2.5 pr-10 text-sm text-white outline-none transition',
|
|
87
|
+
'focus:border-[#6366f1] disabled:cursor-not-allowed disabled:border-[#27272a] disabled:text-[#71717a]'
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{options.length === 0 ? (
|
|
91
|
+
<option value="">{emptyLabel}</option>
|
|
92
|
+
) : (
|
|
93
|
+
options.map((workspace) => (
|
|
94
|
+
<option key={workspace.id} value={workspace.id}>
|
|
95
|
+
{getWorkspaceLabel(workspace)}
|
|
96
|
+
</option>
|
|
97
|
+
))
|
|
98
|
+
)}
|
|
99
|
+
</select>
|
|
100
|
+
|
|
101
|
+
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[#71717a]">
|
|
102
|
+
<ChevronDown className="h-4 w-4" />
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
</label>
|
|
106
|
+
|
|
107
|
+
<div className="flex items-center justify-between gap-3 rounded-xl border border-[#27272a] bg-[#111113] px-3 py-2">
|
|
108
|
+
<div className="min-w-0">
|
|
109
|
+
<p className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Active workspace</p>
|
|
110
|
+
<p className="truncate text-sm font-medium text-white">{selectedLabel}</p>
|
|
111
|
+
{selectedId ? <p className="truncate text-xs text-[#71717a]">{selectedId}</p> : null}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="shrink-0 rounded-full border border-[#27272a] bg-[#18181b] px-2.5 py-1 text-xs text-[#a1a1aa]">
|
|
115
|
+
{loading ? (
|
|
116
|
+
<span className="inline-flex items-center gap-1.5">
|
|
117
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
118
|
+
Switching
|
|
119
|
+
</span>
|
|
120
|
+
) : (
|
|
121
|
+
`${options.length} workspace${options.length === 1 ? '' : 's'}`
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|