@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/AppMenu/AppMenu.d.ts +11 -0
- package/dist/AppMenu/AppMenu.d.ts.map +1 -0
- package/dist/AppMenu/AppMenu.js +27 -0
- package/dist/AppMenu/AppMenu.js.map +1 -0
- package/dist/AppMenu/index.d.ts +3 -0
- package/dist/AppMenu/index.d.ts.map +1 -0
- package/dist/AppMenu/index.js +2 -0
- package/dist/AppMenu/index.js.map +1 -0
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +16 -0
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -0
- package/dist/DocBlocksShell/DocBlocksShell.js +487 -0
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -0
- package/dist/DocBlocksShell/index.d.ts +3 -0
- package/dist/DocBlocksShell/index.d.ts.map +1 -0
- package/dist/DocBlocksShell/index.js +2 -0
- package/dist/DocBlocksShell/index.js.map +1 -0
- package/dist/Export/ExportDialog.d.ts +16 -0
- package/dist/Export/ExportDialog.d.ts.map +1 -0
- package/dist/Export/ExportDialog.js +42 -0
- package/dist/Export/ExportDialog.js.map +1 -0
- package/dist/Export/ExportToolbarControls.d.ts +17 -0
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -0
- package/dist/Export/ExportToolbarControls.js +109 -0
- package/dist/Export/ExportToolbarControls.js.map +1 -0
- package/dist/Export/export-options.d.ts +18 -0
- package/dist/Export/export-options.d.ts.map +1 -0
- package/dist/Export/export-options.js +44 -0
- package/dist/Export/export-options.js.map +1 -0
- package/dist/Export/index.d.ts +6 -0
- package/dist/Export/index.d.ts.map +1 -0
- package/dist/Export/index.js +3 -0
- package/dist/Export/index.js.map +1 -0
- package/dist/Export/run-export.d.ts +8 -0
- package/dist/Export/run-export.d.ts.map +1 -0
- package/dist/Export/run-export.js +208 -0
- package/dist/Export/run-export.js.map +1 -0
- package/dist/FileExplorer/FileExplorer.d.ts +21 -0
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -0
- package/dist/FileExplorer/FileExplorer.js +125 -0
- package/dist/FileExplorer/FileExplorer.js.map +1 -0
- package/dist/FileExplorer/FileTreeNode.d.ts +18 -0
- package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -0
- package/dist/FileExplorer/FileTreeNode.js +86 -0
- package/dist/FileExplorer/FileTreeNode.js.map +1 -0
- package/dist/FileExplorer/index.d.ts +6 -0
- package/dist/FileExplorer/index.d.ts.map +1 -0
- package/dist/FileExplorer/index.js +4 -0
- package/dist/FileExplorer/index.js.map +1 -0
- package/dist/FileExplorer/useFileTree.d.ts +35 -0
- package/dist/FileExplorer/useFileTree.d.ts.map +1 -0
- package/dist/FileExplorer/useFileTree.js +124 -0
- package/dist/FileExplorer/useFileTree.js.map +1 -0
- package/dist/WorkspacePicker/WorkspacePicker.d.ts +17 -0
- package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspacePicker.js +57 -0
- package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +10 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js +27 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -0
- package/dist/WorkspacePicker/index.d.ts +3 -0
- package/dist/WorkspacePicker/index.d.ts.map +1 -0
- package/dist/WorkspacePicker/index.js +2 -0
- package/dist/WorkspacePicker/index.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAutoSave.d.ts +9 -0
- package/dist/hooks/useAutoSave.d.ts.map +1 -0
- package/dist/hooks/useAutoSave.js +48 -0
- package/dist/hooks/useAutoSave.js.map +1 -0
- package/dist/icons.d.ts +6 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +24 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/AppMenu/AppMenu.tsx +124 -0
- package/src/AppMenu/index.ts +2 -0
- package/src/DocBlocksShell/DocBlocksShell.tsx +662 -0
- package/src/DocBlocksShell/index.ts +2 -0
- package/src/Export/ExportDialog.tsx +173 -0
- package/src/Export/ExportToolbarControls.tsx +183 -0
- package/src/Export/export-options.ts +57 -0
- package/src/Export/index.ts +5 -0
- package/src/Export/run-export.ts +238 -0
- package/src/FileExplorer/FileExplorer.tsx +260 -0
- package/src/FileExplorer/FileTreeNode.tsx +172 -0
- package/src/FileExplorer/index.ts +5 -0
- package/src/FileExplorer/useFileTree.ts +178 -0
- package/src/WorkspacePicker/WorkspacePicker.tsx +126 -0
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +79 -0
- package/src/WorkspacePicker/index.ts +2 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAutoSave.ts +58 -0
- package/src/icons.tsx +60 -0
- package/src/index.ts +31 -0
- 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
|
+
}
|