@bendyline/docblocks-react 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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/AppMenu/AppMenu.d.ts +11 -0
  4. package/dist/AppMenu/AppMenu.d.ts.map +1 -0
  5. package/dist/AppMenu/AppMenu.js +27 -0
  6. package/dist/AppMenu/AppMenu.js.map +1 -0
  7. package/dist/AppMenu/index.d.ts +3 -0
  8. package/dist/AppMenu/index.d.ts.map +1 -0
  9. package/dist/AppMenu/index.js +2 -0
  10. package/dist/AppMenu/index.js.map +1 -0
  11. package/dist/DocBlocksShell/DocBlocksShell.d.ts +16 -0
  12. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -0
  13. package/dist/DocBlocksShell/DocBlocksShell.js +487 -0
  14. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -0
  15. package/dist/DocBlocksShell/index.d.ts +3 -0
  16. package/dist/DocBlocksShell/index.d.ts.map +1 -0
  17. package/dist/DocBlocksShell/index.js +2 -0
  18. package/dist/DocBlocksShell/index.js.map +1 -0
  19. package/dist/Export/ExportDialog.d.ts +16 -0
  20. package/dist/Export/ExportDialog.d.ts.map +1 -0
  21. package/dist/Export/ExportDialog.js +42 -0
  22. package/dist/Export/ExportDialog.js.map +1 -0
  23. package/dist/Export/ExportToolbarControls.d.ts +17 -0
  24. package/dist/Export/ExportToolbarControls.d.ts.map +1 -0
  25. package/dist/Export/ExportToolbarControls.js +109 -0
  26. package/dist/Export/ExportToolbarControls.js.map +1 -0
  27. package/dist/Export/export-options.d.ts +18 -0
  28. package/dist/Export/export-options.d.ts.map +1 -0
  29. package/dist/Export/export-options.js +44 -0
  30. package/dist/Export/export-options.js.map +1 -0
  31. package/dist/Export/index.d.ts +6 -0
  32. package/dist/Export/index.d.ts.map +1 -0
  33. package/dist/Export/index.js +3 -0
  34. package/dist/Export/index.js.map +1 -0
  35. package/dist/Export/run-export.d.ts +8 -0
  36. package/dist/Export/run-export.d.ts.map +1 -0
  37. package/dist/Export/run-export.js +208 -0
  38. package/dist/Export/run-export.js.map +1 -0
  39. package/dist/FileExplorer/FileExplorer.d.ts +21 -0
  40. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -0
  41. package/dist/FileExplorer/FileExplorer.js +125 -0
  42. package/dist/FileExplorer/FileExplorer.js.map +1 -0
  43. package/dist/FileExplorer/FileTreeNode.d.ts +18 -0
  44. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -0
  45. package/dist/FileExplorer/FileTreeNode.js +86 -0
  46. package/dist/FileExplorer/FileTreeNode.js.map +1 -0
  47. package/dist/FileExplorer/index.d.ts +6 -0
  48. package/dist/FileExplorer/index.d.ts.map +1 -0
  49. package/dist/FileExplorer/index.js +4 -0
  50. package/dist/FileExplorer/index.js.map +1 -0
  51. package/dist/FileExplorer/useFileTree.d.ts +35 -0
  52. package/dist/FileExplorer/useFileTree.d.ts.map +1 -0
  53. package/dist/FileExplorer/useFileTree.js +124 -0
  54. package/dist/FileExplorer/useFileTree.js.map +1 -0
  55. package/dist/WorkspacePicker/WorkspacePicker.d.ts +17 -0
  56. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -0
  57. package/dist/WorkspacePicker/WorkspacePicker.js +57 -0
  58. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -0
  59. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +10 -0
  60. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -0
  61. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +27 -0
  62. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -0
  63. package/dist/WorkspacePicker/index.d.ts +3 -0
  64. package/dist/WorkspacePicker/index.d.ts.map +1 -0
  65. package/dist/WorkspacePicker/index.js +2 -0
  66. package/dist/WorkspacePicker/index.js.map +1 -0
  67. package/dist/hooks/index.d.ts +2 -0
  68. package/dist/hooks/index.d.ts.map +1 -0
  69. package/dist/hooks/index.js +2 -0
  70. package/dist/hooks/index.js.map +1 -0
  71. package/dist/hooks/useAutoSave.d.ts +9 -0
  72. package/dist/hooks/useAutoSave.d.ts.map +1 -0
  73. package/dist/hooks/useAutoSave.js +48 -0
  74. package/dist/hooks/useAutoSave.js.map +1 -0
  75. package/dist/icons.d.ts +6 -0
  76. package/dist/icons.d.ts.map +1 -0
  77. package/dist/icons.js +24 -0
  78. package/dist/icons.js.map +1 -0
  79. package/dist/index.d.ts +20 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +19 -0
  82. package/dist/index.js.map +1 -0
  83. package/package.json +65 -0
  84. package/src/AppMenu/AppMenu.tsx +124 -0
  85. package/src/AppMenu/index.ts +2 -0
  86. package/src/DocBlocksShell/DocBlocksShell.tsx +662 -0
  87. package/src/DocBlocksShell/index.ts +2 -0
  88. package/src/Export/ExportDialog.tsx +173 -0
  89. package/src/Export/ExportToolbarControls.tsx +183 -0
  90. package/src/Export/export-options.ts +57 -0
  91. package/src/Export/index.ts +5 -0
  92. package/src/Export/run-export.ts +238 -0
  93. package/src/FileExplorer/FileExplorer.tsx +260 -0
  94. package/src/FileExplorer/FileTreeNode.tsx +172 -0
  95. package/src/FileExplorer/index.ts +5 -0
  96. package/src/FileExplorer/useFileTree.ts +178 -0
  97. package/src/WorkspacePicker/WorkspacePicker.tsx +126 -0
  98. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +79 -0
  99. package/src/WorkspacePicker/index.ts +2 -0
  100. package/src/hooks/index.ts +1 -0
  101. package/src/hooks/useAutoSave.ts +58 -0
  102. package/src/icons.tsx +60 -0
  103. package/src/index.ts +31 -0
  104. package/src/styles/docblocks.css +961 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * FileExplorer — tree component rendering FileSystemProvider contents.
