@aprovan/patchwork-editor 0.1.1-dev.6bd527d → 0.1.2-dev.ba8f277
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/.turbo/turbo-build.log +3 -3
- package/dist/components/CodePreview.d.ts +10 -1
- package/dist/components/MarkdownPreview.d.ts +8 -0
- package/dist/components/SaveStatusButton.d.ts +9 -0
- package/dist/components/ServicesInspector.d.ts +3 -4
- package/dist/components/WidgetPreview.d.ts +8 -0
- package/dist/components/edit/EditModal.d.ts +2 -1
- package/dist/components/edit/FileTree.d.ts +22 -3
- package/dist/components/edit/fileTypes.d.ts +2 -0
- package/dist/components/edit/useEditSession.d.ts +1 -0
- package/dist/components/index.d.ts +7 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1030 -197
- package/package.json +4 -3
- package/src/components/CodePreview.tsx +118 -160
- package/src/components/MarkdownPreview.tsx +147 -0
- package/src/components/SaveStatusButton.tsx +55 -0
- package/src/components/ServicesInspector.tsx +101 -37
- package/src/components/WidgetPreview.tsx +102 -0
- package/src/components/edit/CodeBlockView.tsx +135 -19
- package/src/components/edit/EditModal.tsx +86 -28
- package/src/components/edit/FileTree.tsx +524 -29
- package/src/components/edit/MediaPreview.tsx +23 -5
- package/src/components/edit/api.ts +6 -1
- package/src/components/edit/fileTypes.ts +8 -0
- package/src/components/edit/useEditSession.ts +13 -3
- package/src/components/index.ts +7 -5
- package/src/index.ts +10 -6
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import { useMemo, useState, useRef, useCallback } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { useMemo, useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ChevronRight,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronsDown,
|
|
6
|
+
File,
|
|
7
|
+
Folder,
|
|
8
|
+
Upload,
|
|
9
|
+
Pencil,
|
|
10
|
+
Loader2,
|
|
11
|
+
Pin,
|
|
12
|
+
PinOff,
|
|
13
|
+
X,
|
|
14
|
+
} from 'lucide-react';
|
|
3
15
|
import type { VirtualFile } from '@aprovan/patchwork-compiler';
|
|
4
16
|
import { isMediaFile } from './fileTypes';
|
|
5
17
|
|
|
@@ -10,6 +22,27 @@ interface TreeNode {
|
|
|
10
22
|
children: TreeNode[];
|
|
11
23
|
}
|
|
12
24
|
|
|
25
|
+
export interface FileTreeEntry {
|
|
26
|
+
name: string;
|
|
27
|
+
path: string;
|
|
28
|
+
isDir: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FileTreeDirectoryLoader = (path: string) => Promise<FileTreeEntry[]>;
|
|
32
|
+
|
|
33
|
+
function sortNodes(nodes: TreeNode[]): void {
|
|
34
|
+
nodes.sort((a, b) => {
|
|
35
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
36
|
+
return a.name.localeCompare(b.name);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const node of nodes) {
|
|
40
|
+
if (node.children.length > 0) {
|
|
41
|
+
sortNodes(node.children);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
function buildTree(files: VirtualFile[]): TreeNode {
|
|
14
47
|
const root: TreeNode = { name: '', path: '', isDir: true, children: [] };
|
|
15
48
|
|
|
@@ -36,24 +69,44 @@ function buildTree(files: VirtualFile[]): TreeNode {
|
|
|
36
69
|
}
|
|
37
70
|
}
|
|
38
71
|
|
|
39
|
-
root.children
|
|
40
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
41
|
-
return a.name.localeCompare(b.name);
|
|
42
|
-
});
|
|
72
|
+
sortNodes(root.children);
|
|
43
73
|
|
|
44
74
|
return root;
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
interface TreeNodeComponentProps {
|
|
48
78
|
node: TreeNode;
|
|
49
|
-
|
|
79
|
+
activePath: string;
|
|
50
80
|
onSelect: (path: string) => void;
|
|
81
|
+
onSelectDirectory?: (path: string) => void;
|
|
51
82
|
onReplaceFile?: (path: string, content: string, encoding: 'utf8' | 'base64') => void;
|
|
83
|
+
onOpenInEditor?: (path: string, isDir: boolean) => void;
|
|
84
|
+
openInEditorMode?: 'files' | 'directories' | 'all';
|
|
85
|
+
openInEditorIcon?: ReactNode;
|
|
86
|
+
openInEditorTitle?: string;
|
|
87
|
+
pinnedPaths?: Map<string, boolean>;
|
|
88
|
+
onTogglePin?: (path: string, isDir: boolean) => void;
|
|
89
|
+
pageSize?: number;
|
|
52
90
|
depth?: number;
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
function TreeNodeComponent({
|
|
56
|
-
|
|
93
|
+
function TreeNodeComponent({
|
|
94
|
+
node,
|
|
95
|
+
activePath,
|
|
96
|
+
onSelect,
|
|
97
|
+
onSelectDirectory,
|
|
98
|
+
onReplaceFile,
|
|
99
|
+
onOpenInEditor,
|
|
100
|
+
openInEditorMode = 'files',
|
|
101
|
+
openInEditorIcon,
|
|
102
|
+
openInEditorTitle = 'Open in editor',
|
|
103
|
+
pinnedPaths,
|
|
104
|
+
onTogglePin,
|
|
105
|
+
pageSize = 10,
|
|
106
|
+
depth = 0,
|
|
107
|
+
}: TreeNodeComponentProps) {
|
|
108
|
+
const [expanded, setExpanded] = useState(false);
|
|
109
|
+
const [visibleCount, setVisibleCount] = useState(pageSize);
|
|
57
110
|
const [isHovered, setIsHovered] = useState(false);
|
|
58
111
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
59
112
|
|
|
@@ -77,6 +130,14 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
77
130
|
e.target.value = '';
|
|
78
131
|
}, [node.path, onReplaceFile]);
|
|
79
132
|
|
|
133
|
+
const isPinned = pinnedPaths?.has(node.path) ?? false;
|
|
134
|
+
const showPin = onTogglePin && isHovered;
|
|
135
|
+
|
|
136
|
+
const handleTogglePin = useCallback((e: React.MouseEvent) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
onTogglePin?.(node.path, node.isDir);
|
|
139
|
+
}, [node.path, node.isDir, onTogglePin]);
|
|
140
|
+
|
|
80
141
|
if (!node.name) {
|
|
81
142
|
return (
|
|
82
143
|
<>
|
|
@@ -84,9 +145,17 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
84
145
|
<TreeNodeComponent
|
|
85
146
|
key={child.path}
|
|
86
147
|
node={child}
|
|
87
|
-
|
|
148
|
+
activePath={activePath}
|
|
88
149
|
onSelect={onSelect}
|
|
150
|
+
onSelectDirectory={onSelectDirectory}
|
|
89
151
|
onReplaceFile={onReplaceFile}
|
|
152
|
+
onOpenInEditor={onOpenInEditor}
|
|
153
|
+
openInEditorMode={openInEditorMode}
|
|
154
|
+
openInEditorIcon={openInEditorIcon}
|
|
155
|
+
openInEditorTitle={openInEditorTitle}
|
|
156
|
+
pinnedPaths={pinnedPaths}
|
|
157
|
+
onTogglePin={onTogglePin}
|
|
158
|
+
pageSize={pageSize}
|
|
90
159
|
depth={depth}
|
|
91
160
|
/>
|
|
92
161
|
))}
|
|
@@ -94,16 +163,35 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
94
163
|
);
|
|
95
164
|
}
|
|
96
165
|
|
|
97
|
-
const isActive = node.path ===
|
|
166
|
+
const isActive = node.path === activePath;
|
|
98
167
|
const isMedia = !node.isDir && isMediaFile(node.path);
|
|
99
168
|
const showUpload = isMedia && isHovered && onReplaceFile;
|
|
169
|
+
const showOpenInEditor =
|
|
170
|
+
!!onOpenInEditor &&
|
|
171
|
+
isHovered &&
|
|
172
|
+
(openInEditorMode === 'all' || (openInEditorMode === 'directories' ? node.isDir : !node.isDir));
|
|
173
|
+
|
|
174
|
+
const handleOpenInEditor = useCallback(
|
|
175
|
+
(e: React.MouseEvent) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
onOpenInEditor?.(node.path, node.isDir);
|
|
178
|
+
},
|
|
179
|
+
[node.path, node.isDir, onOpenInEditor],
|
|
180
|
+
);
|
|
100
181
|
|
|
101
182
|
if (node.isDir) {
|
|
102
183
|
return (
|
|
103
184
|
<div>
|
|
104
185
|
<button
|
|
105
|
-
onClick={() =>
|
|
106
|
-
|
|
186
|
+
onClick={() => {
|
|
187
|
+
onSelectDirectory?.(node.path);
|
|
188
|
+
setExpanded(!expanded);
|
|
189
|
+
}}
|
|
190
|
+
className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
|
|
191
|
+
isActive ? 'bg-primary/10 text-primary' : ''
|
|
192
|
+
}`}
|
|
193
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
194
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
107
195
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
108
196
|
>
|
|
109
197
|
{expanded ? (
|
|
@@ -112,20 +200,58 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
112
200
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
113
201
|
)}
|
|
114
202
|
<Folder className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
115
|
-
<span className="truncate">{node.name}</span>
|
|
203
|
+
<span className="truncate flex-1 flex pl-2">{node.name}</span>
|
|
204
|
+
{(showPin || isPinned) && (
|
|
205
|
+
<span
|
|
206
|
+
onClick={handleTogglePin}
|
|
207
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
208
|
+
title={isPinned ? 'Unpin' : 'Pin'}
|
|
209
|
+
>
|
|
210
|
+
{isPinned ? <PinOff className="h-3 w-3 text-primary" /> : <Pin className="h-3 w-3 text-muted-foreground" />}
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
{showOpenInEditor && (
|
|
214
|
+
<span
|
|
215
|
+
onClick={handleOpenInEditor}
|
|
216
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
217
|
+
title={openInEditorTitle}
|
|
218
|
+
>
|
|
219
|
+
{openInEditorIcon ?? <Pencil className="h-3 w-3 text-primary" />}
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
116
222
|
</button>
|
|
117
223
|
{expanded && (
|
|
118
224
|
<div>
|
|
119
|
-
{node.children.map(child => (
|
|
225
|
+
{node.children.slice(0, visibleCount).map(child => (
|
|
120
226
|
<TreeNodeComponent
|
|
121
227
|
key={child.path}
|
|
122
228
|
node={child}
|
|
123
|
-
|
|
229
|
+
activePath={activePath}
|
|
124
230
|
onSelect={onSelect}
|
|
231
|
+
onSelectDirectory={onSelectDirectory}
|
|
125
232
|
onReplaceFile={onReplaceFile}
|
|
233
|
+
onOpenInEditor={onOpenInEditor}
|
|
234
|
+
openInEditorMode={openInEditorMode}
|
|
235
|
+
openInEditorIcon={openInEditorIcon}
|
|
236
|
+
openInEditorTitle={openInEditorTitle}
|
|
237
|
+
pinnedPaths={pinnedPaths}
|
|
238
|
+
onTogglePin={onTogglePin}
|
|
239
|
+
pageSize={pageSize}
|
|
126
240
|
depth={depth + 1}
|
|
127
241
|
/>
|
|
128
242
|
))}
|
|
243
|
+
{node.children.length > visibleCount && (
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setVisibleCount((prev) => prev + pageSize)}
|
|
246
|
+
className="flex items-center gap-1 w-full px-2 py-1 text-left text-xs hover:bg-muted/50 rounded text-muted-foreground"
|
|
247
|
+
style={{ paddingLeft: `${(depth + 1) * 12 + 20}px` }}
|
|
248
|
+
>
|
|
249
|
+
<ChevronsDown className="h-3 w-3 shrink-0" />
|
|
250
|
+
<span>
|
|
251
|
+
Show {Math.min(pageSize, node.children.length - visibleCount)} more
|
|
252
|
+
</span>
|
|
253
|
+
</button>
|
|
254
|
+
)}
|
|
129
255
|
</div>
|
|
130
256
|
)}
|
|
131
257
|
</div>
|
|
@@ -146,11 +272,29 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
146
272
|
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
147
273
|
>
|
|
148
274
|
<File className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
149
|
-
<span className="truncate flex-1">{node.name}</span>
|
|
275
|
+
<span className="truncate flex-1 flex pl-2">{node.name}</span>
|
|
276
|
+
{(showPin || isPinned) && (
|
|
277
|
+
<span
|
|
278
|
+
onClick={handleTogglePin}
|
|
279
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
280
|
+
title={isPinned ? 'Unpin' : 'Pin'}
|
|
281
|
+
>
|
|
282
|
+
{isPinned ? <PinOff className="h-3 w-3 text-primary" /> : <Pin className="h-3 w-3 text-muted-foreground" />}
|
|
283
|
+
</span>
|
|
284
|
+
)}
|
|
285
|
+
{showOpenInEditor && (
|
|
286
|
+
<span
|
|
287
|
+
onClick={handleOpenInEditor}
|
|
288
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
289
|
+
title={openInEditorTitle}
|
|
290
|
+
>
|
|
291
|
+
{openInEditorIcon ?? <Pencil className="h-3 w-3 text-primary" />}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
150
294
|
{showUpload && (
|
|
151
295
|
<span
|
|
152
296
|
onClick={handleUploadClick}
|
|
153
|
-
className="p-
|
|
297
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
154
298
|
title="Replace file"
|
|
155
299
|
>
|
|
156
300
|
<Upload className="h-3 w-3 text-primary" />
|
|
@@ -171,27 +315,378 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
171
315
|
}
|
|
172
316
|
|
|
173
317
|
export interface FileTreeProps {
|
|
174
|
-
files
|
|
175
|
-
activeFile
|
|
318
|
+
files?: VirtualFile[];
|
|
319
|
+
activeFile?: string;
|
|
320
|
+
activePath?: string;
|
|
321
|
+
title?: string;
|
|
176
322
|
onSelectFile: (path: string) => void;
|
|
323
|
+
onSelectDirectory?: (path: string) => void;
|
|
177
324
|
onReplaceFile?: (path: string, content: string, encoding: 'utf8' | 'base64') => void;
|
|
325
|
+
onOpenInEditor?: (path: string, isDir: boolean) => void;
|
|
326
|
+
openInEditorMode?: 'files' | 'directories' | 'all';
|
|
327
|
+
openInEditorIcon?: ReactNode;
|
|
328
|
+
openInEditorTitle?: string;
|
|
329
|
+
pinnedPaths?: Map<string, boolean>;
|
|
330
|
+
onTogglePin?: (path: string, isDir: boolean) => void;
|
|
331
|
+
directoryLoader?: FileTreeDirectoryLoader;
|
|
332
|
+
pageSize?: number;
|
|
333
|
+
reloadToken?: number;
|
|
178
334
|
}
|
|
179
335
|
|
|
180
|
-
|
|
336
|
+
interface LazyTreeNodeProps {
|
|
337
|
+
entry: FileTreeEntry;
|
|
338
|
+
activePath: string;
|
|
339
|
+
onSelectFile: (path: string) => void;
|
|
340
|
+
onSelectDirectory?: (path: string) => void;
|
|
341
|
+
onOpenInEditor?: (path: string, isDir: boolean) => void;
|
|
342
|
+
openInEditorMode?: 'files' | 'directories' | 'all';
|
|
343
|
+
openInEditorIcon?: ReactNode;
|
|
344
|
+
openInEditorTitle?: string;
|
|
345
|
+
pinnedPaths?: Map<string, boolean>;
|
|
346
|
+
onTogglePin?: (path: string, isDir: boolean) => void;
|
|
347
|
+
directoryLoader: FileTreeDirectoryLoader;
|
|
348
|
+
pageSize: number;
|
|
349
|
+
depth?: number;
|
|
350
|
+
reloadToken?: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function LazyTreeNode({
|
|
354
|
+
entry,
|
|
355
|
+
activePath,
|
|
356
|
+
onSelectFile,
|
|
357
|
+
onSelectDirectory,
|
|
358
|
+
onOpenInEditor,
|
|
359
|
+
openInEditorMode = 'files',
|
|
360
|
+
openInEditorIcon,
|
|
361
|
+
openInEditorTitle = 'Open in editor',
|
|
362
|
+
pinnedPaths,
|
|
363
|
+
onTogglePin,
|
|
364
|
+
directoryLoader,
|
|
365
|
+
pageSize,
|
|
366
|
+
depth = 0,
|
|
367
|
+
reloadToken,
|
|
368
|
+
}: LazyTreeNodeProps) {
|
|
369
|
+
const [expanded, setExpanded] = useState(false);
|
|
370
|
+
const [loading, setLoading] = useState(false);
|
|
371
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
372
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
373
|
+
const [children, setChildren] = useState<FileTreeEntry[] | null>(null);
|
|
374
|
+
const [visibleCount, setVisibleCount] = useState(pageSize);
|
|
375
|
+
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
setChildren(null);
|
|
378
|
+
setVisibleCount(pageSize);
|
|
379
|
+
if (expanded) {
|
|
380
|
+
setLoading(true);
|
|
381
|
+
setLoadError(null);
|
|
382
|
+
directoryLoader(entry.path)
|
|
383
|
+
.then((loaded) => setChildren(loaded))
|
|
384
|
+
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load directory'))
|
|
385
|
+
.finally(() => setLoading(false));
|
|
386
|
+
}
|
|
387
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
388
|
+
}, [reloadToken]);
|
|
389
|
+
|
|
390
|
+
const isActive = entry.path === activePath;
|
|
391
|
+
const isPinned = pinnedPaths?.has(entry.path) ?? false;
|
|
392
|
+
const showPin = onTogglePin && isHovered;
|
|
393
|
+
const showOpenInEditor =
|
|
394
|
+
!!onOpenInEditor &&
|
|
395
|
+
isHovered &&
|
|
396
|
+
(openInEditorMode === 'all' || (openInEditorMode === 'directories' ? entry.isDir : !entry.isDir));
|
|
397
|
+
|
|
398
|
+
const handleOpenInEditor = useCallback(
|
|
399
|
+
(e: React.MouseEvent) => {
|
|
400
|
+
e.stopPropagation();
|
|
401
|
+
onOpenInEditor?.(entry.path, entry.isDir);
|
|
402
|
+
},
|
|
403
|
+
[entry.path, entry.isDir, onOpenInEditor],
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const handleTogglePin = useCallback(
|
|
407
|
+
(e: React.MouseEvent) => {
|
|
408
|
+
e.stopPropagation();
|
|
409
|
+
onTogglePin?.(entry.path, entry.isDir);
|
|
410
|
+
},
|
|
411
|
+
[entry.path, entry.isDir, onTogglePin],
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const toggleDirectory = useCallback(async () => {
|
|
415
|
+
if (!entry.isDir) return;
|
|
416
|
+
onSelectDirectory?.(entry.path);
|
|
417
|
+
|
|
418
|
+
if (!expanded && children === null) {
|
|
419
|
+
setLoading(true);
|
|
420
|
+
setLoadError(null);
|
|
421
|
+
try {
|
|
422
|
+
const loaded = await directoryLoader(entry.path);
|
|
423
|
+
setChildren(loaded);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
setLoadError(err instanceof Error ? err.message : 'Failed to load directory');
|
|
426
|
+
} finally {
|
|
427
|
+
setLoading(false);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setExpanded((prev) => !prev);
|
|
432
|
+
}, [entry.isDir, entry.path, onSelectDirectory, expanded, children, directoryLoader]);
|
|
433
|
+
|
|
434
|
+
if (!entry.isDir) {
|
|
435
|
+
return (
|
|
436
|
+
<div
|
|
437
|
+
className="relative"
|
|
438
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
439
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
440
|
+
>
|
|
441
|
+
<button
|
|
442
|
+
onClick={() => onSelectFile(entry.path)}
|
|
443
|
+
className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
|
|
444
|
+
isActive ? 'bg-primary/10 text-primary' : ''
|
|
445
|
+
}`}
|
|
446
|
+
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
447
|
+
>
|
|
448
|
+
<File className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
449
|
+
<span className="truncate flex-1 flex pl-2">{entry.name}</span>
|
|
450
|
+
{(showPin || isPinned) && (
|
|
451
|
+
<span
|
|
452
|
+
onClick={handleTogglePin}
|
|
453
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
454
|
+
title={isPinned ? 'Unpin' : 'Pin'}
|
|
455
|
+
>
|
|
456
|
+
{isPinned ? <PinOff className="h-3 w-3 text-primary" /> : <Pin className="h-3 w-3 text-muted-foreground" />}
|
|
457
|
+
</span>
|
|
458
|
+
)}
|
|
459
|
+
{showOpenInEditor && (
|
|
460
|
+
<span
|
|
461
|
+
onClick={handleOpenInEditor}
|
|
462
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
463
|
+
title={openInEditorTitle}
|
|
464
|
+
>
|
|
465
|
+
{openInEditorIcon ?? <Pencil className="h-3 w-3 text-primary" />}
|
|
466
|
+
</span>
|
|
467
|
+
)}
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<div>
|
|
475
|
+
<button
|
|
476
|
+
onClick={() => void toggleDirectory()}
|
|
477
|
+
className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
|
|
478
|
+
isActive ? 'bg-primary/10 text-primary' : ''
|
|
479
|
+
}`}
|
|
480
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
481
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
482
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
483
|
+
>
|
|
484
|
+
{expanded ? (
|
|
485
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
486
|
+
) : (
|
|
487
|
+
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
488
|
+
)}
|
|
489
|
+
<Folder className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
490
|
+
<span className="truncate flex-1 flex pl-2">{entry.name}</span>
|
|
491
|
+
{loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
|
492
|
+
{(showPin || isPinned) && (
|
|
493
|
+
<span
|
|
494
|
+
onClick={handleTogglePin}
|
|
495
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
496
|
+
title={isPinned ? 'Unpin' : 'Pin'}
|
|
497
|
+
>
|
|
498
|
+
{isPinned ? <PinOff className="h-3 w-3 text-primary" /> : <Pin className="h-3 w-3 text-muted-foreground" />}
|
|
499
|
+
</span>
|
|
500
|
+
)}
|
|
501
|
+
{showOpenInEditor && (
|
|
502
|
+
<span
|
|
503
|
+
onClick={handleOpenInEditor}
|
|
504
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
505
|
+
title={openInEditorTitle}
|
|
506
|
+
>
|
|
507
|
+
{openInEditorIcon ?? <Pencil className="h-3 w-3 text-primary" />}
|
|
508
|
+
</span>
|
|
509
|
+
)}
|
|
510
|
+
</button>
|
|
511
|
+
|
|
512
|
+
{expanded && (
|
|
513
|
+
<div>
|
|
514
|
+
{loadError && (
|
|
515
|
+
<div
|
|
516
|
+
className="px-2 py-1 text-xs text-destructive"
|
|
517
|
+
style={{ paddingLeft: `${(depth + 1) * 12 + 20}px` }}
|
|
518
|
+
>
|
|
519
|
+
{loadError}
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
{(children ?? []).slice(0, visibleCount).map((child) => (
|
|
523
|
+
<LazyTreeNode
|
|
524
|
+
key={child.path}
|
|
525
|
+
entry={child}
|
|
526
|
+
activePath={activePath}
|
|
527
|
+
onSelectFile={onSelectFile}
|
|
528
|
+
onSelectDirectory={onSelectDirectory}
|
|
529
|
+
onOpenInEditor={onOpenInEditor}
|
|
530
|
+
openInEditorMode={openInEditorMode}
|
|
531
|
+
openInEditorIcon={openInEditorIcon}
|
|
532
|
+
openInEditorTitle={openInEditorTitle}
|
|
533
|
+
pinnedPaths={pinnedPaths}
|
|
534
|
+
onTogglePin={onTogglePin}
|
|
535
|
+
directoryLoader={directoryLoader}
|
|
536
|
+
pageSize={pageSize}
|
|
537
|
+
depth={depth + 1}
|
|
538
|
+
reloadToken={reloadToken}
|
|
539
|
+
/>
|
|
540
|
+
))}
|
|
541
|
+
{(children?.length ?? 0) > visibleCount && (
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => setVisibleCount((prev) => prev + pageSize)}
|
|
544
|
+
className="flex items-center gap-1 w-full px-2 py-1 text-left text-xs hover:bg-muted/50 rounded text-muted-foreground"
|
|
545
|
+
style={{ paddingLeft: `${(depth + 1) * 12 + 20}px` }}
|
|
546
|
+
>
|
|
547
|
+
<ChevronsDown className="h-3 w-3 shrink-0" />
|
|
548
|
+
<span>
|
|
549
|
+
Show {Math.min(pageSize, (children?.length ?? 0) - visibleCount)} more
|
|
550
|
+
</span>
|
|
551
|
+
</button>
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function FileTree({
|
|
560
|
+
files = [],
|
|
561
|
+
activeFile,
|
|
562
|
+
activePath,
|
|
563
|
+
title = 'Files',
|
|
564
|
+
onSelectFile,
|
|
565
|
+
onSelectDirectory,
|
|
566
|
+
onReplaceFile,
|
|
567
|
+
onOpenInEditor,
|
|
568
|
+
openInEditorMode,
|
|
569
|
+
openInEditorIcon,
|
|
570
|
+
openInEditorTitle,
|
|
571
|
+
pinnedPaths,
|
|
572
|
+
onTogglePin,
|
|
573
|
+
directoryLoader,
|
|
574
|
+
pageSize = 10,
|
|
575
|
+
reloadToken,
|
|
576
|
+
}: FileTreeProps) {
|
|
181
577
|
const tree = useMemo(() => buildTree(files), [files]);
|
|
578
|
+
const selectedPath = activePath ?? activeFile ?? '';
|
|
579
|
+
const [rootEntries, setRootEntries] = useState<FileTreeEntry[]>([]);
|
|
580
|
+
const [rootLoading, setRootLoading] = useState(false);
|
|
581
|
+
const [rootError, setRootError] = useState<string | null>(null);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (!directoryLoader) return;
|
|
585
|
+
|
|
586
|
+
let cancelled = false;
|
|
587
|
+
|
|
588
|
+
const loadRoot = async () => {
|
|
589
|
+
setRootLoading(true);
|
|
590
|
+
setRootError(null);
|
|
591
|
+
try {
|
|
592
|
+
const entries = await directoryLoader('');
|
|
593
|
+
if (!cancelled) {
|
|
594
|
+
setRootEntries(entries);
|
|
595
|
+
}
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (!cancelled) {
|
|
598
|
+
setRootError(err instanceof Error ? err.message : 'Failed to load files');
|
|
599
|
+
}
|
|
600
|
+
} finally {
|
|
601
|
+
if (!cancelled) {
|
|
602
|
+
setRootLoading(false);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
void loadRoot();
|
|
608
|
+
|
|
609
|
+
return () => {
|
|
610
|
+
cancelled = true;
|
|
611
|
+
};
|
|
612
|
+
}, [directoryLoader, reloadToken]);
|
|
182
613
|
|
|
183
614
|
return (
|
|
184
|
-
<div className="w-48 border-r bg-muted/30 overflow-auto text-foreground">
|
|
615
|
+
<div className="min-w-48 border-r bg-muted/30 overflow-auto text-foreground">
|
|
185
616
|
<div className="p-2 border-b text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
186
|
-
|
|
617
|
+
{title}
|
|
187
618
|
</div>
|
|
619
|
+
{pinnedPaths && pinnedPaths.size > 0 && (
|
|
620
|
+
<div className="px-2 py-1 border-b flex flex-wrap gap-1">
|
|
621
|
+
{Array.from(pinnedPaths).map(([p, isDir]) => (
|
|
622
|
+
<button
|
|
623
|
+
key={p}
|
|
624
|
+
onClick={() => isDir ? onSelectDirectory?.(p) : onSelectFile(p)}
|
|
625
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs hover:bg-muted/50 ${
|
|
626
|
+
(activePath ?? activeFile ?? '') === p ? 'bg-primary/10 text-primary' : 'text-muted-foreground'
|
|
627
|
+
}`}
|
|
628
|
+
>
|
|
629
|
+
{isDir ? <Folder className="h-2.5 w-2.5 shrink-0" /> : <Pin className="h-2.5 w-2.5 shrink-0" />}
|
|
630
|
+
<span className="truncate max-w-[120px]">{p.split('/').pop()}</span>
|
|
631
|
+
{onTogglePin && (
|
|
632
|
+
<span
|
|
633
|
+
onClick={(e) => { e.stopPropagation(); onTogglePin(p, isDir); }}
|
|
634
|
+
className="hover:text-destructive"
|
|
635
|
+
>
|
|
636
|
+
<X className="h-2.5 w-2.5" />
|
|
637
|
+
</span>
|
|
638
|
+
)}
|
|
639
|
+
</button>
|
|
640
|
+
))}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
188
643
|
<div className="p-1">
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
644
|
+
{directoryLoader ? (
|
|
645
|
+
<>
|
|
646
|
+
{rootLoading && (
|
|
647
|
+
<div className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground">
|
|
648
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
649
|
+
<span>Loading...</span>
|
|
650
|
+
</div>
|
|
651
|
+
)}
|
|
652
|
+
{rootError && (
|
|
653
|
+
<div className="px-2 py-1 text-xs text-destructive">{rootError}</div>
|
|
654
|
+
)}
|
|
655
|
+
{rootEntries.map((entry) => (
|
|
656
|
+
<LazyTreeNode
|
|
657
|
+
key={entry.path}
|
|
658
|
+
entry={entry}
|
|
659
|
+
activePath={selectedPath}
|
|
660
|
+
onSelectFile={onSelectFile}
|
|
661
|
+
onSelectDirectory={onSelectDirectory}
|
|
662
|
+
onOpenInEditor={onOpenInEditor}
|
|
663
|
+
openInEditorMode={openInEditorMode}
|
|
664
|
+
openInEditorIcon={openInEditorIcon}
|
|
665
|
+
openInEditorTitle={openInEditorTitle}
|
|
666
|
+
pinnedPaths={pinnedPaths}
|
|
667
|
+
onTogglePin={onTogglePin}
|
|
668
|
+
directoryLoader={directoryLoader}
|
|
669
|
+
pageSize={pageSize}
|
|
670
|
+
reloadToken={reloadToken}
|
|
671
|
+
/>
|
|
672
|
+
))}
|
|
673
|
+
</>
|
|
674
|
+
) : (
|
|
675
|
+
<TreeNodeComponent
|
|
676
|
+
node={tree}
|
|
677
|
+
activePath={selectedPath}
|
|
678
|
+
onSelect={onSelectFile}
|
|
679
|
+
onSelectDirectory={onSelectDirectory}
|
|
680
|
+
onReplaceFile={onReplaceFile}
|
|
681
|
+
onOpenInEditor={onOpenInEditor}
|
|
682
|
+
openInEditorMode={openInEditorMode}
|
|
683
|
+
openInEditorIcon={openInEditorIcon}
|
|
684
|
+
openInEditorTitle={openInEditorTitle}
|
|
685
|
+
pinnedPaths={pinnedPaths}
|
|
686
|
+
onTogglePin={onTogglePin}
|
|
687
|
+
pageSize={pageSize}
|
|
688
|
+
/>
|
|
689
|
+
)}
|
|
195
690
|
</div>
|
|
196
691
|
</div>
|
|
197
692
|
);
|