create-visualbuild-app 0.1.0 → 1.0.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/README.md +306 -123
- package/docs/user-guide.md +759 -0
- package/package.json +92 -85
- package/scripts/create-builder-app.mjs +513 -501
- package/scripts/vb-dev.mjs +41 -32
- package/scripts/vb-generate.mjs +332 -224
- package/templates/default/app/.vercelignore +6 -0
- package/templates/default/app/README.md +56 -21
- package/templates/default/app/visualbuild/components.json +3 -0
- package/templates/default/app/visualbuild/config.d.ts +48 -0
- package/templates/default/app/visualbuild.config.ts +38 -0
- package/templates/default/builder/README.md +37 -21
- package/visual-app-builder/server/parseReactPage.ts +776 -571
- package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
- package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
- package/visual-app-builder/shared/generateReactSource.mjs +608 -443
- package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
- package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
- package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
- package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
- package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
- package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
- package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
- package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
- package/visual-app-builder/src/App.tsx +1090 -874
- package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
- package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
- package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
- package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
- package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
- package/visual-app-builder/src/components/Topbar.tsx +257 -128
- package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
- package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
- package/visual-app-builder/src/index.css +383 -111
- package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
- package/visual-app-builder/src/theme.ts +71 -0
- package/visual-app-builder/src/types/index.ts +350 -261
- package/visual-app-builder/src/utils/codegen.ts +90 -66
- package/visual-app-builder/src/utils/deployment.ts +54 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
- package/visual-app-builder/vite.config.ts +1946 -1479
|
@@ -1,1054 +1,1054 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
createProjectEntry,
|
|
4
|
-
deleteProjectEntry,
|
|
5
|
-
listProjectDirectory,
|
|
6
|
-
listProjectDirectories,
|
|
7
|
-
moveProjectEntry,
|
|
8
|
-
renameProjectEntry,
|
|
9
|
-
type ProjectEntry,
|
|
10
|
-
} from '../utils/projectFiles'
|
|
11
|
-
|
|
12
|
-
interface ProjectExplorerProps {
|
|
13
|
-
selectedPath: string | null
|
|
14
|
-
onSelectFile: (path: string | null) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface FileSystemChangeDetail {
|
|
18
|
-
type: 'filesystem-change'
|
|
19
|
-
path: string
|
|
20
|
-
action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
type CreateState = {
|
|
24
|
-
parentPath: string
|
|
25
|
-
kind: 'file' | 'directory'
|
|
26
|
-
} | null
|
|
27
|
-
|
|
28
|
-
const initiallyOpenDirectories = new Set(['', 'src', 'src/pages'])
|
|
29
|
-
|
|
30
|
-
export default function ProjectExplorer({
|
|
31
|
-
selectedPath,
|
|
32
|
-
onSelectFile,
|
|
33
|
-
}: ProjectExplorerProps) {
|
|
34
|
-
const [rootName, setRootName] = useState('Project')
|
|
35
|
-
const [entriesByDirectory, setEntriesByDirectory] = useState<
|
|
36
|
-
Record<string, ProjectEntry[]>
|
|
37
|
-
>({})
|
|
38
|
-
const [openDirectories, setOpenDirectories] = useState(
|
|
39
|
-
() => new Set(initiallyOpenDirectories)
|
|
40
|
-
)
|
|
41
|
-
const [selectedDirectory, setSelectedDirectory] = useState('')
|
|
42
|
-
const [createState, setCreateState] = useState<CreateState>(null)
|
|
43
|
-
const [renamePath, setRenamePath] = useState<string | null>(null)
|
|
44
|
-
const [moveTarget, setMoveTarget] = useState<ProjectEntry | null>(null)
|
|
45
|
-
const [deleteTarget, setDeleteTarget] = useState<ProjectEntry | null>(null)
|
|
46
|
-
const [busyPath, setBusyPath] = useState<string | null>(null)
|
|
47
|
-
const [error, setError] = useState<string | null>(null)
|
|
48
|
-
const mounted = useRef(true)
|
|
49
|
-
|
|
50
|
-
const pruneMissingDirectory = useCallback((directoryPath: string) => {
|
|
51
|
-
if (!directoryPath) return
|
|
52
|
-
|
|
53
|
-
setOpenDirectories(
|
|
54
|
-
(current) =>
|
|
55
|
-
new Set(
|
|
56
|
-
Array.from(current).filter(
|
|
57
|
-
(path) => !isPathWithin(path, directoryPath)
|
|
58
|
-
)
|
|
59
|
-
)
|
|
60
|
-
)
|
|
61
|
-
setEntriesByDirectory((current) =>
|
|
62
|
-
Object.fromEntries(
|
|
63
|
-
Object.entries(current)
|
|
64
|
-
.filter(([path]) => !isPathWithin(path, directoryPath))
|
|
65
|
-
.map(([path, entries]) => [
|
|
66
|
-
path,
|
|
67
|
-
entries.filter(
|
|
68
|
-
(entry) => !isPathWithin(entry.path, directoryPath)
|
|
69
|
-
),
|
|
70
|
-
])
|
|
71
|
-
)
|
|
72
|
-
)
|
|
73
|
-
setSelectedDirectory((current) =>
|
|
74
|
-
isPathWithin(current, directoryPath)
|
|
75
|
-
? getParentPath(directoryPath)
|
|
76
|
-
: current
|
|
77
|
-
)
|
|
78
|
-
setError(null)
|
|
79
|
-
}, [])
|
|
80
|
-
|
|
81
|
-
const loadDirectory = useCallback(
|
|
82
|
-
async (directoryPath: string) => {
|
|
83
|
-
try {
|
|
84
|
-
const result = await listProjectDirectory(directoryPath)
|
|
85
|
-
|
|
86
|
-
if (!mounted.current) return
|
|
87
|
-
setRootName(result.rootName)
|
|
88
|
-
setEntriesByDirectory((current) => ({
|
|
89
|
-
...current,
|
|
90
|
-
[directoryPath]: result.entries,
|
|
91
|
-
}))
|
|
92
|
-
setError(null)
|
|
93
|
-
} catch (loadError) {
|
|
94
|
-
if (!mounted.current) return
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
directoryPath &&
|
|
98
|
-
isMissingProjectPathError(getErrorMessage(loadError))
|
|
99
|
-
) {
|
|
100
|
-
pruneMissingDirectory(directoryPath)
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
setError(getErrorMessage(loadError))
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
[pruneMissingDirectory]
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
const refreshOpenDirectories = useCallback(async () => {
|
|
111
|
-
await Promise.all(
|
|
112
|
-
Array.from(openDirectories, (directoryPath) =>
|
|
113
|
-
loadDirectory(directoryPath)
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
}, [loadDirectory, openDirectories])
|
|
117
|
-
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
mounted.current = true
|
|
120
|
-
void refreshOpenDirectories()
|
|
121
|
-
|
|
122
|
-
return () => {
|
|
123
|
-
mounted.current = false
|
|
124
|
-
}
|
|
125
|
-
}, [refreshOpenDirectories])
|
|
126
|
-
|
|
127
|
-
useEffect(() => {
|
|
128
|
-
const interval = window.setInterval(() => {
|
|
129
|
-
void refreshOpenDirectories()
|
|
130
|
-
}, 3000)
|
|
131
|
-
|
|
132
|
-
return () => window.clearInterval(interval)
|
|
133
|
-
}, [refreshOpenDirectories])
|
|
134
|
-
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
const refresh = (event: Event) => {
|
|
137
|
-
const detail = (event as CustomEvent<FileSystemChangeDetail>).detail
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
detail?.type === 'filesystem-change' &&
|
|
141
|
-
detail.action === 'unlinkDir'
|
|
142
|
-
) {
|
|
143
|
-
const removedPath = detail.path.replace(/\\/g, '/')
|
|
144
|
-
pruneMissingDirectory(removedPath)
|
|
145
|
-
void loadDirectory(getParentPath(removedPath))
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
void refreshOpenDirectories()
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
window.addEventListener('visualbuild:filesystem-changed', refresh)
|
|
153
|
-
return () =>
|
|
154
|
-
window.removeEventListener('visualbuild:filesystem-changed', refresh)
|
|
155
|
-
}, [loadDirectory, pruneMissingDirectory, refreshOpenDirectories])
|
|
156
|
-
|
|
157
|
-
const selectedParentDirectory = useMemo(() => {
|
|
158
|
-
if (!selectedPath) return selectedDirectory
|
|
159
|
-
const entry = findEntry(entriesByDirectory, selectedPath)
|
|
160
|
-
|
|
161
|
-
if (entry?.kind === 'directory') return entry.path
|
|
162
|
-
return getParentPath(selectedPath)
|
|
163
|
-
}, [entriesByDirectory, selectedDirectory, selectedPath])
|
|
164
|
-
|
|
165
|
-
const toggleDirectory = (directoryPath: string) => {
|
|
166
|
-
setSelectedDirectory(directoryPath)
|
|
167
|
-
onSelectFile(null)
|
|
168
|
-
setOpenDirectories((current) => {
|
|
169
|
-
const next = new Set(current)
|
|
170
|
-
|
|
171
|
-
if (next.has(directoryPath)) {
|
|
172
|
-
next.delete(directoryPath)
|
|
173
|
-
} else {
|
|
174
|
-
next.add(directoryPath)
|
|
175
|
-
void loadDirectory(directoryPath)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return next
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const startCreate = (kind: 'file' | 'directory') => {
|
|
183
|
-
setCreateState({ parentPath: selectedParentDirectory, kind })
|
|
184
|
-
setRenamePath(null)
|
|
185
|
-
setError(null)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const handleCreate = async (name: string) => {
|
|
189
|
-
if (!createState) return
|
|
190
|
-
setBusyPath(createState.parentPath || '/')
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
const entry = await createProjectEntry(
|
|
194
|
-
createState.parentPath,
|
|
195
|
-
name,
|
|
196
|
-
createState.kind
|
|
197
|
-
)
|
|
198
|
-
setCreateState(null)
|
|
199
|
-
setOpenDirectories((current) =>
|
|
200
|
-
new Set(current).add(createState.parentPath)
|
|
201
|
-
)
|
|
202
|
-
await loadDirectory(createState.parentPath)
|
|
203
|
-
|
|
204
|
-
if (entry.kind === 'directory') {
|
|
205
|
-
onSelectFile(null)
|
|
206
|
-
setSelectedDirectory(entry.path)
|
|
207
|
-
setOpenDirectories((current) => new Set(current).add(entry.path))
|
|
208
|
-
await loadDirectory(entry.path)
|
|
209
|
-
} else {
|
|
210
|
-
onSelectFile(entry.path)
|
|
211
|
-
}
|
|
212
|
-
} catch (createError) {
|
|
213
|
-
setError(getErrorMessage(createError))
|
|
214
|
-
} finally {
|
|
215
|
-
setBusyPath(null)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const handleRename = async (entry: ProjectEntry, name: string) => {
|
|
220
|
-
setBusyPath(entry.path)
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const renamed = await renameProjectEntry(entry.path, name)
|
|
224
|
-
const parentPath = getParentPath(entry.path)
|
|
225
|
-
setRenamePath(null)
|
|
226
|
-
await loadDirectory(parentPath)
|
|
227
|
-
|
|
228
|
-
if (entry.kind === 'directory') {
|
|
229
|
-
setEntriesByDirectory((current) =>
|
|
230
|
-
renameDirectoryCache(current, entry.path, renamed.path)
|
|
231
|
-
)
|
|
232
|
-
setOpenDirectories((current) =>
|
|
233
|
-
renameOpenDirectories(current, entry.path, renamed.path)
|
|
234
|
-
)
|
|
235
|
-
setSelectedDirectory((current) =>
|
|
236
|
-
replacePathPrefix(current, entry.path, renamed.path)
|
|
237
|
-
)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (selectedPath) {
|
|
241
|
-
const nextSelectedPath = replacePathPrefix(
|
|
242
|
-
selectedPath,
|
|
243
|
-
entry.path,
|
|
244
|
-
renamed.path
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
if (nextSelectedPath !== selectedPath) {
|
|
248
|
-
onSelectFile(nextSelectedPath)
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
} catch (renameError) {
|
|
252
|
-
setError(getErrorMessage(renameError))
|
|
253
|
-
} finally {
|
|
254
|
-
setBusyPath(null)
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const handleDelete = async () => {
|
|
259
|
-
if (!deleteTarget) return
|
|
260
|
-
const target = deleteTarget
|
|
261
|
-
setDeleteTarget(null)
|
|
262
|
-
setBusyPath(target.path)
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
await deleteProjectEntry(target.path)
|
|
266
|
-
const parentPath = getParentPath(target.path)
|
|
267
|
-
await loadDirectory(parentPath)
|
|
268
|
-
|
|
269
|
-
if (selectedPath && isPathWithin(selectedPath, target.path)) {
|
|
270
|
-
onSelectFile(null)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (isPathWithin(selectedDirectory, target.path)) {
|
|
274
|
-
setSelectedDirectory(parentPath)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
setOpenDirectories((current) =>
|
|
278
|
-
new Set(
|
|
279
|
-
Array.from(current).filter(
|
|
280
|
-
(directoryPath) => !isPathWithin(directoryPath, target.path)
|
|
281
|
-
)
|
|
282
|
-
)
|
|
283
|
-
)
|
|
284
|
-
setEntriesByDirectory((current) =>
|
|
285
|
-
Object.fromEntries(
|
|
286
|
-
Object.entries(current).filter(
|
|
287
|
-
([directoryPath]) => !isPathWithin(directoryPath, target.path)
|
|
288
|
-
)
|
|
289
|
-
)
|
|
290
|
-
)
|
|
291
|
-
} catch (deleteError) {
|
|
292
|
-
setError(getErrorMessage(deleteError))
|
|
293
|
-
} finally {
|
|
294
|
-
setBusyPath(null)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const handleMove = async (parentPath: string) => {
|
|
299
|
-
if (!moveTarget) return
|
|
300
|
-
const entry = moveTarget
|
|
301
|
-
setMoveTarget(null)
|
|
302
|
-
setBusyPath(entry.path)
|
|
303
|
-
|
|
304
|
-
try {
|
|
305
|
-
const moved = await moveProjectEntry(entry.path, parentPath)
|
|
306
|
-
const previousParentPath = getParentPath(entry.path)
|
|
307
|
-
setEntriesByDirectory((current) =>
|
|
308
|
-
renameDirectoryCache(current, entry.path, moved.path)
|
|
309
|
-
)
|
|
310
|
-
setOpenDirectories((current) =>
|
|
311
|
-
renameOpenDirectories(current, entry.path, moved.path)
|
|
312
|
-
)
|
|
313
|
-
setSelectedDirectory((current) =>
|
|
314
|
-
replacePathPrefix(current, entry.path, moved.path)
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
if (selectedPath) {
|
|
318
|
-
const nextSelectedPath = replacePathPrefix(
|
|
319
|
-
selectedPath,
|
|
320
|
-
entry.path,
|
|
321
|
-
moved.path
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
if (nextSelectedPath !== selectedPath) {
|
|
325
|
-
onSelectFile(nextSelectedPath)
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
await Promise.all([
|
|
330
|
-
loadDirectory(previousParentPath),
|
|
331
|
-
loadDirectory(parentPath),
|
|
332
|
-
])
|
|
333
|
-
} catch (moveError) {
|
|
334
|
-
setError(getErrorMessage(moveError))
|
|
335
|
-
} finally {
|
|
336
|
-
setBusyPath(null)
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return (
|
|
341
|
-
<div className="flex h-full min-h-0 flex-col text-[11px]">
|
|
342
|
-
<div className="flex shrink-0 items-center gap-1 border-b border-white/[0.05] px-2 py-1.5">
|
|
343
|
-
<ExplorerAction label="New file" onClick={() => startCreate('file')} />
|
|
344
|
-
<ExplorerAction
|
|
345
|
-
label="New folder"
|
|
346
|
-
onClick={() => startCreate('directory')}
|
|
347
|
-
/>
|
|
348
|
-
<ExplorerAction
|
|
349
|
-
label="Refresh"
|
|
350
|
-
onClick={() => void refreshOpenDirectories()}
|
|
351
|
-
/>
|
|
352
|
-
</div>
|
|
353
|
-
|
|
354
|
-
{createState && (
|
|
355
|
-
<NameEditor
|
|
356
|
-
label={createState.kind === 'file' ? 'New file' : 'New folder'}
|
|
357
|
-
parentPath={createState.parentPath}
|
|
358
|
-
onCancel={() => setCreateState(null)}
|
|
359
|
-
onSubmit={(name) => void handleCreate(name)}
|
|
360
|
-
/>
|
|
361
|
-
)}
|
|
362
|
-
|
|
363
|
-
{error && (
|
|
364
|
-
<div className="mx-2 mt-2 rounded border border-red-400/20 bg-red-500/10 px-2 py-1.5 text-[10px] leading-4 text-red-300">
|
|
365
|
-
{error}
|
|
366
|
-
</div>
|
|
367
|
-
)}
|
|
368
|
-
|
|
369
|
-
<div className="min-h-0 flex-1 overflow-auto px-1.5 py-1.5">
|
|
370
|
-
<ProjectTreeRow
|
|
371
|
-
name={rootName}
|
|
372
|
-
path=""
|
|
373
|
-
kind="directory"
|
|
374
|
-
depth={0}
|
|
375
|
-
isOpen={openDirectories.has('')}
|
|
376
|
-
isSelected={!selectedPath && selectedDirectory === ''}
|
|
377
|
-
isBusy={busyPath === '/'}
|
|
378
|
-
onSelect={() => toggleDirectory('')}
|
|
379
|
-
/>
|
|
380
|
-
{openDirectories.has('') &&
|
|
381
|
-
<ProjectEntries
|
|
382
|
-
directoryPath=""
|
|
383
|
-
depth={1}
|
|
384
|
-
entriesByDirectory={entriesByDirectory}
|
|
385
|
-
openDirectories={openDirectories}
|
|
386
|
-
selectedPath={selectedPath}
|
|
387
|
-
selectedDirectory={selectedDirectory}
|
|
388
|
-
renamePath={renamePath}
|
|
389
|
-
busyPath={busyPath}
|
|
390
|
-
onToggleDirectory={toggleDirectory}
|
|
391
|
-
onSelectFile={(path) => {
|
|
392
|
-
setSelectedDirectory(getParentPath(path))
|
|
393
|
-
onSelectFile(path)
|
|
394
|
-
}}
|
|
395
|
-
onStartRename={(path) => {
|
|
396
|
-
setCreateState(null)
|
|
397
|
-
setRenamePath(path)
|
|
398
|
-
}}
|
|
399
|
-
onRename={handleRename}
|
|
400
|
-
onCancelRename={() => setRenamePath(null)}
|
|
401
|
-
onMove={setMoveTarget}
|
|
402
|
-
onDelete={setDeleteTarget}
|
|
403
|
-
/>}
|
|
404
|
-
</div>
|
|
405
|
-
|
|
406
|
-
{deleteTarget && (
|
|
407
|
-
<DeleteDialog
|
|
408
|
-
entry={deleteTarget}
|
|
409
|
-
onCancel={() => setDeleteTarget(null)}
|
|
410
|
-
onConfirm={() => void handleDelete()}
|
|
411
|
-
/>
|
|
412
|
-
)}
|
|
413
|
-
{moveTarget && (
|
|
414
|
-
<MoveDialog
|
|
415
|
-
entry={moveTarget}
|
|
416
|
-
initialParentPath={getParentPath(moveTarget.path)}
|
|
417
|
-
onCancel={() => setMoveTarget(null)}
|
|
418
|
-
onConfirm={(parentPath) => void handleMove(parentPath)}
|
|
419
|
-
/>
|
|
420
|
-
)}
|
|
421
|
-
</div>
|
|
422
|
-
)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function ProjectEntries({
|
|
426
|
-
directoryPath,
|
|
427
|
-
depth,
|
|
428
|
-
entriesByDirectory,
|
|
429
|
-
openDirectories,
|
|
430
|
-
selectedPath,
|
|
431
|
-
selectedDirectory,
|
|
432
|
-
renamePath,
|
|
433
|
-
busyPath,
|
|
434
|
-
onToggleDirectory,
|
|
435
|
-
onSelectFile,
|
|
436
|
-
onStartRename,
|
|
437
|
-
onRename,
|
|
438
|
-
onCancelRename,
|
|
439
|
-
onMove,
|
|
440
|
-
onDelete,
|
|
441
|
-
}: {
|
|
442
|
-
directoryPath: string
|
|
443
|
-
depth: number
|
|
444
|
-
entriesByDirectory: Record<string, ProjectEntry[]>
|
|
445
|
-
openDirectories: Set<string>
|
|
446
|
-
selectedPath: string | null
|
|
447
|
-
selectedDirectory: string
|
|
448
|
-
renamePath: string | null
|
|
449
|
-
busyPath: string | null
|
|
450
|
-
onToggleDirectory: (path: string) => void
|
|
451
|
-
onSelectFile: (path: string) => void
|
|
452
|
-
onStartRename: (path: string) => void
|
|
453
|
-
onRename: (entry: ProjectEntry, name: string) => Promise<void>
|
|
454
|
-
onCancelRename: () => void
|
|
455
|
-
onMove: (entry: ProjectEntry) => void
|
|
456
|
-
onDelete: (entry: ProjectEntry) => void
|
|
457
|
-
}): React.ReactNode {
|
|
458
|
-
const entries = entriesByDirectory[directoryPath]
|
|
459
|
-
|
|
460
|
-
if (!entries) {
|
|
461
|
-
return (
|
|
462
|
-
<div
|
|
463
|
-
className="py-1 text-[10px] text-gray-600"
|
|
464
|
-
style={{ paddingLeft: `${depth * 0.8 + 1.25}rem` }}
|
|
465
|
-
>
|
|
466
|
-
Loading...
|
|
467
|
-
</div>
|
|
468
|
-
)
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (entries.length === 0) {
|
|
472
|
-
return (
|
|
473
|
-
<div
|
|
474
|
-
className="py-1 text-[10px] text-gray-600"
|
|
475
|
-
style={{ paddingLeft: `${depth * 0.8 + 1.25}rem` }}
|
|
476
|
-
>
|
|
477
|
-
Empty folder
|
|
478
|
-
</div>
|
|
479
|
-
)
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return (
|
|
483
|
-
<>
|
|
484
|
-
{entries.map((entry) => {
|
|
485
|
-
const isDirectory = entry.kind === 'directory'
|
|
486
|
-
const isOpen = isDirectory && openDirectories.has(entry.path)
|
|
487
|
-
|
|
488
|
-
return (
|
|
489
|
-
<div key={entry.path}>
|
|
490
|
-
{renamePath === entry.path ? (
|
|
491
|
-
<InlineRename
|
|
492
|
-
entry={entry}
|
|
493
|
-
depth={depth}
|
|
494
|
-
onCancel={onCancelRename}
|
|
495
|
-
onSubmit={(name) => void onRename(entry, name)}
|
|
496
|
-
/>
|
|
497
|
-
) : (
|
|
498
|
-
<ProjectTreeRow
|
|
499
|
-
name={entry.name}
|
|
500
|
-
path={entry.path}
|
|
501
|
-
kind={entry.kind}
|
|
502
|
-
depth={depth}
|
|
503
|
-
isOpen={isOpen}
|
|
504
|
-
isSelected={
|
|
505
|
-
isDirectory
|
|
506
|
-
? !selectedPath && selectedDirectory === entry.path
|
|
507
|
-
: selectedPath === entry.path
|
|
508
|
-
}
|
|
509
|
-
isBusy={busyPath === entry.path}
|
|
510
|
-
onSelect={() =>
|
|
511
|
-
isDirectory
|
|
512
|
-
? onToggleDirectory(entry.path)
|
|
513
|
-
: onSelectFile(entry.path)
|
|
514
|
-
}
|
|
515
|
-
onRename={() => onStartRename(entry.path)}
|
|
516
|
-
onMove={() => onMove(entry)}
|
|
517
|
-
onDelete={() => onDelete(entry)}
|
|
518
|
-
/>
|
|
519
|
-
)}
|
|
520
|
-
{isOpen && (
|
|
521
|
-
<ProjectEntries
|
|
522
|
-
directoryPath={entry.path}
|
|
523
|
-
depth={depth + 1}
|
|
524
|
-
entriesByDirectory={entriesByDirectory}
|
|
525
|
-
openDirectories={openDirectories}
|
|
526
|
-
selectedPath={selectedPath}
|
|
527
|
-
selectedDirectory={selectedDirectory}
|
|
528
|
-
renamePath={renamePath}
|
|
529
|
-
busyPath={busyPath}
|
|
530
|
-
onToggleDirectory={onToggleDirectory}
|
|
531
|
-
onSelectFile={onSelectFile}
|
|
532
|
-
onStartRename={onStartRename}
|
|
533
|
-
onRename={onRename}
|
|
534
|
-
onCancelRename={onCancelRename}
|
|
535
|
-
onMove={onMove}
|
|
536
|
-
onDelete={onDelete}
|
|
537
|
-
/>
|
|
538
|
-
)}
|
|
539
|
-
</div>
|
|
540
|
-
)
|
|
541
|
-
})}
|
|
542
|
-
</>
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function ProjectTreeRow({
|
|
547
|
-
name,
|
|
548
|
-
path,
|
|
549
|
-
kind,
|
|
550
|
-
depth,
|
|
551
|
-
isOpen,
|
|
552
|
-
isSelected,
|
|
553
|
-
isBusy,
|
|
554
|
-
onSelect,
|
|
555
|
-
onRename,
|
|
556
|
-
onMove,
|
|
557
|
-
onDelete,
|
|
558
|
-
}: {
|
|
559
|
-
name: string
|
|
560
|
-
path: string
|
|
561
|
-
kind: ProjectEntry['kind']
|
|
562
|
-
depth: number
|
|
563
|
-
isOpen: boolean
|
|
564
|
-
isSelected: boolean
|
|
565
|
-
isBusy: boolean
|
|
566
|
-
onSelect: () => void
|
|
567
|
-
onRename?: () => void
|
|
568
|
-
onMove?: () => void
|
|
569
|
-
onDelete?: () => void
|
|
570
|
-
}) {
|
|
571
|
-
const isDirectory = kind === 'directory'
|
|
572
|
-
|
|
573
|
-
return (
|
|
574
|
-
<div
|
|
575
|
-
className={`group flex min-h-7 items-center rounded transition-colors ${
|
|
576
|
-
isSelected
|
|
577
|
-
? 'bg-accent/30 text-white'
|
|
578
|
-
: 'text-gray-400 hover:bg-white/[0.05] hover:text-gray-100'
|
|
579
|
-
}`}
|
|
580
|
-
style={{ paddingLeft: `${depth * 0.8}rem` }}
|
|
581
|
-
title={path || name}
|
|
582
|
-
>
|
|
583
|
-
<button
|
|
584
|
-
type="button"
|
|
585
|
-
onClick={onSelect}
|
|
586
|
-
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 py-1 pr-1 text-left"
|
|
587
|
-
>
|
|
588
|
-
<span className="flex w-3 shrink-0 items-center justify-center text-[9px] text-gray-500">
|
|
589
|
-
{isDirectory ? (isOpen ? 'v' : '>') : kind === 'symlink' ? '@' : ''}
|
|
590
|
-
</span>
|
|
591
|
-
<span className="truncate">{name}</span>
|
|
592
|
-
{isBusy && <span className="text-[9px] text-blue-300">...</span>}
|
|
593
|
-
</button>
|
|
594
|
-
{(onRename || onMove || onDelete) && (
|
|
595
|
-
<div className="mr-1 hidden shrink-0 items-center gap-0.5 group-hover:flex group-focus-within:flex">
|
|
596
|
-
{onRename && (
|
|
597
|
-
<RowAction label="Rename" text="R" onClick={onRename} />
|
|
598
|
-
)}
|
|
599
|
-
{onMove && (
|
|
600
|
-
<RowAction label="Move" text="M" onClick={onMove} />
|
|
601
|
-
)}
|
|
602
|
-
{onDelete && (
|
|
603
|
-
<RowAction label="Delete" text="x" danger onClick={onDelete} />
|
|
604
|
-
)}
|
|
605
|
-
</div>
|
|
606
|
-
)}
|
|
607
|
-
</div>
|
|
608
|
-
)
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function ExplorerAction({ label, onClick }: { label: string; onClick: () => void }) {
|
|
612
|
-
return (
|
|
613
|
-
<button
|
|
614
|
-
type="button"
|
|
615
|
-
onClick={onClick}
|
|
616
|
-
className="min-w-0 flex-1 cursor-pointer rounded bg-white/[0.04] px-1.5 py-1 text-[9px] text-gray-400 transition-colors hover:bg-white/[0.09] hover:text-white"
|
|
617
|
-
title={label}
|
|
618
|
-
>
|
|
619
|
-
{label}
|
|
620
|
-
</button>
|
|
621
|
-
)
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function RowAction({
|
|
625
|
-
label,
|
|
626
|
-
text,
|
|
627
|
-
danger = false,
|
|
628
|
-
onClick,
|
|
629
|
-
}: {
|
|
630
|
-
label: string
|
|
631
|
-
text: string
|
|
632
|
-
danger?: boolean
|
|
633
|
-
onClick: () => void
|
|
634
|
-
}) {
|
|
635
|
-
return (
|
|
636
|
-
<button
|
|
637
|
-
type="button"
|
|
638
|
-
onClick={(event) => {
|
|
639
|
-
event.stopPropagation()
|
|
640
|
-
onClick()
|
|
641
|
-
}}
|
|
642
|
-
className={`flex h-5 w-5 cursor-pointer items-center justify-center rounded text-[9px] transition-colors ${
|
|
643
|
-
danger
|
|
644
|
-
? 'text-gray-500 hover:bg-red-500 hover:text-white'
|
|
645
|
-
: 'text-gray-500 hover:bg-white/[0.1] hover:text-white'
|
|
646
|
-
}`}
|
|
647
|
-
aria-label={`${label} ${text}`}
|
|
648
|
-
title={label}
|
|
649
|
-
>
|
|
650
|
-
{text}
|
|
651
|
-
</button>
|
|
652
|
-
)
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function NameEditor({
|
|
656
|
-
label,
|
|
657
|
-
parentPath,
|
|
658
|
-
onCancel,
|
|
659
|
-
onSubmit,
|
|
660
|
-
}: {
|
|
661
|
-
label: string
|
|
662
|
-
parentPath: string
|
|
663
|
-
onCancel: () => void
|
|
664
|
-
onSubmit: (name: string) => void
|
|
665
|
-
}) {
|
|
666
|
-
const [name, setName] = useState('')
|
|
667
|
-
|
|
668
|
-
return (
|
|
669
|
-
<form
|
|
670
|
-
className="shrink-0 border-b border-white/[0.05] bg-black/10 px-2 py-2"
|
|
671
|
-
onSubmit={(event) => {
|
|
672
|
-
event.preventDefault()
|
|
673
|
-
if (name.trim()) onSubmit(name.trim())
|
|
674
|
-
}}
|
|
675
|
-
>
|
|
676
|
-
<p className="mb-1 truncate text-[9px] uppercase tracking-wide text-gray-500">
|
|
677
|
-
{label} in {parentPath || '/'}
|
|
678
|
-
</p>
|
|
679
|
-
<div className="flex gap-1">
|
|
680
|
-
<input
|
|
681
|
-
autoFocus
|
|
682
|
-
value={name}
|
|
683
|
-
onChange={(event) => setName(event.target.value)}
|
|
684
|
-
onKeyDown={(event) => {
|
|
685
|
-
if (event.key === 'Escape') onCancel()
|
|
686
|
-
}}
|
|
687
|
-
className="h-7 min-w-0 flex-1 rounded border border-blue-400/70 bg-[#11141a] px-2 text-[11px] text-white outline-none"
|
|
688
|
-
aria-label={label}
|
|
689
|
-
/>
|
|
690
|
-
<button
|
|
691
|
-
type="submit"
|
|
692
|
-
className="cursor-pointer rounded bg-blue-500 px-2 text-[10px] font-medium text-white hover:bg-blue-400"
|
|
693
|
-
>
|
|
694
|
-
Add
|
|
695
|
-
</button>
|
|
696
|
-
<button
|
|
697
|
-
type="button"
|
|
698
|
-
onClick={onCancel}
|
|
699
|
-
className="cursor-pointer rounded bg-white/[0.06] px-2 text-[10px] text-gray-300 hover:bg-white/[0.1]"
|
|
700
|
-
>
|
|
701
|
-
Cancel
|
|
702
|
-
</button>
|
|
703
|
-
</div>
|
|
704
|
-
</form>
|
|
705
|
-
)
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function InlineRename({
|
|
709
|
-
entry,
|
|
710
|
-
depth,
|
|
711
|
-
onCancel,
|
|
712
|
-
onSubmit,
|
|
713
|
-
}: {
|
|
714
|
-
entry: ProjectEntry
|
|
715
|
-
depth: number
|
|
716
|
-
onCancel: () => void
|
|
717
|
-
onSubmit: (name: string) => void
|
|
718
|
-
}) {
|
|
719
|
-
const [name, setName] = useState(entry.name)
|
|
720
|
-
|
|
721
|
-
return (
|
|
722
|
-
<form
|
|
723
|
-
className="flex min-h-7 items-center gap-1 pr-1"
|
|
724
|
-
style={{ paddingLeft: `${depth * 0.8 + 0.75}rem` }}
|
|
725
|
-
onSubmit={(event) => {
|
|
726
|
-
event.preventDefault()
|
|
727
|
-
if (name.trim() && name.trim() !== entry.name) onSubmit(name.trim())
|
|
728
|
-
else onCancel()
|
|
729
|
-
}}
|
|
730
|
-
>
|
|
731
|
-
<input
|
|
732
|
-
autoFocus
|
|
733
|
-
value={name}
|
|
734
|
-
onChange={(event) => setName(event.target.value)}
|
|
735
|
-
onBlur={() => {
|
|
736
|
-
const nextName = name.trim()
|
|
737
|
-
if (nextName && nextName !== entry.name) onSubmit(nextName)
|
|
738
|
-
else onCancel()
|
|
739
|
-
}}
|
|
740
|
-
onKeyDown={(event) => {
|
|
741
|
-
if (event.key === 'Escape') onCancel()
|
|
742
|
-
}}
|
|
743
|
-
onFocus={(event) => selectFileName(event.currentTarget, entry.kind)}
|
|
744
|
-
className="h-6 min-w-0 flex-1 rounded border border-blue-400 bg-[#11141a] px-1.5 text-[11px] text-white outline-none"
|
|
745
|
-
aria-label={`Rename ${entry.name}`}
|
|
746
|
-
/>
|
|
747
|
-
</form>
|
|
748
|
-
)
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function MoveDialog({
|
|
752
|
-
entry,
|
|
753
|
-
initialParentPath,
|
|
754
|
-
onCancel,
|
|
755
|
-
onConfirm,
|
|
756
|
-
}: {
|
|
757
|
-
entry: ProjectEntry
|
|
758
|
-
initialParentPath: string
|
|
759
|
-
onCancel: () => void
|
|
760
|
-
onConfirm: (parentPath: string) => void
|
|
761
|
-
}) {
|
|
762
|
-
const [directories, setDirectories] = useState<ProjectEntry[]>([])
|
|
763
|
-
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
|
764
|
-
const [query, setQuery] = useState('')
|
|
765
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
766
|
-
const [isLoading, setIsLoading] = useState(true)
|
|
767
|
-
const [loadError, setLoadError] = useState<string | null>(null)
|
|
768
|
-
|
|
769
|
-
useEffect(() => {
|
|
770
|
-
let active = true
|
|
771
|
-
|
|
772
|
-
void listProjectDirectories()
|
|
773
|
-
.then((projectDirectories) => {
|
|
774
|
-
if (!active) return
|
|
775
|
-
|
|
776
|
-
setDirectories(
|
|
777
|
-
projectDirectories.filter((directory) => {
|
|
778
|
-
if (directory.path === initialParentPath) return false
|
|
779
|
-
if (entry.kind !== 'directory') return true
|
|
780
|
-
|
|
781
|
-
return (
|
|
782
|
-
directory.path !== entry.path &&
|
|
783
|
-
!directory.path.startsWith(`${entry.path}/`)
|
|
784
|
-
)
|
|
785
|
-
})
|
|
786
|
-
)
|
|
787
|
-
setLoadError(null)
|
|
788
|
-
})
|
|
789
|
-
.catch((error) => {
|
|
790
|
-
if (!active) return
|
|
791
|
-
setLoadError(getErrorMessage(error))
|
|
792
|
-
})
|
|
793
|
-
.finally(() => {
|
|
794
|
-
if (active) setIsLoading(false)
|
|
795
|
-
})
|
|
796
|
-
|
|
797
|
-
return () => {
|
|
798
|
-
active = false
|
|
799
|
-
}
|
|
800
|
-
}, [entry.kind, entry.path, initialParentPath])
|
|
801
|
-
|
|
802
|
-
const selectedDirectory = directories.find(
|
|
803
|
-
(directory) => directory.path === selectedPath
|
|
804
|
-
)
|
|
805
|
-
const normalizedQuery = query.trim().toLowerCase()
|
|
806
|
-
const filteredDirectories = directories.filter(
|
|
807
|
-
(directory) =>
|
|
808
|
-
!normalizedQuery ||
|
|
809
|
-
directory.name.toLowerCase().includes(normalizedQuery) ||
|
|
810
|
-
directory.path.toLowerCase().includes(normalizedQuery)
|
|
811
|
-
)
|
|
812
|
-
|
|
813
|
-
return (
|
|
814
|
-
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[2px]">
|
|
815
|
-
<form
|
|
816
|
-
className="w-[400px] max-w-full rounded-md border border-white/10 bg-[#181b21] p-4 shadow-2xl"
|
|
817
|
-
onSubmit={(event) => {
|
|
818
|
-
event.preventDefault()
|
|
819
|
-
if (selectedPath !== null) onConfirm(selectedPath)
|
|
820
|
-
}}
|
|
821
|
-
onKeyDown={(event) => {
|
|
822
|
-
if (event.key !== 'Escape') return
|
|
823
|
-
|
|
824
|
-
if (isOpen) {
|
|
825
|
-
setIsOpen(false)
|
|
826
|
-
} else {
|
|
827
|
-
onCancel()
|
|
828
|
-
}
|
|
829
|
-
}}
|
|
830
|
-
>
|
|
831
|
-
<p className="text-sm font-medium text-white">Move {entry.name}</p>
|
|
832
|
-
<div className="relative mt-3">
|
|
833
|
-
<p className="text-[10px] uppercase tracking-wide text-gray-500">
|
|
834
|
-
Destination folder
|
|
835
|
-
</p>
|
|
836
|
-
<button
|
|
837
|
-
autoFocus
|
|
838
|
-
type="button"
|
|
839
|
-
onClick={() => {
|
|
840
|
-
if (!isLoading && !loadError && directories.length > 0) {
|
|
841
|
-
setIsOpen((current) => !current)
|
|
842
|
-
}
|
|
843
|
-
}}
|
|
844
|
-
className="mt-1 flex h-9 w-full cursor-pointer items-center gap-3 rounded border border-white/10 bg-[#11141a] px-2.5 text-left text-xs text-white outline-none transition-colors hover:border-white/20 focus:border-blue-400 disabled:cursor-not-allowed disabled:text-gray-500"
|
|
845
|
-
aria-label="Choose destination folder"
|
|
846
|
-
aria-expanded={isOpen}
|
|
847
|
-
aria-haspopup="listbox"
|
|
848
|
-
disabled={isLoading || Boolean(loadError) || directories.length === 0}
|
|
849
|
-
>
|
|
850
|
-
<span className="min-w-0 flex-1 truncate font-medium">
|
|
851
|
-
{isLoading
|
|
852
|
-
? 'Loading folders...'
|
|
853
|
-
: loadError
|
|
854
|
-
? 'Could not load folders'
|
|
855
|
-
: selectedDirectory?.name || 'Choose a folder'}
|
|
856
|
-
</span>
|
|
857
|
-
{selectedDirectory && (
|
|
858
|
-
<span className="max-w-[58%] truncate text-[10px] text-gray-500">
|
|
859
|
-
{selectedDirectory.path}
|
|
860
|
-
</span>
|
|
861
|
-
)}
|
|
862
|
-
<span className="shrink-0 text-[10px] text-gray-500">
|
|
863
|
-
{isOpen ? '^' : 'v'}
|
|
864
|
-
</span>
|
|
865
|
-
</button>
|
|
866
|
-
|
|
867
|
-
{isOpen && (
|
|
868
|
-
<div className="absolute left-0 right-0 top-full z-10 mt-1 overflow-hidden rounded-md border border-white/10 bg-[#11141a] shadow-2xl">
|
|
869
|
-
<div className="border-b border-white/[0.07] p-2">
|
|
870
|
-
<input
|
|
871
|
-
value={query}
|
|
872
|
-
onChange={(event) => setQuery(event.target.value)}
|
|
873
|
-
placeholder="Search folders"
|
|
874
|
-
className="h-8 w-full rounded border border-white/10 bg-[#181b21] px-2 text-xs text-white outline-none placeholder:text-gray-600 focus:border-blue-400"
|
|
875
|
-
aria-label="Search destination folders"
|
|
876
|
-
/>
|
|
877
|
-
</div>
|
|
878
|
-
<div
|
|
879
|
-
className="max-h-52 overflow-y-auto p-1"
|
|
880
|
-
role="listbox"
|
|
881
|
-
aria-label="Destination folders"
|
|
882
|
-
>
|
|
883
|
-
{filteredDirectories.length > 0 ? (
|
|
884
|
-
filteredDirectories.map((directory) => (
|
|
885
|
-
<button
|
|
886
|
-
key={directory.path}
|
|
887
|
-
type="button"
|
|
888
|
-
role="option"
|
|
889
|
-
aria-selected={selectedPath === directory.path}
|
|
890
|
-
onClick={() => {
|
|
891
|
-
setSelectedPath(directory.path)
|
|
892
|
-
setIsOpen(false)
|
|
893
|
-
setQuery('')
|
|
894
|
-
}}
|
|
895
|
-
className={`flex w-full cursor-pointer items-center gap-3 rounded px-2.5 py-2 text-left transition-colors ${
|
|
896
|
-
selectedPath === directory.path
|
|
897
|
-
? 'bg-blue-500/20 text-white'
|
|
898
|
-
: 'text-gray-300 hover:bg-white/[0.07] hover:text-white'
|
|
899
|
-
}`}
|
|
900
|
-
>
|
|
901
|
-
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
|
902
|
-
{directory.name}
|
|
903
|
-
</span>
|
|
904
|
-
<span className="max-w-[62%] truncate text-[10px] text-gray-500">
|
|
905
|
-
{directory.path}
|
|
906
|
-
</span>
|
|
907
|
-
</button>
|
|
908
|
-
))
|
|
909
|
-
) : (
|
|
910
|
-
<p className="px-2.5 py-3 text-center text-[10px] text-gray-500">
|
|
911
|
-
No matching folders
|
|
912
|
-
</p>
|
|
913
|
-
)}
|
|
914
|
-
</div>
|
|
915
|
-
</div>
|
|
916
|
-
)}
|
|
917
|
-
</div>
|
|
918
|
-
{loadError && (
|
|
919
|
-
<p className="mt-2 text-[10px] leading-4 text-red-300">
|
|
920
|
-
{loadError}
|
|
921
|
-
</p>
|
|
922
|
-
)}
|
|
923
|
-
{!isLoading && !loadError && directories.length === 0 && (
|
|
924
|
-
<p className="mt-2 text-[10px] leading-4 text-gray-500">
|
|
925
|
-
No other valid folders are available in this project.
|
|
926
|
-
</p>
|
|
927
|
-
)}
|
|
928
|
-
<div className="mt-4 flex justify-end gap-2">
|
|
929
|
-
<button
|
|
930
|
-
type="button"
|
|
931
|
-
onClick={onCancel}
|
|
932
|
-
className="cursor-pointer rounded bg-white/[0.07] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.12]"
|
|
933
|
-
>
|
|
934
|
-
Cancel
|
|
935
|
-
</button>
|
|
936
|
-
<button
|
|
937
|
-
type="submit"
|
|
938
|
-
disabled={selectedPath === null}
|
|
939
|
-
className="cursor-pointer rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-400 disabled:cursor-not-allowed disabled:bg-blue-500/30 disabled:text-white/40"
|
|
940
|
-
>
|
|
941
|
-
Move
|
|
942
|
-
</button>
|
|
943
|
-
</div>
|
|
944
|
-
</form>
|
|
945
|
-
</div>
|
|
946
|
-
)
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
function DeleteDialog({
|
|
950
|
-
entry,
|
|
951
|
-
onCancel,
|
|
952
|
-
onConfirm,
|
|
953
|
-
}: {
|
|
954
|
-
entry: ProjectEntry
|
|
955
|
-
onCancel: () => void
|
|
956
|
-
onConfirm: () => void
|
|
957
|
-
}) {
|
|
958
|
-
return (
|
|
959
|
-
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[2px]">
|
|
960
|
-
<div className="w-[360px] max-w-full rounded-md border border-white/10 bg-[#181b21] p-4 shadow-2xl">
|
|
961
|
-
<p className="text-sm font-medium text-white">Delete {entry.name}?</p>
|
|
962
|
-
<p className="mt-1 text-xs leading-5 text-gray-400">
|
|
963
|
-
{entry.kind === 'directory'
|
|
964
|
-
? 'This folder and everything inside it will be removed.'
|
|
965
|
-
: 'This file will be removed from the user app.'}
|
|
966
|
-
</p>
|
|
967
|
-
<div className="mt-4 flex justify-end gap-2">
|
|
968
|
-
<button
|
|
969
|
-
type="button"
|
|
970
|
-
onClick={onCancel}
|
|
971
|
-
className="cursor-pointer rounded bg-white/[0.07] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.12]"
|
|
972
|
-
>
|
|
973
|
-
Cancel
|
|
974
|
-
</button>
|
|
975
|
-
<button
|
|
976
|
-
type="button"
|
|
977
|
-
onClick={onConfirm}
|
|
978
|
-
className="cursor-pointer rounded bg-red-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-400"
|
|
979
|
-
>
|
|
980
|
-
Delete
|
|
981
|
-
</button>
|
|
982
|
-
</div>
|
|
983
|
-
</div>
|
|
984
|
-
</div>
|
|
985
|
-
)
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function findEntry(
|
|
989
|
-
entriesByDirectory: Record<string, ProjectEntry[]>,
|
|
990
|
-
path: string
|
|
991
|
-
) {
|
|
992
|
-
return Object.values(entriesByDirectory)
|
|
993
|
-
.flat()
|
|
994
|
-
.find((entry) => entry.path === path)
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function getParentPath(path: string) {
|
|
998
|
-
const separatorIndex = path.lastIndexOf('/')
|
|
999
|
-
return separatorIndex === -1 ? '' : path.slice(0, separatorIndex)
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function isPathWithin(path: string, parentPath: string) {
|
|
1003
|
-
return path === parentPath || path.startsWith(`${parentPath}/`)
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string) {
|
|
1007
|
-
if (!isPathWithin(path, oldPrefix)) return path
|
|
1008
|
-
return `${newPrefix}${path.slice(oldPrefix.length)}`
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
function renameDirectoryCache(
|
|
1012
|
-
cache: Record<string, ProjectEntry[]>,
|
|
1013
|
-
oldPath: string,
|
|
1014
|
-
newPath: string
|
|
1015
|
-
) {
|
|
1016
|
-
return Object.fromEntries(
|
|
1017
|
-
Object.entries(cache).map(([directoryPath, entries]) => [
|
|
1018
|
-
replacePathPrefix(directoryPath, oldPath, newPath),
|
|
1019
|
-
entries.map((entry) => ({
|
|
1020
|
-
...entry,
|
|
1021
|
-
path: replacePathPrefix(entry.path, oldPath, newPath),
|
|
1022
|
-
})),
|
|
1023
|
-
])
|
|
1024
|
-
)
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
function renameOpenDirectories(
|
|
1028
|
-
directories: Set<string>,
|
|
1029
|
-
oldPath: string,
|
|
1030
|
-
newPath: string
|
|
1031
|
-
) {
|
|
1032
|
-
return new Set(
|
|
1033
|
-
Array.from(directories, (directoryPath) =>
|
|
1034
|
-
replacePathPrefix(directoryPath, oldPath, newPath)
|
|
1035
|
-
)
|
|
1036
|
-
)
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function selectFileName(input: HTMLInputElement, kind: ProjectEntry['kind']) {
|
|
1040
|
-
const dotIndex = kind === 'file' ? input.value.lastIndexOf('.') : -1
|
|
1041
|
-
input.setSelectionRange(0, dotIndex > 0 ? dotIndex : input.value.length)
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
function getErrorMessage(error: unknown) {
|
|
1045
|
-
return error instanceof Error ? error.message : 'Project file operation failed'
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
function isMissingProjectPathError(message: string) {
|
|
1049
|
-
return (
|
|
1050
|
-
/\bENOENT\b/i.test(message) ||
|
|
1051
|
-
/no such file or directory/i.test(message) ||
|
|
1052
|
-
/request failed with 404/i.test(message)
|
|
1053
|
-
)
|
|
1054
|
-
}
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
createProjectEntry,
|
|
4
|
+
deleteProjectEntry,
|
|
5
|
+
listProjectDirectory,
|
|
6
|
+
listProjectDirectories,
|
|
7
|
+
moveProjectEntry,
|
|
8
|
+
renameProjectEntry,
|
|
9
|
+
type ProjectEntry,
|
|
10
|
+
} from '../utils/projectFiles'
|
|
11
|
+
|
|
12
|
+
interface ProjectExplorerProps {
|
|
13
|
+
selectedPath: string | null
|
|
14
|
+
onSelectFile: (path: string | null) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FileSystemChangeDetail {
|
|
18
|
+
type: 'filesystem-change'
|
|
19
|
+
path: string
|
|
20
|
+
action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CreateState = {
|
|
24
|
+
parentPath: string
|
|
25
|
+
kind: 'file' | 'directory'
|
|
26
|
+
} | null
|
|
27
|
+
|
|
28
|
+
const initiallyOpenDirectories = new Set(['', 'src', 'src/pages'])
|
|
29
|
+
|
|
30
|
+
export default function ProjectExplorer({
|
|
31
|
+
selectedPath,
|
|
32
|
+
onSelectFile,
|
|
33
|
+
}: ProjectExplorerProps) {
|
|
34
|
+
const [rootName, setRootName] = useState('Project')
|
|
35
|
+
const [entriesByDirectory, setEntriesByDirectory] = useState<
|
|
36
|
+
Record<string, ProjectEntry[]>
|
|
37
|
+
>({})
|
|
38
|
+
const [openDirectories, setOpenDirectories] = useState(
|
|
39
|
+
() => new Set(initiallyOpenDirectories)
|
|
40
|
+
)
|
|
41
|
+
const [selectedDirectory, setSelectedDirectory] = useState('')
|
|
42
|
+
const [createState, setCreateState] = useState<CreateState>(null)
|
|
43
|
+
const [renamePath, setRenamePath] = useState<string | null>(null)
|
|
44
|
+
const [moveTarget, setMoveTarget] = useState<ProjectEntry | null>(null)
|
|
45
|
+
const [deleteTarget, setDeleteTarget] = useState<ProjectEntry | null>(null)
|
|
46
|
+
const [busyPath, setBusyPath] = useState<string | null>(null)
|
|
47
|
+
const [error, setError] = useState<string | null>(null)
|
|
48
|
+
const mounted = useRef(true)
|
|
49
|
+
|
|
50
|
+
const pruneMissingDirectory = useCallback((directoryPath: string) => {
|
|
51
|
+
if (!directoryPath) return
|
|
52
|
+
|
|
53
|
+
setOpenDirectories(
|
|
54
|
+
(current) =>
|
|
55
|
+
new Set(
|
|
56
|
+
Array.from(current).filter(
|
|
57
|
+
(path) => !isPathWithin(path, directoryPath)
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
setEntriesByDirectory((current) =>
|
|
62
|
+
Object.fromEntries(
|
|
63
|
+
Object.entries(current)
|
|
64
|
+
.filter(([path]) => !isPathWithin(path, directoryPath))
|
|
65
|
+
.map(([path, entries]) => [
|
|
66
|
+
path,
|
|
67
|
+
entries.filter(
|
|
68
|
+
(entry) => !isPathWithin(entry.path, directoryPath)
|
|
69
|
+
),
|
|
70
|
+
])
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
setSelectedDirectory((current) =>
|
|
74
|
+
isPathWithin(current, directoryPath)
|
|
75
|
+
? getParentPath(directoryPath)
|
|
76
|
+
: current
|
|
77
|
+
)
|
|
78
|
+
setError(null)
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
const loadDirectory = useCallback(
|
|
82
|
+
async (directoryPath: string) => {
|
|
83
|
+
try {
|
|
84
|
+
const result = await listProjectDirectory(directoryPath)
|
|
85
|
+
|
|
86
|
+
if (!mounted.current) return
|
|
87
|
+
setRootName(result.rootName)
|
|
88
|
+
setEntriesByDirectory((current) => ({
|
|
89
|
+
...current,
|
|
90
|
+
[directoryPath]: result.entries,
|
|
91
|
+
}))
|
|
92
|
+
setError(null)
|
|
93
|
+
} catch (loadError) {
|
|
94
|
+
if (!mounted.current) return
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
directoryPath &&
|
|
98
|
+
isMissingProjectPathError(getErrorMessage(loadError))
|
|
99
|
+
) {
|
|
100
|
+
pruneMissingDirectory(directoryPath)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setError(getErrorMessage(loadError))
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
[pruneMissingDirectory]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const refreshOpenDirectories = useCallback(async () => {
|
|
111
|
+
await Promise.all(
|
|
112
|
+
Array.from(openDirectories, (directoryPath) =>
|
|
113
|
+
loadDirectory(directoryPath)
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
}, [loadDirectory, openDirectories])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
mounted.current = true
|
|
120
|
+
void refreshOpenDirectories()
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
mounted.current = false
|
|
124
|
+
}
|
|
125
|
+
}, [refreshOpenDirectories])
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const interval = window.setInterval(() => {
|
|
129
|
+
void refreshOpenDirectories()
|
|
130
|
+
}, 3000)
|
|
131
|
+
|
|
132
|
+
return () => window.clearInterval(interval)
|
|
133
|
+
}, [refreshOpenDirectories])
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const refresh = (event: Event) => {
|
|
137
|
+
const detail = (event as CustomEvent<FileSystemChangeDetail>).detail
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
detail?.type === 'filesystem-change' &&
|
|
141
|
+
detail.action === 'unlinkDir'
|
|
142
|
+
) {
|
|
143
|
+
const removedPath = detail.path.replace(/\\/g, '/')
|
|
144
|
+
pruneMissingDirectory(removedPath)
|
|
145
|
+
void loadDirectory(getParentPath(removedPath))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
void refreshOpenDirectories()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
window.addEventListener('visualbuild:filesystem-changed', refresh)
|
|
153
|
+
return () =>
|
|
154
|
+
window.removeEventListener('visualbuild:filesystem-changed', refresh)
|
|
155
|
+
}, [loadDirectory, pruneMissingDirectory, refreshOpenDirectories])
|
|
156
|
+
|
|
157
|
+
const selectedParentDirectory = useMemo(() => {
|
|
158
|
+
if (!selectedPath) return selectedDirectory
|
|
159
|
+
const entry = findEntry(entriesByDirectory, selectedPath)
|
|
160
|
+
|
|
161
|
+
if (entry?.kind === 'directory') return entry.path
|
|
162
|
+
return getParentPath(selectedPath)
|
|
163
|
+
}, [entriesByDirectory, selectedDirectory, selectedPath])
|
|
164
|
+
|
|
165
|
+
const toggleDirectory = (directoryPath: string) => {
|
|
166
|
+
setSelectedDirectory(directoryPath)
|
|
167
|
+
onSelectFile(null)
|
|
168
|
+
setOpenDirectories((current) => {
|
|
169
|
+
const next = new Set(current)
|
|
170
|
+
|
|
171
|
+
if (next.has(directoryPath)) {
|
|
172
|
+
next.delete(directoryPath)
|
|
173
|
+
} else {
|
|
174
|
+
next.add(directoryPath)
|
|
175
|
+
void loadDirectory(directoryPath)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return next
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const startCreate = (kind: 'file' | 'directory') => {
|
|
183
|
+
setCreateState({ parentPath: selectedParentDirectory, kind })
|
|
184
|
+
setRenamePath(null)
|
|
185
|
+
setError(null)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const handleCreate = async (name: string) => {
|
|
189
|
+
if (!createState) return
|
|
190
|
+
setBusyPath(createState.parentPath || '/')
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const entry = await createProjectEntry(
|
|
194
|
+
createState.parentPath,
|
|
195
|
+
name,
|
|
196
|
+
createState.kind
|
|
197
|
+
)
|
|
198
|
+
setCreateState(null)
|
|
199
|
+
setOpenDirectories((current) =>
|
|
200
|
+
new Set(current).add(createState.parentPath)
|
|
201
|
+
)
|
|
202
|
+
await loadDirectory(createState.parentPath)
|
|
203
|
+
|
|
204
|
+
if (entry.kind === 'directory') {
|
|
205
|
+
onSelectFile(null)
|
|
206
|
+
setSelectedDirectory(entry.path)
|
|
207
|
+
setOpenDirectories((current) => new Set(current).add(entry.path))
|
|
208
|
+
await loadDirectory(entry.path)
|
|
209
|
+
} else {
|
|
210
|
+
onSelectFile(entry.path)
|
|
211
|
+
}
|
|
212
|
+
} catch (createError) {
|
|
213
|
+
setError(getErrorMessage(createError))
|
|
214
|
+
} finally {
|
|
215
|
+
setBusyPath(null)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const handleRename = async (entry: ProjectEntry, name: string) => {
|
|
220
|
+
setBusyPath(entry.path)
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const renamed = await renameProjectEntry(entry.path, name)
|
|
224
|
+
const parentPath = getParentPath(entry.path)
|
|
225
|
+
setRenamePath(null)
|
|
226
|
+
await loadDirectory(parentPath)
|
|
227
|
+
|
|
228
|
+
if (entry.kind === 'directory') {
|
|
229
|
+
setEntriesByDirectory((current) =>
|
|
230
|
+
renameDirectoryCache(current, entry.path, renamed.path)
|
|
231
|
+
)
|
|
232
|
+
setOpenDirectories((current) =>
|
|
233
|
+
renameOpenDirectories(current, entry.path, renamed.path)
|
|
234
|
+
)
|
|
235
|
+
setSelectedDirectory((current) =>
|
|
236
|
+
replacePathPrefix(current, entry.path, renamed.path)
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (selectedPath) {
|
|
241
|
+
const nextSelectedPath = replacePathPrefix(
|
|
242
|
+
selectedPath,
|
|
243
|
+
entry.path,
|
|
244
|
+
renamed.path
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if (nextSelectedPath !== selectedPath) {
|
|
248
|
+
onSelectFile(nextSelectedPath)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (renameError) {
|
|
252
|
+
setError(getErrorMessage(renameError))
|
|
253
|
+
} finally {
|
|
254
|
+
setBusyPath(null)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const handleDelete = async () => {
|
|
259
|
+
if (!deleteTarget) return
|
|
260
|
+
const target = deleteTarget
|
|
261
|
+
setDeleteTarget(null)
|
|
262
|
+
setBusyPath(target.path)
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await deleteProjectEntry(target.path)
|
|
266
|
+
const parentPath = getParentPath(target.path)
|
|
267
|
+
await loadDirectory(parentPath)
|
|
268
|
+
|
|
269
|
+
if (selectedPath && isPathWithin(selectedPath, target.path)) {
|
|
270
|
+
onSelectFile(null)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (isPathWithin(selectedDirectory, target.path)) {
|
|
274
|
+
setSelectedDirectory(parentPath)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
setOpenDirectories((current) =>
|
|
278
|
+
new Set(
|
|
279
|
+
Array.from(current).filter(
|
|
280
|
+
(directoryPath) => !isPathWithin(directoryPath, target.path)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
setEntriesByDirectory((current) =>
|
|
285
|
+
Object.fromEntries(
|
|
286
|
+
Object.entries(current).filter(
|
|
287
|
+
([directoryPath]) => !isPathWithin(directoryPath, target.path)
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
} catch (deleteError) {
|
|
292
|
+
setError(getErrorMessage(deleteError))
|
|
293
|
+
} finally {
|
|
294
|
+
setBusyPath(null)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const handleMove = async (parentPath: string) => {
|
|
299
|
+
if (!moveTarget) return
|
|
300
|
+
const entry = moveTarget
|
|
301
|
+
setMoveTarget(null)
|
|
302
|
+
setBusyPath(entry.path)
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const moved = await moveProjectEntry(entry.path, parentPath)
|
|
306
|
+
const previousParentPath = getParentPath(entry.path)
|
|
307
|
+
setEntriesByDirectory((current) =>
|
|
308
|
+
renameDirectoryCache(current, entry.path, moved.path)
|
|
309
|
+
)
|
|
310
|
+
setOpenDirectories((current) =>
|
|
311
|
+
renameOpenDirectories(current, entry.path, moved.path)
|
|
312
|
+
)
|
|
313
|
+
setSelectedDirectory((current) =>
|
|
314
|
+
replacePathPrefix(current, entry.path, moved.path)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if (selectedPath) {
|
|
318
|
+
const nextSelectedPath = replacePathPrefix(
|
|
319
|
+
selectedPath,
|
|
320
|
+
entry.path,
|
|
321
|
+
moved.path
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if (nextSelectedPath !== selectedPath) {
|
|
325
|
+
onSelectFile(nextSelectedPath)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await Promise.all([
|
|
330
|
+
loadDirectory(previousParentPath),
|
|
331
|
+
loadDirectory(parentPath),
|
|
332
|
+
])
|
|
333
|
+
} catch (moveError) {
|
|
334
|
+
setError(getErrorMessage(moveError))
|
|
335
|
+
} finally {
|
|
336
|
+
setBusyPath(null)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className="flex h-full min-h-0 flex-col text-[11px]">
|
|
342
|
+
<div className="flex shrink-0 items-center gap-1 border-b border-white/[0.05] px-2 py-1.5">
|
|
343
|
+
<ExplorerAction label="New file" onClick={() => startCreate('file')} />
|
|
344
|
+
<ExplorerAction
|
|
345
|
+
label="New folder"
|
|
346
|
+
onClick={() => startCreate('directory')}
|
|
347
|
+
/>
|
|
348
|
+
<ExplorerAction
|
|
349
|
+
label="Refresh"
|
|
350
|
+
onClick={() => void refreshOpenDirectories()}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{createState && (
|
|
355
|
+
<NameEditor
|
|
356
|
+
label={createState.kind === 'file' ? 'New file' : 'New folder'}
|
|
357
|
+
parentPath={createState.parentPath}
|
|
358
|
+
onCancel={() => setCreateState(null)}
|
|
359
|
+
onSubmit={(name) => void handleCreate(name)}
|
|
360
|
+
/>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{error && (
|
|
364
|
+
<div className="mx-2 mt-2 rounded border border-red-400/20 bg-red-500/10 px-2 py-1.5 text-[10px] leading-4 text-red-300">
|
|
365
|
+
{error}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
<div className="min-h-0 flex-1 overflow-auto px-1.5 py-1.5">
|
|
370
|
+
<ProjectTreeRow
|
|
371
|
+
name={rootName}
|
|
372
|
+
path=""
|
|
373
|
+
kind="directory"
|
|
374
|
+
depth={0}
|
|
375
|
+
isOpen={openDirectories.has('')}
|
|
376
|
+
isSelected={!selectedPath && selectedDirectory === ''}
|
|
377
|
+
isBusy={busyPath === '/'}
|
|
378
|
+
onSelect={() => toggleDirectory('')}
|
|
379
|
+
/>
|
|
380
|
+
{openDirectories.has('') &&
|
|
381
|
+
<ProjectEntries
|
|
382
|
+
directoryPath=""
|
|
383
|
+
depth={1}
|
|
384
|
+
entriesByDirectory={entriesByDirectory}
|
|
385
|
+
openDirectories={openDirectories}
|
|
386
|
+
selectedPath={selectedPath}
|
|
387
|
+
selectedDirectory={selectedDirectory}
|
|
388
|
+
renamePath={renamePath}
|
|
389
|
+
busyPath={busyPath}
|
|
390
|
+
onToggleDirectory={toggleDirectory}
|
|
391
|
+
onSelectFile={(path) => {
|
|
392
|
+
setSelectedDirectory(getParentPath(path))
|
|
393
|
+
onSelectFile(path)
|
|
394
|
+
}}
|
|
395
|
+
onStartRename={(path) => {
|
|
396
|
+
setCreateState(null)
|
|
397
|
+
setRenamePath(path)
|
|
398
|
+
}}
|
|
399
|
+
onRename={handleRename}
|
|
400
|
+
onCancelRename={() => setRenamePath(null)}
|
|
401
|
+
onMove={setMoveTarget}
|
|
402
|
+
onDelete={setDeleteTarget}
|
|
403
|
+
/>}
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{deleteTarget && (
|
|
407
|
+
<DeleteDialog
|
|
408
|
+
entry={deleteTarget}
|
|
409
|
+
onCancel={() => setDeleteTarget(null)}
|
|
410
|
+
onConfirm={() => void handleDelete()}
|
|
411
|
+
/>
|
|
412
|
+
)}
|
|
413
|
+
{moveTarget && (
|
|
414
|
+
<MoveDialog
|
|
415
|
+
entry={moveTarget}
|
|
416
|
+
initialParentPath={getParentPath(moveTarget.path)}
|
|
417
|
+
onCancel={() => setMoveTarget(null)}
|
|
418
|
+
onConfirm={(parentPath) => void handleMove(parentPath)}
|
|
419
|
+
/>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function ProjectEntries({
|
|
426
|
+
directoryPath,
|
|
427
|
+
depth,
|
|
428
|
+
entriesByDirectory,
|
|
429
|
+
openDirectories,
|
|
430
|
+
selectedPath,
|
|
431
|
+
selectedDirectory,
|
|
432
|
+
renamePath,
|
|
433
|
+
busyPath,
|
|
434
|
+
onToggleDirectory,
|
|
435
|
+
onSelectFile,
|
|
436
|
+
onStartRename,
|
|
437
|
+
onRename,
|
|
438
|
+
onCancelRename,
|
|
439
|
+
onMove,
|
|
440
|
+
onDelete,
|
|
441
|
+
}: {
|
|
442
|
+
directoryPath: string
|
|
443
|
+
depth: number
|
|
444
|
+
entriesByDirectory: Record<string, ProjectEntry[]>
|
|
445
|
+
openDirectories: Set<string>
|
|
446
|
+
selectedPath: string | null
|
|
447
|
+
selectedDirectory: string
|
|
448
|
+
renamePath: string | null
|
|
449
|
+
busyPath: string | null
|
|
450
|
+
onToggleDirectory: (path: string) => void
|
|
451
|
+
onSelectFile: (path: string) => void
|
|
452
|
+
onStartRename: (path: string) => void
|
|
453
|
+
onRename: (entry: ProjectEntry, name: string) => Promise<void>
|
|
454
|
+
onCancelRename: () => void
|
|
455
|
+
onMove: (entry: ProjectEntry) => void
|
|
456
|
+
onDelete: (entry: ProjectEntry) => void
|
|
457
|
+
}): React.ReactNode {
|
|
458
|
+
const entries = entriesByDirectory[directoryPath]
|
|
459
|
+
|
|
460
|
+
if (!entries) {
|
|
461
|
+
return (
|
|
462
|
+
<div
|
|
463
|
+
className="py-1 text-[10px] text-gray-600"
|
|
464
|
+
style={{ paddingLeft: `${depth * 0.8 + 1.25}rem` }}
|
|
465
|
+
>
|
|
466
|
+
Loading...
|
|
467
|
+
</div>
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (entries.length === 0) {
|
|
472
|
+
return (
|
|
473
|
+
<div
|
|
474
|
+
className="py-1 text-[10px] text-gray-600"
|
|
475
|
+
style={{ paddingLeft: `${depth * 0.8 + 1.25}rem` }}
|
|
476
|
+
>
|
|
477
|
+
Empty folder
|
|
478
|
+
</div>
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<>
|
|
484
|
+
{entries.map((entry) => {
|
|
485
|
+
const isDirectory = entry.kind === 'directory'
|
|
486
|
+
const isOpen = isDirectory && openDirectories.has(entry.path)
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div key={entry.path}>
|
|
490
|
+
{renamePath === entry.path ? (
|
|
491
|
+
<InlineRename
|
|
492
|
+
entry={entry}
|
|
493
|
+
depth={depth}
|
|
494
|
+
onCancel={onCancelRename}
|
|
495
|
+
onSubmit={(name) => void onRename(entry, name)}
|
|
496
|
+
/>
|
|
497
|
+
) : (
|
|
498
|
+
<ProjectTreeRow
|
|
499
|
+
name={entry.name}
|
|
500
|
+
path={entry.path}
|
|
501
|
+
kind={entry.kind}
|
|
502
|
+
depth={depth}
|
|
503
|
+
isOpen={isOpen}
|
|
504
|
+
isSelected={
|
|
505
|
+
isDirectory
|
|
506
|
+
? !selectedPath && selectedDirectory === entry.path
|
|
507
|
+
: selectedPath === entry.path
|
|
508
|
+
}
|
|
509
|
+
isBusy={busyPath === entry.path}
|
|
510
|
+
onSelect={() =>
|
|
511
|
+
isDirectory
|
|
512
|
+
? onToggleDirectory(entry.path)
|
|
513
|
+
: onSelectFile(entry.path)
|
|
514
|
+
}
|
|
515
|
+
onRename={() => onStartRename(entry.path)}
|
|
516
|
+
onMove={() => onMove(entry)}
|
|
517
|
+
onDelete={() => onDelete(entry)}
|
|
518
|
+
/>
|
|
519
|
+
)}
|
|
520
|
+
{isOpen && (
|
|
521
|
+
<ProjectEntries
|
|
522
|
+
directoryPath={entry.path}
|
|
523
|
+
depth={depth + 1}
|
|
524
|
+
entriesByDirectory={entriesByDirectory}
|
|
525
|
+
openDirectories={openDirectories}
|
|
526
|
+
selectedPath={selectedPath}
|
|
527
|
+
selectedDirectory={selectedDirectory}
|
|
528
|
+
renamePath={renamePath}
|
|
529
|
+
busyPath={busyPath}
|
|
530
|
+
onToggleDirectory={onToggleDirectory}
|
|
531
|
+
onSelectFile={onSelectFile}
|
|
532
|
+
onStartRename={onStartRename}
|
|
533
|
+
onRename={onRename}
|
|
534
|
+
onCancelRename={onCancelRename}
|
|
535
|
+
onMove={onMove}
|
|
536
|
+
onDelete={onDelete}
|
|
537
|
+
/>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
)
|
|
541
|
+
})}
|
|
542
|
+
</>
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function ProjectTreeRow({
|
|
547
|
+
name,
|
|
548
|
+
path,
|
|
549
|
+
kind,
|
|
550
|
+
depth,
|
|
551
|
+
isOpen,
|
|
552
|
+
isSelected,
|
|
553
|
+
isBusy,
|
|
554
|
+
onSelect,
|
|
555
|
+
onRename,
|
|
556
|
+
onMove,
|
|
557
|
+
onDelete,
|
|
558
|
+
}: {
|
|
559
|
+
name: string
|
|
560
|
+
path: string
|
|
561
|
+
kind: ProjectEntry['kind']
|
|
562
|
+
depth: number
|
|
563
|
+
isOpen: boolean
|
|
564
|
+
isSelected: boolean
|
|
565
|
+
isBusy: boolean
|
|
566
|
+
onSelect: () => void
|
|
567
|
+
onRename?: () => void
|
|
568
|
+
onMove?: () => void
|
|
569
|
+
onDelete?: () => void
|
|
570
|
+
}) {
|
|
571
|
+
const isDirectory = kind === 'directory'
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<div
|
|
575
|
+
className={`group flex min-h-7 items-center rounded transition-colors ${
|
|
576
|
+
isSelected
|
|
577
|
+
? 'bg-accent/30 text-white'
|
|
578
|
+
: 'text-gray-400 hover:bg-white/[0.05] hover:text-gray-100'
|
|
579
|
+
}`}
|
|
580
|
+
style={{ paddingLeft: `${depth * 0.8}rem` }}
|
|
581
|
+
title={path || name}
|
|
582
|
+
>
|
|
583
|
+
<button
|
|
584
|
+
type="button"
|
|
585
|
+
onClick={onSelect}
|
|
586
|
+
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 py-1 pr-1 text-left"
|
|
587
|
+
>
|
|
588
|
+
<span className="flex w-3 shrink-0 items-center justify-center text-[9px] text-gray-500">
|
|
589
|
+
{isDirectory ? (isOpen ? 'v' : '>') : kind === 'symlink' ? '@' : ''}
|
|
590
|
+
</span>
|
|
591
|
+
<span className="truncate">{name}</span>
|
|
592
|
+
{isBusy && <span className="text-[9px] text-blue-300">...</span>}
|
|
593
|
+
</button>
|
|
594
|
+
{(onRename || onMove || onDelete) && (
|
|
595
|
+
<div className="mr-1 hidden shrink-0 items-center gap-0.5 group-hover:flex group-focus-within:flex">
|
|
596
|
+
{onRename && (
|
|
597
|
+
<RowAction label="Rename" text="R" onClick={onRename} />
|
|
598
|
+
)}
|
|
599
|
+
{onMove && (
|
|
600
|
+
<RowAction label="Move" text="M" onClick={onMove} />
|
|
601
|
+
)}
|
|
602
|
+
{onDelete && (
|
|
603
|
+
<RowAction label="Delete" text="x" danger onClick={onDelete} />
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function ExplorerAction({ label, onClick }: { label: string; onClick: () => void }) {
|
|
612
|
+
return (
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
onClick={onClick}
|
|
616
|
+
className="min-w-0 flex-1 cursor-pointer rounded bg-white/[0.04] px-1.5 py-1 text-[9px] text-gray-400 transition-colors hover:bg-white/[0.09] hover:text-white"
|
|
617
|
+
title={label}
|
|
618
|
+
>
|
|
619
|
+
{label}
|
|
620
|
+
</button>
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function RowAction({
|
|
625
|
+
label,
|
|
626
|
+
text,
|
|
627
|
+
danger = false,
|
|
628
|
+
onClick,
|
|
629
|
+
}: {
|
|
630
|
+
label: string
|
|
631
|
+
text: string
|
|
632
|
+
danger?: boolean
|
|
633
|
+
onClick: () => void
|
|
634
|
+
}) {
|
|
635
|
+
return (
|
|
636
|
+
<button
|
|
637
|
+
type="button"
|
|
638
|
+
onClick={(event) => {
|
|
639
|
+
event.stopPropagation()
|
|
640
|
+
onClick()
|
|
641
|
+
}}
|
|
642
|
+
className={`flex h-5 w-5 cursor-pointer items-center justify-center rounded text-[9px] transition-colors ${
|
|
643
|
+
danger
|
|
644
|
+
? 'text-gray-500 hover:bg-red-500 hover:text-white'
|
|
645
|
+
: 'text-gray-500 hover:bg-white/[0.1] hover:text-white'
|
|
646
|
+
}`}
|
|
647
|
+
aria-label={`${label} ${text}`}
|
|
648
|
+
title={label}
|
|
649
|
+
>
|
|
650
|
+
{text}
|
|
651
|
+
</button>
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function NameEditor({
|
|
656
|
+
label,
|
|
657
|
+
parentPath,
|
|
658
|
+
onCancel,
|
|
659
|
+
onSubmit,
|
|
660
|
+
}: {
|
|
661
|
+
label: string
|
|
662
|
+
parentPath: string
|
|
663
|
+
onCancel: () => void
|
|
664
|
+
onSubmit: (name: string) => void
|
|
665
|
+
}) {
|
|
666
|
+
const [name, setName] = useState('')
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<form
|
|
670
|
+
className="shrink-0 border-b border-white/[0.05] bg-black/10 px-2 py-2"
|
|
671
|
+
onSubmit={(event) => {
|
|
672
|
+
event.preventDefault()
|
|
673
|
+
if (name.trim()) onSubmit(name.trim())
|
|
674
|
+
}}
|
|
675
|
+
>
|
|
676
|
+
<p className="mb-1 truncate text-[9px] uppercase tracking-wide text-gray-500">
|
|
677
|
+
{label} in {parentPath || '/'}
|
|
678
|
+
</p>
|
|
679
|
+
<div className="flex gap-1">
|
|
680
|
+
<input
|
|
681
|
+
autoFocus
|
|
682
|
+
value={name}
|
|
683
|
+
onChange={(event) => setName(event.target.value)}
|
|
684
|
+
onKeyDown={(event) => {
|
|
685
|
+
if (event.key === 'Escape') onCancel()
|
|
686
|
+
}}
|
|
687
|
+
className="h-7 min-w-0 flex-1 rounded border border-blue-400/70 bg-[#11141a] px-2 text-[11px] text-white outline-none"
|
|
688
|
+
aria-label={label}
|
|
689
|
+
/>
|
|
690
|
+
<button
|
|
691
|
+
type="submit"
|
|
692
|
+
className="cursor-pointer rounded bg-blue-500 px-2 text-[10px] font-medium text-white hover:bg-blue-400"
|
|
693
|
+
>
|
|
694
|
+
Add
|
|
695
|
+
</button>
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
onClick={onCancel}
|
|
699
|
+
className="cursor-pointer rounded bg-white/[0.06] px-2 text-[10px] text-gray-300 hover:bg-white/[0.1]"
|
|
700
|
+
>
|
|
701
|
+
Cancel
|
|
702
|
+
</button>
|
|
703
|
+
</div>
|
|
704
|
+
</form>
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function InlineRename({
|
|
709
|
+
entry,
|
|
710
|
+
depth,
|
|
711
|
+
onCancel,
|
|
712
|
+
onSubmit,
|
|
713
|
+
}: {
|
|
714
|
+
entry: ProjectEntry
|
|
715
|
+
depth: number
|
|
716
|
+
onCancel: () => void
|
|
717
|
+
onSubmit: (name: string) => void
|
|
718
|
+
}) {
|
|
719
|
+
const [name, setName] = useState(entry.name)
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<form
|
|
723
|
+
className="flex min-h-7 items-center gap-1 pr-1"
|
|
724
|
+
style={{ paddingLeft: `${depth * 0.8 + 0.75}rem` }}
|
|
725
|
+
onSubmit={(event) => {
|
|
726
|
+
event.preventDefault()
|
|
727
|
+
if (name.trim() && name.trim() !== entry.name) onSubmit(name.trim())
|
|
728
|
+
else onCancel()
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
<input
|
|
732
|
+
autoFocus
|
|
733
|
+
value={name}
|
|
734
|
+
onChange={(event) => setName(event.target.value)}
|
|
735
|
+
onBlur={() => {
|
|
736
|
+
const nextName = name.trim()
|
|
737
|
+
if (nextName && nextName !== entry.name) onSubmit(nextName)
|
|
738
|
+
else onCancel()
|
|
739
|
+
}}
|
|
740
|
+
onKeyDown={(event) => {
|
|
741
|
+
if (event.key === 'Escape') onCancel()
|
|
742
|
+
}}
|
|
743
|
+
onFocus={(event) => selectFileName(event.currentTarget, entry.kind)}
|
|
744
|
+
className="h-6 min-w-0 flex-1 rounded border border-blue-400 bg-[#11141a] px-1.5 text-[11px] text-white outline-none"
|
|
745
|
+
aria-label={`Rename ${entry.name}`}
|
|
746
|
+
/>
|
|
747
|
+
</form>
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function MoveDialog({
|
|
752
|
+
entry,
|
|
753
|
+
initialParentPath,
|
|
754
|
+
onCancel,
|
|
755
|
+
onConfirm,
|
|
756
|
+
}: {
|
|
757
|
+
entry: ProjectEntry
|
|
758
|
+
initialParentPath: string
|
|
759
|
+
onCancel: () => void
|
|
760
|
+
onConfirm: (parentPath: string) => void
|
|
761
|
+
}) {
|
|
762
|
+
const [directories, setDirectories] = useState<ProjectEntry[]>([])
|
|
763
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
|
764
|
+
const [query, setQuery] = useState('')
|
|
765
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
766
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
767
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
768
|
+
|
|
769
|
+
useEffect(() => {
|
|
770
|
+
let active = true
|
|
771
|
+
|
|
772
|
+
void listProjectDirectories()
|
|
773
|
+
.then((projectDirectories) => {
|
|
774
|
+
if (!active) return
|
|
775
|
+
|
|
776
|
+
setDirectories(
|
|
777
|
+
projectDirectories.filter((directory) => {
|
|
778
|
+
if (directory.path === initialParentPath) return false
|
|
779
|
+
if (entry.kind !== 'directory') return true
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
directory.path !== entry.path &&
|
|
783
|
+
!directory.path.startsWith(`${entry.path}/`)
|
|
784
|
+
)
|
|
785
|
+
})
|
|
786
|
+
)
|
|
787
|
+
setLoadError(null)
|
|
788
|
+
})
|
|
789
|
+
.catch((error) => {
|
|
790
|
+
if (!active) return
|
|
791
|
+
setLoadError(getErrorMessage(error))
|
|
792
|
+
})
|
|
793
|
+
.finally(() => {
|
|
794
|
+
if (active) setIsLoading(false)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
return () => {
|
|
798
|
+
active = false
|
|
799
|
+
}
|
|
800
|
+
}, [entry.kind, entry.path, initialParentPath])
|
|
801
|
+
|
|
802
|
+
const selectedDirectory = directories.find(
|
|
803
|
+
(directory) => directory.path === selectedPath
|
|
804
|
+
)
|
|
805
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
806
|
+
const filteredDirectories = directories.filter(
|
|
807
|
+
(directory) =>
|
|
808
|
+
!normalizedQuery ||
|
|
809
|
+
directory.name.toLowerCase().includes(normalizedQuery) ||
|
|
810
|
+
directory.path.toLowerCase().includes(normalizedQuery)
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
<div data-vb-editor-chrome className="fixed inset-0 z-[110] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[2px]">
|
|
815
|
+
<form
|
|
816
|
+
className="w-[400px] max-w-full rounded-md border border-white/10 bg-[#181b21] p-4 shadow-2xl"
|
|
817
|
+
onSubmit={(event) => {
|
|
818
|
+
event.preventDefault()
|
|
819
|
+
if (selectedPath !== null) onConfirm(selectedPath)
|
|
820
|
+
}}
|
|
821
|
+
onKeyDown={(event) => {
|
|
822
|
+
if (event.key !== 'Escape') return
|
|
823
|
+
|
|
824
|
+
if (isOpen) {
|
|
825
|
+
setIsOpen(false)
|
|
826
|
+
} else {
|
|
827
|
+
onCancel()
|
|
828
|
+
}
|
|
829
|
+
}}
|
|
830
|
+
>
|
|
831
|
+
<p className="text-sm font-medium text-white">Move {entry.name}</p>
|
|
832
|
+
<div className="relative mt-3">
|
|
833
|
+
<p className="text-[10px] uppercase tracking-wide text-gray-500">
|
|
834
|
+
Destination folder
|
|
835
|
+
</p>
|
|
836
|
+
<button
|
|
837
|
+
autoFocus
|
|
838
|
+
type="button"
|
|
839
|
+
onClick={() => {
|
|
840
|
+
if (!isLoading && !loadError && directories.length > 0) {
|
|
841
|
+
setIsOpen((current) => !current)
|
|
842
|
+
}
|
|
843
|
+
}}
|
|
844
|
+
className="mt-1 flex h-9 w-full cursor-pointer items-center gap-3 rounded border border-white/10 bg-[#11141a] px-2.5 text-left text-xs text-white outline-none transition-colors hover:border-white/20 focus:border-blue-400 disabled:cursor-not-allowed disabled:text-gray-500"
|
|
845
|
+
aria-label="Choose destination folder"
|
|
846
|
+
aria-expanded={isOpen}
|
|
847
|
+
aria-haspopup="listbox"
|
|
848
|
+
disabled={isLoading || Boolean(loadError) || directories.length === 0}
|
|
849
|
+
>
|
|
850
|
+
<span className="min-w-0 flex-1 truncate font-medium">
|
|
851
|
+
{isLoading
|
|
852
|
+
? 'Loading folders...'
|
|
853
|
+
: loadError
|
|
854
|
+
? 'Could not load folders'
|
|
855
|
+
: selectedDirectory?.name || 'Choose a folder'}
|
|
856
|
+
</span>
|
|
857
|
+
{selectedDirectory && (
|
|
858
|
+
<span className="max-w-[58%] truncate text-[10px] text-gray-500">
|
|
859
|
+
{selectedDirectory.path}
|
|
860
|
+
</span>
|
|
861
|
+
)}
|
|
862
|
+
<span className="shrink-0 text-[10px] text-gray-500">
|
|
863
|
+
{isOpen ? '^' : 'v'}
|
|
864
|
+
</span>
|
|
865
|
+
</button>
|
|
866
|
+
|
|
867
|
+
{isOpen && (
|
|
868
|
+
<div className="absolute left-0 right-0 top-full z-10 mt-1 overflow-hidden rounded-md border border-white/10 bg-[#11141a] shadow-2xl">
|
|
869
|
+
<div className="border-b border-white/[0.07] p-2">
|
|
870
|
+
<input
|
|
871
|
+
value={query}
|
|
872
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
873
|
+
placeholder="Search folders"
|
|
874
|
+
className="h-8 w-full rounded border border-white/10 bg-[#181b21] px-2 text-xs text-white outline-none placeholder:text-gray-600 focus:border-blue-400"
|
|
875
|
+
aria-label="Search destination folders"
|
|
876
|
+
/>
|
|
877
|
+
</div>
|
|
878
|
+
<div
|
|
879
|
+
className="max-h-52 overflow-y-auto p-1"
|
|
880
|
+
role="listbox"
|
|
881
|
+
aria-label="Destination folders"
|
|
882
|
+
>
|
|
883
|
+
{filteredDirectories.length > 0 ? (
|
|
884
|
+
filteredDirectories.map((directory) => (
|
|
885
|
+
<button
|
|
886
|
+
key={directory.path}
|
|
887
|
+
type="button"
|
|
888
|
+
role="option"
|
|
889
|
+
aria-selected={selectedPath === directory.path}
|
|
890
|
+
onClick={() => {
|
|
891
|
+
setSelectedPath(directory.path)
|
|
892
|
+
setIsOpen(false)
|
|
893
|
+
setQuery('')
|
|
894
|
+
}}
|
|
895
|
+
className={`flex w-full cursor-pointer items-center gap-3 rounded px-2.5 py-2 text-left transition-colors ${
|
|
896
|
+
selectedPath === directory.path
|
|
897
|
+
? 'bg-blue-500/20 text-white'
|
|
898
|
+
: 'text-gray-300 hover:bg-white/[0.07] hover:text-white'
|
|
899
|
+
}`}
|
|
900
|
+
>
|
|
901
|
+
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
|
902
|
+
{directory.name}
|
|
903
|
+
</span>
|
|
904
|
+
<span className="max-w-[62%] truncate text-[10px] text-gray-500">
|
|
905
|
+
{directory.path}
|
|
906
|
+
</span>
|
|
907
|
+
</button>
|
|
908
|
+
))
|
|
909
|
+
) : (
|
|
910
|
+
<p className="px-2.5 py-3 text-center text-[10px] text-gray-500">
|
|
911
|
+
No matching folders
|
|
912
|
+
</p>
|
|
913
|
+
)}
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
)}
|
|
917
|
+
</div>
|
|
918
|
+
{loadError && (
|
|
919
|
+
<p className="mt-2 text-[10px] leading-4 text-red-300">
|
|
920
|
+
{loadError}
|
|
921
|
+
</p>
|
|
922
|
+
)}
|
|
923
|
+
{!isLoading && !loadError && directories.length === 0 && (
|
|
924
|
+
<p className="mt-2 text-[10px] leading-4 text-gray-500">
|
|
925
|
+
No other valid folders are available in this project.
|
|
926
|
+
</p>
|
|
927
|
+
)}
|
|
928
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
929
|
+
<button
|
|
930
|
+
type="button"
|
|
931
|
+
onClick={onCancel}
|
|
932
|
+
className="cursor-pointer rounded bg-white/[0.07] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.12]"
|
|
933
|
+
>
|
|
934
|
+
Cancel
|
|
935
|
+
</button>
|
|
936
|
+
<button
|
|
937
|
+
type="submit"
|
|
938
|
+
disabled={selectedPath === null}
|
|
939
|
+
className="cursor-pointer rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-400 disabled:cursor-not-allowed disabled:bg-blue-500/30 disabled:text-white/40"
|
|
940
|
+
>
|
|
941
|
+
Move
|
|
942
|
+
</button>
|
|
943
|
+
</div>
|
|
944
|
+
</form>
|
|
945
|
+
</div>
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function DeleteDialog({
|
|
950
|
+
entry,
|
|
951
|
+
onCancel,
|
|
952
|
+
onConfirm,
|
|
953
|
+
}: {
|
|
954
|
+
entry: ProjectEntry
|
|
955
|
+
onCancel: () => void
|
|
956
|
+
onConfirm: () => void
|
|
957
|
+
}) {
|
|
958
|
+
return (
|
|
959
|
+
<div data-vb-editor-chrome className="fixed inset-0 z-[110] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[2px]">
|
|
960
|
+
<div className="w-[360px] max-w-full rounded-md border border-white/10 bg-[#181b21] p-4 shadow-2xl">
|
|
961
|
+
<p className="text-sm font-medium text-white">Delete {entry.name}?</p>
|
|
962
|
+
<p className="mt-1 text-xs leading-5 text-gray-400">
|
|
963
|
+
{entry.kind === 'directory'
|
|
964
|
+
? 'This folder and everything inside it will be removed.'
|
|
965
|
+
: 'This file will be removed from the user app.'}
|
|
966
|
+
</p>
|
|
967
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
968
|
+
<button
|
|
969
|
+
type="button"
|
|
970
|
+
onClick={onCancel}
|
|
971
|
+
className="cursor-pointer rounded bg-white/[0.07] px-3 py-1.5 text-xs text-gray-200 hover:bg-white/[0.12]"
|
|
972
|
+
>
|
|
973
|
+
Cancel
|
|
974
|
+
</button>
|
|
975
|
+
<button
|
|
976
|
+
type="button"
|
|
977
|
+
onClick={onConfirm}
|
|
978
|
+
className="cursor-pointer rounded bg-red-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-400"
|
|
979
|
+
>
|
|
980
|
+
Delete
|
|
981
|
+
</button>
|
|
982
|
+
</div>
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function findEntry(
|
|
989
|
+
entriesByDirectory: Record<string, ProjectEntry[]>,
|
|
990
|
+
path: string
|
|
991
|
+
) {
|
|
992
|
+
return Object.values(entriesByDirectory)
|
|
993
|
+
.flat()
|
|
994
|
+
.find((entry) => entry.path === path)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function getParentPath(path: string) {
|
|
998
|
+
const separatorIndex = path.lastIndexOf('/')
|
|
999
|
+
return separatorIndex === -1 ? '' : path.slice(0, separatorIndex)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function isPathWithin(path: string, parentPath: string) {
|
|
1003
|
+
return path === parentPath || path.startsWith(`${parentPath}/`)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string) {
|
|
1007
|
+
if (!isPathWithin(path, oldPrefix)) return path
|
|
1008
|
+
return `${newPrefix}${path.slice(oldPrefix.length)}`
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function renameDirectoryCache(
|
|
1012
|
+
cache: Record<string, ProjectEntry[]>,
|
|
1013
|
+
oldPath: string,
|
|
1014
|
+
newPath: string
|
|
1015
|
+
) {
|
|
1016
|
+
return Object.fromEntries(
|
|
1017
|
+
Object.entries(cache).map(([directoryPath, entries]) => [
|
|
1018
|
+
replacePathPrefix(directoryPath, oldPath, newPath),
|
|
1019
|
+
entries.map((entry) => ({
|
|
1020
|
+
...entry,
|
|
1021
|
+
path: replacePathPrefix(entry.path, oldPath, newPath),
|
|
1022
|
+
})),
|
|
1023
|
+
])
|
|
1024
|
+
)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function renameOpenDirectories(
|
|
1028
|
+
directories: Set<string>,
|
|
1029
|
+
oldPath: string,
|
|
1030
|
+
newPath: string
|
|
1031
|
+
) {
|
|
1032
|
+
return new Set(
|
|
1033
|
+
Array.from(directories, (directoryPath) =>
|
|
1034
|
+
replacePathPrefix(directoryPath, oldPath, newPath)
|
|
1035
|
+
)
|
|
1036
|
+
)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function selectFileName(input: HTMLInputElement, kind: ProjectEntry['kind']) {
|
|
1040
|
+
const dotIndex = kind === 'file' ? input.value.lastIndexOf('.') : -1
|
|
1041
|
+
input.setSelectionRange(0, dotIndex > 0 ? dotIndex : input.value.length)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function getErrorMessage(error: unknown) {
|
|
1045
|
+
return error instanceof Error ? error.message : 'Project file operation failed'
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isMissingProjectPathError(message: string) {
|
|
1049
|
+
return (
|
|
1050
|
+
/\bENOENT\b/i.test(message) ||
|
|
1051
|
+
/no such file or directory/i.test(message) ||
|
|
1052
|
+
/request failed with 404/i.test(message)
|
|
1053
|
+
)
|
|
1054
|
+
}
|