3
+ *
4
+ * Shows a file/folder tree with expand/collapse, selection, and a toolbar
5
+ * for creating new files/folders.
6
+ */
7
+
8
+ import { useCallback, useState } from 'react';
9
+ import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
10
+ import { useFileTree } from './useFileTree.js';
11
+ import { FileTreeNode } from './FileTreeNode.js';
12
+ import { NewFileIcon, NewFolderIcon, RefreshIcon } from '../icons.js';
13
+
14
+ const SUPPORTED_EXTENSIONS = new Set(['.txt', '.md', '.docx', '.pdf', '.dbk', '.zip']);
15
+
16
+ export interface FileExplorerProps {
17
+ /** The filesystem to display. */
18
+ provider: FileSystemProvider | null;
19
+ /** Called when any entry is selected (file or directory). */
20
+ onSelect?: (path: string, kind: 'file' | 'directory') => void;
21
+ /** Called after any mutation (create, delete, rename). */
22
+ onTreeChange?: () => void;
23
+ /** Called when supported files are dropped onto the explorer. */
24
+ onImportFiles?: (files: File[]) => void;
25
+ /** Optional className for the root element. */
26
+ className?: string;
27
+ }
28
+
29
+ export function FileExplorer({
30
+ provider,
31
+ onSelect,
32
+ onTreeChange,
33
+ onImportFiles,
34
+ className,
35
+ }: FileExplorerProps) {
36
+ const tree = useFileTree(provider);
37
+ const { childEntries } = tree as typeof tree & {
38
+ childEntries: Map<string, FileSystemEntry[]>;
39
+ };
40
+
41
+ const [newItemName, setNewItemName] = useState('');
42
+ const [newItemType, setNewItemType] = useState<'file' | 'directory' | null>(null);
43
+ const [dragOver, setDragOver] = useState(false);
44
+ const dragCounter = useState({ current: 0 })[0];
45
+
46
+ const hasSupported = useCallback((dt: DataTransfer): boolean => {
47
+ for (const item of Array.from(dt.items)) {
48
+ if (item.kind !== 'file') continue;
49
+ const name = (item as DataTransferItem & { getAsFile(): File | null }).getAsFile?.()?.name;
50
+ // During dragover the filename may not be available, so accept broadly
51
+ if (!name) return true;
52
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
53
+ if (SUPPORTED_EXTENSIONS.has(ext)) return true;
54
+ }
55
+ return dt.items.length > 0;
56
+ }, []);
57
+
58
+ const handleDragEnter = useCallback(
59
+ (e: React.DragEvent) => {
60
+ e.preventDefault();
61
+ dragCounter.current += 1;
62
+ if (hasSupported(e.dataTransfer)) setDragOver(true);
63
+ },
64
+ [hasSupported, dragCounter],
65
+ );
66
+
67
+ const handleDragOver = useCallback((e: React.DragEvent) => {
68
+ e.preventDefault();
69
+ e.dataTransfer.dropEffect = 'copy';
70
+ }, []);
71
+
72
+ const handleDragLeave = useCallback(
73
+ (e: React.DragEvent) => {
74
+ e.preventDefault();
75
+ dragCounter.current -= 1;
76
+ if (dragCounter.current <= 0) {
77
+ dragCounter.current = 0;
78
+ setDragOver(false);
79
+ }
80
+ },
81
+ [dragCounter],
82
+ );
83
+
84
+ const handleDrop = useCallback(
85
+ (e: React.DragEvent) => {
86
+ e.preventDefault();
87
+ dragCounter.current = 0;
88
+ setDragOver(false);
89
+ const supported = Array.from(e.dataTransfer.files).filter((f) => {
90
+ const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase();
91
+ return SUPPORTED_EXTENSIONS.has(ext);
92
+ });
93
+ if (supported.length > 0) onImportFiles?.(supported);
94
+ },
95
+ [onImportFiles, dragCounter],
96
+ );
97
+
98
+ const handleSelect = useCallback(
99
+ (path: string) => {
100
+ // Determine kind from entries (root + children)
101
+ const findKind = (p: string): 'file' | 'directory' => {
102
+ for (const e of tree.entries) {
103
+ if (e.path === p) return e.kind;
104
+ }
105
+ for (const children of childEntries.values()) {
106
+ for (const e of children) {
107
+ if (e.path === p) return e.kind;
108
+ }
109
+ }
110
+ return 'file';
111
+ };
112
+ const kind = findKind(path);
113
+ tree.select(path, kind);
114
+ onSelect?.(path, kind);
115
+ },
116
+ [tree, onSelect, childEntries],
117
+ );
118
+
119
+ const handleNewItemSubmit = useCallback(async () => {
120
+ if (!newItemName.trim()) {
121
+ setNewItemType(null);
122
+ return;
123
+ }
124
+ const name = newItemName.trim();
125
+ // Scope to currently selected folder (or root)
126
+ const prefix =
127
+ tree.selectedKind === 'directory' && tree.selectedPath ? `${tree.selectedPath}/` : '';
128
+ if (newItemType === 'file') {
129
+ const filename = name.endsWith('.md') ? name : `${name}.md`;
130
+ await tree.createFile(`${prefix}${filename}`, '');
131
+ } else if (newItemType === 'directory') {
132
+ await tree.createDirectory(`${prefix}${name}`);
133
+ }
134
+ setNewItemName('');
135
+ setNewItemType(null);
136
+ onTreeChange?.();
137
+ }, [newItemName, newItemType, tree, onTreeChange]);
138
+
139
+ const handleDelete = useCallback(
140
+ async (path: string) => {
141
+ await tree.deleteEntry(path);
142
+ onTreeChange?.();
143
+ },
144
+ [tree, onTreeChange],
145
+ );
146
+
147
+ const handleRename = useCallback(
148
+ async (oldPath: string, newPath: string) => {
149
+ await tree.renameEntry(oldPath, newPath);
150
+ onTreeChange?.();
151
+ },
152
+ [tree, onTreeChange],
153
+ );
154
+
155
+ const renderEntries = useCallback(
156
+ (entries: FileSystemEntry[], depth: number): React.ReactNode => {
157
+ return entries.map((entry) => (
158
+ <FileTreeNode
159
+ key={entry.path}
160
+ entry={entry}
161
+ depth={depth}
162
+ expanded={tree.expanded.has(entry.path)}
163
+ selected={tree.selectedPath === entry.path}
164
+ onToggle={tree.toggleExpand}
165
+ onSelect={handleSelect}
166
+ onDelete={handleDelete}
167
+ onRename={handleRename}
168
+ renderChildren={(dirPath: string) => {
169
+ const children = childEntries.get(dirPath) ?? [];
170
+ return renderEntries(children, depth + 1);
171
+ }}
172
+ />
173
+ ));
174
+ },
175
+ [tree, handleSelect, handleDelete, handleRename, childEntries],
176
+ );
177
+
178
+ return (
179
+ <div
180
+ className={`db-file-explorer ${dragOver ? 'db-file-explorer--drop-active' : ''} ${className ?? ''}`}
181
+ onDragEnter={handleDragEnter}
182
+ onDragOver={handleDragOver}
183
+ onDragLeave={handleDragLeave}
184
+ onDrop={handleDrop}
185
+ >
186
+ {/* Toolbar */}
187
+ <div className="db-explorer-toolbar">
188
+ <span className="db-explorer-title">Files</span>
189
+ <div className="db-explorer-actions">
190
+ <button
191
+ className="db-explorer-btn"
192
+ onClick={() => setNewItemType('file')}
193
+ title="New File"
194
+ aria-label="New File"
195
+ >
196
+ <NewFileIcon />
197
+ </button>
198
+ <button
199
+ className="db-explorer-btn"
200
+ onClick={() => setNewItemType('directory')}
201
+ title="New Folder"
202
+ aria-label="New Folder"
203
+ >
204
+ <NewFolderIcon />
205
+ </button>
206
+ <button
207
+ className="db-explorer-btn"
208
+ onClick={() => tree.refresh()}
209
+ title="Refresh file list"
210
+ aria-label="Refresh file list"
211
+ >
212
+ <RefreshIcon />
213
+ </button>
214
+ </div>
215
+ </div>
216
+
217
+ {/* New item input */}
218
+ {newItemType && (
219
+ <div className="db-new-item">
220
+ <form
221
+ className="db-new-item-row"
222
+ onSubmit={(e) => {
223
+ e.preventDefault();
224
+ handleNewItemSubmit();
225
+ }}
226
+ >
227
+ <input
228
+ className="db-new-item-input"
229
+ placeholder={newItemType === 'file' ? 'filename' : 'folder-name'}
230
+ value={newItemName}
231
+ onChange={(e) => setNewItemName(e.target.value)}
232
+ onKeyDown={(e) => {
233
+ if (e.key === 'Escape') {
234
+ setNewItemType(null);
235
+ setNewItemName('');
236
+ }
237
+ }}
238
+ autoFocus
239
+ />
240
+ {newItemType === 'file' && <span className="db-new-item-suffix">.md</span>}
241
+ <button type="submit" className="db-new-item-add">
242
+ Add
243
+ </button>
244
+ </form>
245
+ </div>
246
+ )}
247
+
248
+ {/* Tree */}
249
+ <div className="db-tree" role="tree">
250
+ {tree.loading ? (
251
+ <div className="db-tree-loading">Loading...</div>
252
+ ) : tree.entries.length === 0 ? (
253
+ <div className="db-tree-empty">No files yet</div>
254
+ ) : (
255
+ renderEntries(tree.entries, 0)
256
+ )}
257
+ </div>
258
+ </div>
259
+ );
260
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * FileTreeNode — recursive tree node for the file explorer.
3
+ */
4
+
5
+ import { useCallback, useState, useRef, useEffect } from 'react';
6
+ import type { FileSystemEntry } from '@bendyline/docblocks/filesystem';
7
+
8
+ export interface FileTreeNodeProps {
9
+ entry: FileSystemEntry;
10
+ depth: number;
11
+ expanded: boolean;
12
+ selected: boolean;
13
+ children?: FileSystemEntry[];
14
+ onToggle: (path: string) => void;
15
+ onSelect: (path: string) => void;
16
+ onDelete: (path: string) => Promise<void>;
17
+ onRename: (oldPath: string, newPath: string) => Promise<void>;
18
+ renderChildren?: (dirPath: string) => React.ReactNode;
19
+ }
20
+
21
+ export function FileTreeNode({
22
+ entry,
23
+ depth,
24
+ expanded,
25
+ selected,
26
+ onToggle,
27
+ onSelect,
28
+ onDelete,
29
+ onRename,
30
+ renderChildren,
31
+ }: FileTreeNodeProps) {
32
+ const [renaming, setRenaming] = useState(false);
33
+ const [renameValue, setRenameValue] = useState(entry.name);
34
+ const [showContext, setShowContext] = useState(false);
35
+ const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
36
+ const inputRef = useRef<HTMLInputElement>(null);
37
+ const contextRef = useRef<HTMLDivElement>(null);
38
+ const nodeRef = useRef<HTMLDivElement>(null);
39
+
40
+ const isDir = entry.kind === 'directory';
41
+ const icon = isDir ? (expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0';
42
+
43
+ const handleClick = useCallback(() => {
44
+ if (isDir) {
45
+ onToggle(entry.path);
46
+ }
47
+ onSelect(entry.path);
48
+ }, [isDir, entry.path, onToggle, onSelect]);
49
+
50
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
51
+ e.preventDefault();
52
+ e.stopPropagation();
53
+ const rect = nodeRef.current?.getBoundingClientRect();
54
+ setContextPos({
55
+ x: e.clientX - (rect?.left ?? 0),
56
+ y: e.clientY - (rect?.top ?? 0),
57
+ });
58
+ setShowContext(true);
59
+ }, []);
60
+
61
+ const handleRenameStart = useCallback(() => {
62
+ setRenameValue(entry.name);
63
+ setRenaming(true);
64
+ setShowContext(false);
65
+ }, [entry.name]);
66
+
67
+ const handleRenameSubmit = useCallback(async () => {
68
+ if (renameValue && renameValue !== entry.name) {
69
+ const parentPath = entry.path.includes('/')
70
+ ? entry.path.slice(0, entry.path.lastIndexOf('/'))
71
+ : '';
72
+ const newPath = parentPath ? `${parentPath}/${renameValue}` : renameValue;
73
+ await onRename(entry.path, newPath);
74
+ }
75
+ setRenaming(false);
76
+ }, [renameValue, entry.name, entry.path, onRename]);
77
+
78
+ const handleDeleteClick = useCallback(async () => {
79
+ setShowContext(false);
80
+ await onDelete(entry.path);
81
+ }, [entry.path, onDelete]);
82
+
83
+ // Close context menu on outside click or scroll
84
+ useEffect(() => {
85
+ if (!showContext) return;
86
+ function handleClose(e: Event) {
87
+ if (contextRef.current && !contextRef.current.contains(e.target as Node)) {
88
+ setShowContext(false);
89
+ }
90
+ }
91
+ // Use click (not mousedown) so menu button clicks register first
92
+ document.addEventListener('click', handleClose, true);
93
+ document.addEventListener('contextmenu', handleClose, true);
94
+ document.addEventListener('scroll', () => setShowContext(false), { capture: true, once: true });
95
+ return () => {
96
+ document.removeEventListener('click', handleClose, true);
97
+ document.removeEventListener('contextmenu', handleClose, true);
98
+ };
99
+ }, [showContext]);
100
+
101
+ // Focus input when renaming
102
+ useEffect(() => {
103
+ if (renaming && inputRef.current) {
104
+ inputRef.current.focus();
105
+ inputRef.current.select();
106
+ }
107
+ }, [renaming]);
108
+
109
+ return (
110
+ <div className="db-tree-node" ref={nodeRef}>
111
+ <div
112
+ className={`db-tree-row ${selected ? 'db-tree-row--selected' : ''}`}
113
+ style={{ paddingLeft: depth * 16 + 4 }}
114
+ onClick={handleClick}
115
+ onContextMenu={handleContextMenu}
116
+ role="treeitem"
117
+ aria-expanded={isDir ? expanded : undefined}
118
+ aria-selected={selected}
119
+ tabIndex={0}
120
+ onKeyDown={(e) => {
121
+ if (e.key === 'Enter') handleClick();
122
+ }}
123
+ >
124
+ <span className="db-tree-icon">{icon}</span>
125
+ {renaming ? (
126
+ <input
127
+ ref={inputRef}
128
+ className="db-tree-rename-input"
129
+ value={renameValue}
130
+ onChange={(e) => setRenameValue(e.target.value)}
131
+ onBlur={handleRenameSubmit}
132
+ onKeyDown={(e) => {
133
+ if (e.key === 'Enter') handleRenameSubmit();
134
+ if (e.key === 'Escape') setRenaming(false);
135
+ }}
136
+ onClick={(e) => e.stopPropagation()}
137
+ />
138
+ ) : (
139
+ <span className="db-tree-label">
140
+ {entry.name.endsWith('.md') ? entry.name.slice(0, -3) : entry.name}
141
+ </span>
142
+ )}
143
+ </div>
144
+
145
+ {/* Context menu */}
146
+ {showContext && (
147
+ <div
148
+ ref={contextRef}
149
+ className="db-tree-context"
150
+ style={{ left: contextPos.x, top: contextPos.y }}
151
+ >
152
+ <button className="db-tree-context-item" onClick={handleRenameStart}>
153
+ Rename
154
+ </button>
155
+ <button
156
+ className="db-tree-context-item db-tree-context-item--danger"
157
+ onClick={handleDeleteClick}
158
+ >
159
+ Delete
160
+ </button>
161
+ </div>
162
+ )}
163
+
164
+ {/* Render children if directory is expanded */}
165
+ {isDir && expanded && renderChildren && (
166
+ <div className="db-tree-children" role="group">
167
+ {renderChildren(entry.path)}
168
+ </div>
169
+ )}
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,5 @@
1
+ export { FileExplorer } from './FileExplorer.js';
2
+ export type { FileExplorerProps } from './FileExplorer.js';
3
+ export { FileTreeNode } from './FileTreeNode.js';
4
+ export type { FileTreeNodeProps } from './FileTreeNode.js';
5
+ export { useFileTree } from './useFileTree.js';
@@ -0,0 +1,178 @@
1
+ /**
2
+ * useFileTree — hook that reads from a FileSystemProvider,
3
+ * maintains expanded/collapsed state, and provides CRUD operations.
4
+ */
5
+
6
+ import { useState, useCallback, useEffect, useRef } from 'react';
7
+ import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
8
+
9
+ export interface FileTreeState {
10
+ /** Flat list of entries for the current directory view. */
11
+ entries: FileSystemEntry[];
12
+ /** Set of expanded directory paths. */
13
+ expanded: Set<string>;
14
+ /** Currently selected path — file or directory (null if none). */
15
+ selectedPath: string | null;
16
+ /** Kind of the selected entry. */
17
+ selectedKind: 'file' | 'directory' | null;
18
+ /** Whether the tree is loading. */
19
+ loading: boolean;
20
+ }
21
+
22
+ export interface FileTreeActions {
23
+ /** Toggle a directory's expanded state. */
24
+ toggleExpand: (path: string) => void;
25
+ /** Select a file or directory. */
26
+ select: (path: string, kind: 'file' | 'directory') => void;
27
+ /** Create a new file with optional initial content. */
28
+ createFile: (path: string, content?: string) => Promise<void>;
29
+ /** Create a new directory. */
30
+ createDirectory: (path: string) => Promise<void>;
31
+ /** Delete a file or directory. */
32
+ deleteEntry: (path: string) => Promise<void>;
33
+ /** Rename a file or directory. */
34
+ renameEntry: (oldPath: string, newPath: string) => Promise<void>;
35
+ /** Force refresh the tree. */
36
+ refresh: () => Promise<void>;
37
+ }
38
+
39
+ export function useFileTree(provider: FileSystemProvider | null): FileTreeState & FileTreeActions {
40
+ const [entries, setEntries] = useState<FileSystemEntry[]>([]);
41
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
42
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
43
+ const [selectedKind, setSelectedKind] = useState<'file' | 'directory' | null>(null);
44
+ const [loading, setLoading] = useState(false);
45
+
46
+ // Track child entries for expanded directories
47
+ const [childEntries, setChildEntries] = useState<Map<string, FileSystemEntry[]>>(new Map());
48
+
49
+ const providerRef = useRef(provider);
50
+ providerRef.current = provider;
51
+
52
+ const loadRoot = useCallback(async () => {
53
+ if (!providerRef.current) {
54
+ setEntries([]);
55
+ return;
56
+ }
57
+ setLoading(true);
58
+ try {
59
+ const root = await providerRef.current.readDirectory('');
60
+ setEntries(root);
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ }, []);
65
+
66
+ const loadChildren = useCallback(async (dirPath: string) => {
67
+ if (!providerRef.current) return;
68
+ const children = await providerRef.current.readDirectory(dirPath);
69
+ setChildEntries((prev) => {
70
+ const next = new Map(prev);
71
+ next.set(dirPath, children);
72
+ return next;
73
+ });
74
+ }, []);
75
+
76
+ // Load root on provider change
77
+ useEffect(() => {
78
+ setExpanded(new Set());
79
+ setChildEntries(new Map());
80
+ setSelectedPath(null);
81
+ setSelectedKind(null);
82
+ loadRoot();
83
+ }, [provider, loadRoot]);
84
+
85
+ const toggleExpand = useCallback(
86
+ (path: string) => {
87
+ setExpanded((prev) => {
88
+ const next = new Set(prev);
89
+ if (next.has(path)) {
90
+ next.delete(path);
91
+ } else {
92
+ next.add(path);
93
+ // Load children when expanding
94
+ loadChildren(path);
95
+ }
96
+ return next;
97
+ });
98
+ },
99
+ [loadChildren],
100
+ );
101
+
102
+ const select = useCallback((path: string, kind: 'file' | 'directory') => {
103
+ setSelectedPath(path);
104
+ setSelectedKind(kind);
105
+ }, []);
106
+
107
+ const refresh = useCallback(async () => {
108
+ await loadRoot();
109
+ // Reload all expanded directories
110
+ const expandedPaths = [...expanded];
111
+ for (const dirPath of expandedPaths) {
112
+ await loadChildren(dirPath);
113
+ }
114
+ }, [loadRoot, loadChildren, expanded]);
115
+
116
+ const createFile = useCallback(
117
+ async (path: string, content = '') => {
118
+ if (!providerRef.current) return;
119
+ await providerRef.current.writeFile(path, content);
120
+ await refresh();
121
+ },
122
+ [refresh],
123
+ );
124
+
125
+ const createDirectory = useCallback(
126
+ async (path: string) => {
127
+ if (!providerRef.current) return;
128
+ await providerRef.current.createDirectory(path);
129
+ await refresh();
130
+ },
131
+ [refresh],
132
+ );
133
+
134
+ const deleteEntry = useCallback(
135
+ async (path: string) => {
136
+ if (!providerRef.current) return;
137
+ await providerRef.current.delete(path);
138
+ if (selectedPath === path) {
139
+ setSelectedPath(null);
140
+ setSelectedKind(null);
141
+ }
142
+ await refresh();
143
+ },
144
+ [refresh, selectedPath],
145
+ );
146
+
147
+ const renameEntry = useCallback(
148
+ async (oldPath: string, newPath: string) => {
149
+ if (!providerRef.current) return;
150
+ await providerRef.current.rename(oldPath, newPath);
151
+ if (selectedPath === oldPath) {
152
+ setSelectedPath(newPath);
153
+ }
154
+ await refresh();
155
+ },
156
+ [refresh, selectedPath],
157
+ );
158
+
159
+ // Merge root entries with child entries for a flat tree representation
160
+ // (consumers use `expanded` and `childEntries` to render recursively)
161
+
162
+ return {
163
+ entries,
164
+ expanded,
165
+ selectedPath,
166
+ selectedKind,
167
+ loading,
168
+ toggleExpand,
169
+ select,
170
+ createFile,
171
+ createDirectory,
172
+ deleteEntry,
173
+ renameEntry,
174
+ refresh,
175
+ // Expose child entries for rendering
176
+ ...{ childEntries },
177
+ } as FileTreeState & FileTreeActions & { childEntries: Map<string, FileSystemEntry[]> };
178
+ }