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.
Files changed (44) hide show
  1. package/README.md +306 -123
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. 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
+ }