@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,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
+ }