@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.
@@ -1,5 +1,17 @@
1
- import { useMemo, useState, useRef, useCallback } from 'react';
2
- import { ChevronRight, ChevronDown, File, Folder, Upload } from 'lucide-react';
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.sort((a, b) => {
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
- activeFile: string;
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({ node, activeFile, onSelect, onReplaceFile, depth = 0 }: TreeNodeComponentProps) {
56
- const [expanded, setExpanded] = useState(true);
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
- activeFile={activeFile}
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 === activeFile;
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={() => setExpanded(!expanded)}
106
- className="flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded"
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
- activeFile={activeFile}
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-0.5 hover:bg-primary/20 rounded cursor-pointer"
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: VirtualFile[];
175
- activeFile: string;
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
- export function FileTree({ files, activeFile, onSelectFile, onReplaceFile }: FileTreeProps) {
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
- Files
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
- <TreeNodeComponent
190
- node={tree}
191
- activeFile={activeFile}
192
- onSelect={onSelectFile}
193
- onReplaceFile={onReplaceFile}
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
  );