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,812 +1,1147 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
2
- import { createPortal } from 'react-dom'
3
- import { useAppStore } from '../stores/useAppStore'
4
- import {
5
- getPageSourcePath,
6
- generateAppCode,
7
- generateLayoutCode,
8
- generatePageCode,
9
- generateProjectSchema,
10
- } from '../utils/codegen'
11
- import { importProjectPage } from '../utils/projectPersistence'
12
- import {
13
- loadProjectFile,
14
- saveProjectFile,
15
- type ProjectFile,
16
- } from '../utils/projectFiles'
17
- import type { Page } from '../types'
18
- import ProjectExplorer from './ProjectExplorer'
19
-
20
- export type CodeView = 'page' | 'app' | 'layout' | 'schema' | 'file'
21
-
22
- interface FileSystemChangeDetail {
23
- type: 'filesystem-change'
24
- path: string
25
- action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
26
- }
27
-
28
- const externalMoveMatchWindowMs = 1600
29
-
30
- function getProjectFileName(path: string) {
31
- return path.split('/').pop() ?? path
32
- }
33
-
34
- export default function CodePanel({
35
- view,
36
- onViewChange,
37
- onPageRegistered,
38
- onWorkspaceSelectFile,
39
- filePath,
40
- embedded = false,
41
- }: {
42
- view: CodeView
43
- onViewChange: (view: CodeView) => void
44
- onPageRegistered?: (page: Page) => void
45
- onWorkspaceSelectFile?: (path: string) => void
46
- filePath?: string | null
47
- embedded?: boolean
48
- }) {
49
- const {
50
- appShell,
51
- pages,
52
- activePageId,
53
- projectName,
54
- registerPage,
55
- saveProject,
56
- setActivePage,
57
- setSelectedComponent,
58
- } = useAppStore()
59
- const [copied, setCopied] = useState(false)
60
- const [projectFile, setProjectFile] = useState<ProjectFile | null>(null)
61
- const [fileContent, setFileContent] = useState('')
62
- const [fileLoading, setFileLoading] = useState(false)
63
- const [fileError, setFileError] = useState<string | null>(null)
64
- const [fileSaveState, setFileSaveState] = useState<
65
- 'idle' | 'saving' | 'saved'
66
- >('idle')
67
- const [fileReloadVersion, setFileReloadVersion] = useState(0)
68
- const [externalFileChangePending, setExternalFileChangePending] =
69
- useState(false)
70
- const fileStateRef = useRef({
71
- view,
72
- filePath,
73
- projectFile,
74
- fileContent,
75
- })
76
- const workspaceSelectFileRef = useRef(onWorkspaceSelectFile)
77
- const recentAddedFilesRef = useRef(new Map<string, number>())
78
- const pendingMovedFileRef = useRef<{
79
- path: string
80
- fileName: string
81
- } | null>(null)
82
- const missingFileTimerRef = useRef<number | null>(null)
83
- const [registeringPage, setRegisteringPage] = useState(false)
84
- const [registerName, setRegisterName] = useState('')
85
- const [registerRoute, setRegisterRoute] = useState('')
86
- const [registerError, setRegisterError] = useState<string | null>(null)
87
- const [registerBusy, setRegisterBusy] = useState(false)
88
- const [workspaceOpen, setWorkspaceOpen] = useState(false)
89
- const activePage = pages.find((page) => page.id === activePageId) ?? pages[0]
90
-
91
- useEffect(() => {
92
- if (!workspaceOpen) return
93
-
94
- const previousOverflow = document.body.style.overflow
95
- const closeOnEscape = (event: KeyboardEvent) => {
96
- if (event.key === 'Escape') {
97
- setWorkspaceOpen(false)
98
- }
99
- }
100
-
101
- document.body.style.overflow = 'hidden'
102
- window.addEventListener('keydown', closeOnEscape)
103
-
104
- return () => {
105
- document.body.style.overflow = previousOverflow
106
- window.removeEventListener('keydown', closeOnEscape)
107
- }
108
- }, [workspaceOpen])
109
-
110
- useEffect(() => {
111
- fileStateRef.current = {
112
- view,
113
- filePath,
114
- projectFile,
115
- fileContent,
116
- }
117
- }, [fileContent, filePath, projectFile, view])
118
-
119
- useEffect(() => {
120
- workspaceSelectFileRef.current = onWorkspaceSelectFile
121
- }, [onWorkspaceSelectFile])
122
-
123
- useEffect(
124
- () => () => {
125
- if (missingFileTimerRef.current !== null) {
126
- window.clearTimeout(missingFileTimerRef.current)
127
- }
128
- },
129
- []
130
- )
131
-
132
- useEffect(() => {
133
- if (view !== 'file' || !filePath) return
134
- let cancelled = false
135
-
136
- pendingMovedFileRef.current = null
137
- if (missingFileTimerRef.current !== null) {
138
- window.clearTimeout(missingFileTimerRef.current)
139
- missingFileTimerRef.current = null
140
- }
141
-
142
- void Promise.resolve().then(async () => {
143
- if (cancelled) return
144
- setFileLoading(true)
145
- setFileError(null)
146
- setFileSaveState('idle')
147
- setExternalFileChangePending(false)
148
-
149
- try {
150
- const nextFile = await loadProjectFile(filePath)
151
-
152
- if (cancelled) return
153
- setProjectFile(nextFile)
154
- setFileContent(nextFile.content)
155
- } catch (error) {
156
- if (cancelled) return
157
- setProjectFile(null)
158
- setFileError(getErrorMessage(error))
159
- } finally {
160
- if (!cancelled) setFileLoading(false)
161
- }
162
- })
163
-
164
- return () => {
165
- cancelled = true
166
- }
167
- }, [filePath, fileReloadVersion, view])
168
-
169
- useEffect(() => {
170
- const handleFileSystemChange = (event: Event) => {
171
- const detail = (event as CustomEvent<FileSystemChangeDetail>).detail
172
- const current = fileStateRef.current
173
- const normalizedEventPath = detail?.path.replace(/\\/g, '/') ?? ''
174
- const normalizedCurrentPath = current.filePath?.replace(/\\/g, '/')
175
- const now = Date.now()
176
-
177
- if (
178
- !detail ||
179
- detail.type !== 'filesystem-change' ||
180
- current.view !== 'file' ||
181
- !normalizedCurrentPath
182
- ) {
183
- return
184
- }
185
-
186
- for (const [addedPath, addedAt] of recentAddedFilesRef.current) {
187
- if (now - addedAt > externalMoveMatchWindowMs) {
188
- recentAddedFilesRef.current.delete(addedPath)
189
- }
190
- }
191
-
192
- if (detail.action === 'add') {
193
- recentAddedFilesRef.current.set(normalizedEventPath, now)
194
- const pendingMove = pendingMovedFileRef.current
195
-
196
- if (
197
- pendingMove &&
198
- normalizedEventPath !== pendingMove.path &&
199
- getProjectFileName(normalizedEventPath) === pendingMove.fileName
200
- ) {
201
- pendingMovedFileRef.current = null
202
- if (missingFileTimerRef.current !== null) {
203
- window.clearTimeout(missingFileTimerRef.current)
204
- missingFileTimerRef.current = null
205
- }
206
- setFileError(null)
207
- workspaceSelectFileRef.current?.(normalizedEventPath)
208
- return
209
- }
210
- }
211
-
212
- const selectedPathWasRemoved =
213
- (detail.action === 'unlink' &&
214
- normalizedEventPath === normalizedCurrentPath) ||
215
- (detail.action === 'unlinkDir' &&
216
- normalizedCurrentPath.startsWith(`${normalizedEventPath}/`))
217
-
218
- if (selectedPathWasRemoved) {
219
- const hasLocalEdits =
220
- current.projectFile?.content !== current.fileContent
221
-
222
- if (hasLocalEdits) {
223
- setExternalFileChangePending(true)
224
- return
225
- }
226
-
227
- const fileName = getProjectFileName(normalizedCurrentPath)
228
- const movedPath = Array.from(
229
- recentAddedFilesRef.current.entries()
230
- )
231
- .filter(
232
- ([addedPath, addedAt]) =>
233
- addedPath !== normalizedCurrentPath &&
234
- now - addedAt <= externalMoveMatchWindowMs &&
235
- getProjectFileName(addedPath) === fileName
236
- )
237
- .sort((left, right) => right[1] - left[1])[0]?.[0]
238
-
239
- if (movedPath) {
240
- setFileError(null)
241
- workspaceSelectFileRef.current?.(movedPath)
242
- return
243
- }
244
-
245
- pendingMovedFileRef.current = {
246
- path: normalizedCurrentPath,
247
- fileName,
248
- }
249
- setFileError(null)
250
-
251
- if (missingFileTimerRef.current !== null) {
252
- window.clearTimeout(missingFileTimerRef.current)
253
- }
254
- missingFileTimerRef.current = window.setTimeout(() => {
255
- if (pendingMovedFileRef.current?.path !== normalizedCurrentPath) {
256
- return
257
- }
258
-
259
- pendingMovedFileRef.current = null
260
- missingFileTimerRef.current = null
261
- setProjectFile(null)
262
- setFileError('This file was removed from the project.')
263
- }, externalMoveMatchWindowMs)
264
- return
265
- }
266
-
267
- if (normalizedEventPath !== normalizedCurrentPath) {
268
- return
269
- }
270
-
271
- const hasLocalEdits =
272
- current.projectFile?.content !== current.fileContent
273
-
274
- if (hasLocalEdits) {
275
- setExternalFileChangePending(true)
276
- return
277
- }
278
-
279
- setFileReloadVersion((version) => version + 1)
280
- }
281
-
282
- window.addEventListener(
283
- 'visualbuild:filesystem-changed',
284
- handleFileSystemChange
285
- )
286
- return () =>
287
- window.removeEventListener(
288
- 'visualbuild:filesystem-changed',
289
- handleFileSystemChange
290
- )
291
- }, [])
292
-
293
- const generated = useMemo(() => {
294
- if (view === 'file') {
295
- return {
296
- filename: filePath ?? 'No file selected',
297
- code: fileContent,
298
- }
299
- }
300
-
301
- if (view === 'page') {
302
- return {
303
- filename: getPageSourcePath(activePage),
304
- code: generatePageCode(activePage),
305
- }
306
- }
307
-
308
- if (view === 'app') {
309
- return {
310
- filename: 'src/App.tsx',
311
- code: generateAppCode(pages, appShell),
312
- }
313
- }
314
-
315
- if (view === 'layout') {
316
- return {
317
- filename: 'src/layouts/AppLayout.tsx',
318
- code: generateLayoutCode(appShell),
319
- }
320
- }
321
-
322
- if (view === 'schema') {
323
- return {
324
- filename: 'visualbuild/pages.json',
325
- code: generateProjectSchema(pages, appShell, projectName),
326
- }
327
- }
328
-
329
- return {
330
- filename: 'visualbuild/pages.json',
331
- code: generateProjectSchema(pages, appShell, projectName),
332
- }
333
- }, [activePage, appShell, fileContent, filePath, pages, projectName, view])
334
-
335
- const copyCode = () => {
336
- void navigator.clipboard?.writeText(generated.code)
337
- setCopied(true)
338
- window.setTimeout(() => setCopied(false), 1000)
339
- }
340
-
341
- const saveFile = async () => {
342
- if (!filePath || !projectFile || projectFile.isBinary || projectFile.isTooLarge) {
343
- return
344
- }
345
-
346
- setFileSaveState('saving')
347
- setFileError(null)
348
-
349
- try {
350
- await saveProjectFile(filePath, fileContent)
351
- setProjectFile({
352
- ...projectFile,
353
- content: fileContent,
354
- size: new Blob([fileContent]).size,
355
- })
356
- setExternalFileChangePending(false)
357
- setFileSaveState('saved')
358
- window.setTimeout(() => setFileSaveState('idle'), 1200)
359
- } catch (error) {
360
- setFileError(getErrorMessage(error))
361
- setFileSaveState('idle')
362
- }
363
- }
364
-
365
- const isFileDirty =
366
- view === 'file' && projectFile?.content !== fileContent
367
- const normalizedFilePath = filePath?.replace(/\\/g, '/') ?? ''
368
- const canRegisterFileAsPage =
369
- view === 'file' &&
370
- Boolean(projectFile) &&
371
- /^src\/.+\.(jsx|tsx)$/i.test(normalizedFilePath) &&
372
- !pages.some((page) => getPageSourcePath(page) === normalizedFilePath)
373
-
374
- const openPageRegistration = () => {
375
- const name = derivePageName(normalizedFilePath)
376
-
377
- setRegisterName(name)
378
- setRegisterRoute(derivePageRoute(name))
379
- setRegisterError(null)
380
- setRegisteringPage(true)
381
- }
382
-
383
- const registerFileAsPage = async () => {
384
- const name = registerName.trim()
385
- const routeInput = registerRoute.trim()
386
- const route = routeInput.startsWith('/') ? routeInput : `/${routeInput}`
387
-
388
- if (!filePath || !name || !routeInput) {
389
- setRegisterError('Enter a page name and route')
390
- return
391
- }
392
-
393
- if (
394
- pages.some((page) => page.name.toLowerCase() === name.toLowerCase())
395
- ) {
396
- setRegisterError('Page name already exists')
397
- return
398
- }
399
-
400
- if (pages.some((page) => page.route === route)) {
401
- setRegisterError('Route already exists')
402
- return
403
- }
404
-
405
- setRegisterBusy(true)
406
- setRegisterError(null)
407
-
408
- try {
409
- if (isFileDirty) {
410
- await saveProjectFile(filePath, fileContent)
411
- setProjectFile((current) =>
412
- current
413
- ? {
414
- ...current,
415
- content: fileContent,
416
- size: new Blob([fileContent]).size,
417
- }
418
- : current
419
- )
420
- }
421
-
422
- const page = await importProjectPage(filePath, name, route)
423
- registerPage(page)
424
- setActivePage(page.id)
425
- await saveProject()
426
- onViewChange('page')
427
- onPageRegistered?.(page)
428
- setRegisteringPage(false)
429
- } catch (error) {
430
- setRegisterError(getErrorMessage(error))
431
- } finally {
432
- setRegisterBusy(false)
433
- }
434
- }
435
-
436
- const panelContent = (
437
- <>
438
- <div className="h-10 px-3 border-b border-white/[0.06] flex items-center justify-between gap-3">
439
- <div className="min-w-0">
440
- <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
441
- Code
442
- </p>
443
- <p className="text-[10px] text-gray-400 truncate">
444
- {generated.filename}
445
- </p>
446
- </div>
447
- <div className="flex shrink-0 items-center gap-1">
448
- {!workspaceOpen && (
449
- <button
450
- type="button"
451
- onClick={() => setWorkspaceOpen(true)}
452
- className="cursor-pointer rounded bg-white/[0.06] px-2 py-1 text-[10px] font-medium text-gray-300 transition-colors hover:bg-white/[0.1] hover:text-white"
453
- title="Open a larger code workspace"
454
- >
455
- Open editor
456
- </button>
457
- )}
458
- {canRegisterFileAsPage && (
459
- <button
460
- type="button"
461
- onClick={openPageRegistration}
462
- className="cursor-pointer rounded bg-emerald-500/15 px-2 py-1 text-[10px] font-medium text-emerald-300 transition-colors hover:bg-emerald-500/25 hover:text-emerald-200"
463
- >
464
- Register page
465
- </button>
466
- )}
467
- {view === 'file' && externalFileChangePending && (
468
- <button
469
- type="button"
470
- onClick={() => {
471
- setExternalFileChangePending(false)
472
- setFileReloadVersion((version) => version + 1)
473
- }}
474
- className="cursor-pointer rounded bg-amber-500/15 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/25 hover:text-amber-200"
475
- title="Discard unsaved editor text and load the external file version"
476
- >
477
- Reload disk
478
- </button>
479
- )}
480
- {view === 'file' && projectFile && !projectFile.isBinary && !projectFile.isTooLarge && (
481
- <button
482
- type="button"
483
- onClick={() => void saveFile()}
484
- disabled={!isFileDirty || fileSaveState === 'saving'}
485
- className="cursor-pointer rounded bg-blue-500 px-2 py-1 text-[10px] font-medium text-white transition-colors hover:bg-blue-400 disabled:cursor-default disabled:bg-white/[0.06] disabled:text-gray-500"
486
- >
487
- {fileSaveState === 'saving'
488
- ? 'Saving...'
489
- : fileSaveState === 'saved'
490
- ? 'Saved'
491
- : 'Save file'}
492
- </button>
493
- )}
494
- <button
495
- type="button"
496
- onClick={copyCode}
497
- className="px-2 py-1 text-[10px] text-gray-300 bg-white/[0.06] hover:bg-white/[0.1] rounded transition-colors cursor-pointer select-none"
498
- >
499
- {copied ? 'Copied' : 'Copy'}
500
- </button>
501
- </div>
502
- </div>
503
-
504
- <div className="px-2 py-2 border-b border-white/[0.06] flex flex-wrap gap-1">
505
- <CodeTab active={view === 'page'} onClick={() => onViewChange('page')}>
506
- Page
507
- </CodeTab>
508
- <CodeTab active={view === 'app'} onClick={() => onViewChange('app')}>
509
- App
510
- </CodeTab>
511
- {(appShell.header || appShell.footer) && (
512
- <CodeTab
513
- active={view === 'layout'}
514
- onClick={() => onViewChange('layout')}
515
- >
516
- Layout
517
- </CodeTab>
518
- )}
519
- <CodeTab
520
- active={view === 'schema'}
521
- onClick={() => onViewChange('schema')}
522
- >
523
- JSON
524
- </CodeTab>
525
- {filePath && (
526
- <CodeTab active={view === 'file'} onClick={() => onViewChange('file')}>
527
- File{isFileDirty ? ' *' : ''}
528
- </CodeTab>
529
- )}
530
- </div>
531
-
532
- {view === 'file' ? (
533
- <FileEditor
534
- file={projectFile}
535
- content={fileContent}
536
- loading={fileLoading}
537
- error={fileError}
538
- onChange={setFileContent}
539
- onSave={() => void saveFile()}
540
- />
541
- ) : (
542
- <pre className="flex-1 overflow-auto p-4 text-[11px] leading-5 font-mono text-gray-200 bg-[#0b0d11]">
543
- <code>{generated.code}</code>
544
- </pre>
545
- )}
546
- {registeringPage && (
547
- <PageRegistrationDialog
548
- name={registerName}
549
- route={registerRoute}
550
- error={registerError}
551
- busy={registerBusy}
552
- onNameChange={setRegisterName}
553
- onRouteChange={setRegisterRoute}
554
- onCancel={() => setRegisteringPage(false)}
555
- onConfirm={() => void registerFileAsPage()}
556
- />
557
- )}
558
- </>
559
- )
560
-
561
- const compactPanel = (
562
- <aside
563
- className={`${embedded ? 'h-full w-full' : 'w-[460px] border-l'} relative flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-[#101318]`}
564
- onClick={() => setSelectedComponent(null)}
565
- >
566
- {panelContent}
567
- </aside>
568
- )
569
-
570
- if (!workspaceOpen) {
571
- return compactPanel
572
- }
573
-
574
- return createPortal(
575
- <div
576
- className="fixed inset-0 z-[140] flex bg-black/70 p-3 backdrop-blur-sm"
577
- role="dialog"
578
- aria-modal="true"
579
- aria-label="VisualBuild code workspace"
580
- >
581
- <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-white/10 bg-[#0b0d11] shadow-2xl">
582
- <header className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.08] bg-[#15181e] px-4">
583
- <div className="min-w-0">
584
- <p className="text-xs font-semibold text-white">
585
- VisualBuild Code Editor
586
- </p>
587
- <p className="truncate text-[10px] text-gray-500">
588
- {generated.filename}
589
- </p>
590
- </div>
591
- <button
592
- type="button"
593
- onClick={() => setWorkspaceOpen(false)}
594
- className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
595
- aria-label="Close code editor"
596
- title="Close code editor"
597
- >
598
- x
599
- </button>
600
- </header>
601
-
602
- <div className="flex min-h-0 flex-1 flex-col sm:flex-row">
603
- <aside className="flex h-[38%] w-full shrink-0 flex-col overflow-hidden border-b border-white/[0.08] bg-[#101318] sm:h-auto sm:w-[300px] sm:border-r sm:border-b-0">
604
- <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-3">
605
- <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
606
- Project files
607
- </p>
608
- </div>
609
- <div className="min-h-0 flex-1 overflow-hidden">
610
- <ProjectExplorer
611
- selectedPath={filePath ?? null}
612
- onSelectFile={(path) => {
613
- if (path) onWorkspaceSelectFile?.(path)
614
- }}
615
- />
616
- </div>
617
- </aside>
618
-
619
- <section
620
- className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-[#101318]"
621
- onClick={() => setSelectedComponent(null)}
622
- >
623
- {panelContent}
624
- </section>
625
- </div>
626
- </div>
627
- </div>,
628
- document.body
629
- )
630
- }
631
-
632
- function PageRegistrationDialog({
633
- name,
634
- route,
635
- error,
636
- busy,
637
- onNameChange,
638
- onRouteChange,
639
- onCancel,
640
- onConfirm,
641
- }: {
642
- name: string
643
- route: string
644
- error: string | null
645
- busy: boolean
646
- onNameChange: (value: string) => void
647
- onRouteChange: (value: string) => void
648
- onCancel: () => void
649
- onConfirm: () => void
650
- }) {
651
- return (
652
- <div className="absolute inset-0 z-20 flex items-center justify-center bg-black/55 p-5 backdrop-blur-sm">
653
- <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#191d24] p-4 shadow-2xl">
654
- <h2 className="text-sm font-semibold text-white">Register as page</h2>
655
- <p className="mt-1 text-[11px] leading-4 text-gray-400">
656
- VisualBuild will manage this file from the canvas after registration.
657
- </p>
658
- <label className="mt-4 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
659
- Page name
660
- <input
661
- autoFocus
662
- value={name}
663
- onChange={(event) => onNameChange(event.target.value)}
664
- className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
665
- />
666
- </label>
667
- <label className="mt-3 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
668
- Route
669
- <input
670
- value={route}
671
- onChange={(event) => onRouteChange(event.target.value)}
672
- onKeyDown={(event) => {
673
- if (event.key === 'Enter') onConfirm()
674
- }}
675
- className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
676
- />
677
- </label>
678
- {error && <p className="mt-3 text-xs text-red-300">{error}</p>}
679
- <div className="mt-4 flex justify-end gap-2">
680
- <button
681
- type="button"
682
- onClick={onCancel}
683
- disabled={busy}
684
- className="cursor-pointer rounded px-3 py-1.5 text-xs text-gray-400 hover:bg-white/[0.06] hover:text-white disabled:cursor-default"
685
- >
686
- Keep as file
687
- </button>
688
- <button
689
- type="button"
690
- onClick={onConfirm}
691
- disabled={busy}
692
- className="cursor-pointer rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-400 disabled:cursor-default disabled:bg-blue-500/40"
693
- >
694
- {busy ? 'Registering...' : 'Register page'}
695
- </button>
696
- </div>
697
- </div>
698
- </div>
699
- )
700
- }
701
-
702
- function FileEditor({
703
- file,
704
- content,
705
- loading,
706
- error,
707
- onChange,
708
- onSave,
709
- }: {
710
- file: ProjectFile | null
711
- content: string
712
- loading: boolean
713
- error: string | null
714
- onChange: (content: string) => void
715
- onSave: () => void
716
- }) {
717
- if (loading) {
718
- return (
719
- <div className="flex flex-1 items-center justify-center bg-[#0b0d11] text-xs text-gray-500">
720
- Opening file...
721
- </div>
722
- )
723
- }
724
-
725
- if (error) {
726
- return (
727
- <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-red-300">
728
- {error}
729
- </div>
730
- )
731
- }
732
-
733
- if (file?.isBinary || file?.isTooLarge) {
734
- return (
735
- <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-gray-400">
736
- {file.isBinary
737
- ? 'Binary files cannot be edited in the code panel.'
738
- : 'This file is larger than the 2 MB editor limit.'}
739
- </div>
740
- )
741
- }
742
-
743
- return (
744
- <textarea
745
- value={content}
746
- onChange={(event) => onChange(event.target.value)}
747
- onKeyDown={(event) => {
748
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
749
- event.preventDefault()
750
- onSave()
751
- }
752
- }}
753
- spellCheck={false}
754
- className="min-h-0 flex-1 resize-none overflow-auto bg-[#0b0d11] p-4 font-mono text-[11px] leading-5 text-gray-200 outline-none"
755
- aria-label="Project file editor"
756
- />
757
- )
758
- }
759
-
760
- function CodeTab({
761
- active,
762
- children,
763
- onClick,
764
- }: {
765
- active: boolean
766
- children: React.ReactNode
767
- onClick: () => void
768
- }) {
769
- return (
770
- <button
771
- type="button"
772
- onClick={onClick}
773
- className={`px-2 py-1 text-[10px] rounded transition-colors cursor-pointer select-none ${
774
- active
775
- ? 'bg-accent text-white'
776
- : 'text-gray-500 hover:text-gray-200 hover:bg-white/[0.06]'
777
- }`}
778
- >
779
- {children}
780
- </button>
781
- )
782
- }
783
-
784
- function getErrorMessage(error: unknown) {
785
- return error instanceof Error ? error.message : 'Project file operation failed'
786
- }
787
-
788
- function derivePageName(filePath: string) {
789
- const filename = filePath.split('/').pop() ?? 'Page'
790
- const baseName = filename
791
- .replace(/\.[^.]+$/, '')
792
- .replace(/Page$/i, '')
793
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
794
- .replace(/[-_]+/g, ' ')
795
- .trim()
796
-
797
- return baseName
798
- .split(/\s+/)
799
- .filter(Boolean)
800
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
801
- .join(' ') || 'Page'
802
- }
803
-
804
- function derivePageRoute(name: string) {
805
- const slug = name
806
- .trim()
807
- .toLowerCase()
808
- .replace(/[^a-z0-9]+/g, '-')
809
- .replace(/^-+|-+$/g, '')
810
-
811
- return slug === 'home' ? '/' : `/${slug || 'page'}`
812
- }
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { syncCustomComponentRoot } from '../../shared/syncCustomComponentSource.mjs'
4
+ import { useAppStore } from '../stores/useAppStore'
5
+ import {
6
+ getPageSourcePath,
7
+ generateAppCode,
8
+ generateLayoutCode,
9
+ generatePageCode,
10
+ generateProjectSchema,
11
+ } from '../utils/codegen'
12
+ import {
13
+ importProjectPage,
14
+ registerProjectComponent,
15
+ } from '../utils/projectPersistence'
16
+ import {
17
+ loadProjectFile,
18
+ saveProjectFile,
19
+ type ProjectFile,
20
+ } from '../utils/projectFiles'
21
+ import type { Page, VisualNode } from '../types'
22
+ import ProjectExplorer from './ProjectExplorer'
23
+
24
+ export type CodeView = 'page' | 'app' | 'layout' | 'schema' | 'file'
25
+
26
+ interface FileSystemChangeDetail {
27
+ type: 'filesystem-change'
28
+ path: string
29
+ action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
30
+ }
31
+
32
+ const externalMoveMatchWindowMs = 1600
33
+
34
+ function getProjectFileName(path: string) {
35
+ return path.split('/').pop() ?? path
36
+ }
37
+
38
+ function findVisualNodeById(
39
+ nodes: VisualNode[],
40
+ nodeId: string
41
+ ): VisualNode | null {
42
+ for (const node of nodes) {
43
+ if (node.id === nodeId) return node
44
+ const child = findVisualNodeById(node.children, nodeId)
45
+ if (child) return child
46
+ const tree = node.props.componentTree
47
+ if (
48
+ typeof tree === 'object' &&
49
+ tree !== null &&
50
+ Array.isArray((tree as { children?: unknown }).children)
51
+ ) {
52
+ const definitionChild = findVisualNodeById(
53
+ (tree as { children: VisualNode[] }).children,
54
+ nodeId
55
+ )
56
+ if (definitionChild) return definitionChild
57
+ }
58
+ }
59
+
60
+ return null
61
+ }
62
+
63
+ function findCustomOwnerNode(
64
+ nodes: VisualNode[],
65
+ nodeId: string,
66
+ names: Set<string>
67
+ ): VisualNode | null {
68
+ for (const node of nodes) {
69
+ if (names.has(node.type)) {
70
+ const tree = node.props.componentTree
71
+ const children =
72
+ typeof tree === 'object' &&
73
+ tree !== null &&
74
+ Array.isArray((tree as { children?: unknown }).children)
75
+ ? (tree as { children: VisualNode[] }).children
76
+ : []
77
+ if (node.id === nodeId || findVisualNodeById(children, nodeId)) {
78
+ return node
79
+ }
80
+ }
81
+ const owner = findCustomOwnerNode(node.children, nodeId, names)
82
+ if (owner) return owner
83
+ }
84
+ return null
85
+ }
86
+
87
+ function getCustomComponentTreeChildren(node: VisualNode) {
88
+ const tree = node.props.componentTree
89
+ return typeof tree === 'object' &&
90
+ tree !== null &&
91
+ Array.isArray((tree as { children?: unknown }).children)
92
+ ? (tree as { children: VisualNode[] }).children
93
+ : []
94
+ }
95
+
96
+ export default function CodePanel({
97
+ view,
98
+ onViewChange,
99
+ onPageRegistered,
100
+ onWorkspaceSelectFile,
101
+ filePath,
102
+ embedded = false,
103
+ }: {
104
+ view: CodeView
105
+ onViewChange: (view: CodeView) => void
106
+ onPageRegistered?: (page: Page) => void
107
+ onWorkspaceSelectFile?: (path: string) => void
108
+ filePath?: string | null
109
+ embedded?: boolean
110
+ }) {
111
+ const {
112
+ appShell,
113
+ pages,
114
+ activePageId,
115
+ selectedComponentId,
116
+ projectName,
117
+ registerPage,
118
+ saveProject,
119
+ setActivePage,
120
+ setSelectedComponent,
121
+ customComponents,
122
+ registerCustomComponent,
123
+ projectConfig,
124
+ } = useAppStore()
125
+ const [copied, setCopied] = useState(false)
126
+ const [projectFile, setProjectFile] = useState<ProjectFile | null>(null)
127
+ const [fileContent, setFileContent] = useState('')
128
+ const fileContentOriginRef = useRef<'disk' | 'generated' | 'user'>('disk')
129
+ const [fileLoading, setFileLoading] = useState(false)
130
+ const [fileError, setFileError] = useState<string | null>(null)
131
+ const [fileSaveState, setFileSaveState] = useState<
132
+ 'idle' | 'saving' | 'saved'
133
+ >('idle')
134
+ const [fileReloadVersion, setFileReloadVersion] = useState(0)
135
+ const [externalFileChangePending, setExternalFileChangePending] =
136
+ useState(false)
137
+ const fileStateRef = useRef({
138
+ view,
139
+ filePath,
140
+ projectFile,
141
+ fileContent,
142
+ })
143
+ const workspaceSelectFileRef = useRef(onWorkspaceSelectFile)
144
+ const recentAddedFilesRef = useRef(new Map<string, number>())
145
+ const pendingMovedFileRef = useRef<{
146
+ path: string
147
+ fileName: string
148
+ } | null>(null)
149
+ const missingFileTimerRef = useRef<number | null>(null)
150
+ const [registrationOpen, setRegistrationOpen] = useState(false)
151
+ const [registrationKind, setRegistrationKind] =
152
+ useState<'page' | 'component'>('page')
153
+ const [registerName, setRegisterName] = useState('')
154
+ const [registerRoute, setRegisterRoute] = useState('')
155
+ const [registerError, setRegisterError] = useState<string | null>(null)
156
+ const [registerBusy, setRegisterBusy] = useState(false)
157
+ const [workspaceOpen, setWorkspaceOpen] = useState(false)
158
+ const activePage = pages.find((page) => page.id === activePageId) ?? pages[0]
159
+ const selectedCustomSource = useMemo(() => {
160
+ if (!selectedComponentId || !filePath) return null
161
+
162
+ const projectNodes = [
163
+ ...(appShell.header ? [appShell.header] : []),
164
+ ...pages.flatMap((page) => [page.root, ...page.components]),
165
+ ...(appShell.footer ? [appShell.footer] : []),
166
+ ]
167
+ const selectedNode = findVisualNodeById(
168
+ projectNodes,
169
+ selectedComponentId
170
+ )
171
+ const customOwner = findCustomOwnerNode(
172
+ projectNodes,
173
+ selectedComponentId,
174
+ new Set(customComponents.map((component) => component.name))
175
+ )
176
+ const definition = customComponents.find(
177
+ (component) =>
178
+ component.name === (customOwner?.type ?? selectedNode?.type)
179
+ )
180
+
181
+ if (
182
+ !selectedNode ||
183
+ !definition ||
184
+ definition.importPath.replace(/\\/g, '/') !==
185
+ filePath.replace(/\\/g, '/')
186
+ ) {
187
+ return null
188
+ }
189
+
190
+ return {
191
+ definition,
192
+ node: customOwner ?? selectedNode,
193
+ }
194
+ }, [
195
+ appShell,
196
+ customComponents,
197
+ filePath,
198
+ pages,
199
+ selectedComponentId,
200
+ ])
201
+
202
+ useEffect(() => {
203
+ if (!workspaceOpen) return
204
+
205
+ const previousOverflow = document.body.style.overflow
206
+ const closeOnEscape = (event: KeyboardEvent) => {
207
+ if (event.key === 'Escape') {
208
+ setWorkspaceOpen(false)
209
+ }
210
+ }
211
+
212
+ document.body.style.overflow = 'hidden'
213
+ window.addEventListener('keydown', closeOnEscape)
214
+
215
+ return () => {
216
+ document.body.style.overflow = previousOverflow
217
+ window.removeEventListener('keydown', closeOnEscape)
218
+ }
219
+ }, [workspaceOpen])
220
+
221
+ useEffect(() => {
222
+ fileStateRef.current = {
223
+ view,
224
+ filePath,
225
+ projectFile,
226
+ fileContent,
227
+ }
228
+ }, [fileContent, filePath, projectFile, view])
229
+
230
+ useEffect(() => {
231
+ workspaceSelectFileRef.current = onWorkspaceSelectFile
232
+ }, [onWorkspaceSelectFile])
233
+
234
+ useEffect(
235
+ () => () => {
236
+ if (missingFileTimerRef.current !== null) {
237
+ window.clearTimeout(missingFileTimerRef.current)
238
+ }
239
+ },
240
+ []
241
+ )
242
+
243
+ useEffect(() => {
244
+ if (view !== 'file' || !filePath) return
245
+ let cancelled = false
246
+
247
+ pendingMovedFileRef.current = null
248
+ if (missingFileTimerRef.current !== null) {
249
+ window.clearTimeout(missingFileTimerRef.current)
250
+ missingFileTimerRef.current = null
251
+ }
252
+
253
+ void Promise.resolve().then(async () => {
254
+ if (cancelled) return
255
+ setFileLoading(true)
256
+ setFileError(null)
257
+ setFileSaveState('idle')
258
+ setExternalFileChangePending(false)
259
+
260
+ try {
261
+ const nextFile = await loadProjectFile(filePath)
262
+
263
+ if (cancelled) return
264
+ setProjectFile(nextFile)
265
+ setFileContent(nextFile.content)
266
+ fileContentOriginRef.current = 'disk'
267
+ } catch (error) {
268
+ if (cancelled) return
269
+ setProjectFile(null)
270
+ setFileError(getErrorMessage(error))
271
+ } finally {
272
+ if (!cancelled) setFileLoading(false)
273
+ }
274
+ })
275
+
276
+ return () => {
277
+ cancelled = true
278
+ }
279
+ }, [filePath, fileReloadVersion, view])
280
+
281
+ useEffect(() => {
282
+ const handleFileSystemChange = (event: Event) => {
283
+ const detail = (event as CustomEvent<FileSystemChangeDetail>).detail
284
+ const current = fileStateRef.current
285
+ const normalizedCurrentPath = current.filePath?.replace(/\\/g, '/')
286
+
287
+ if (
288
+ !detail &&
289
+ current.view === 'file' &&
290
+ normalizedCurrentPath
291
+ ) {
292
+ const hasLocalEdits =
293
+ fileContentOriginRef.current === 'user' &&
294
+ current.projectFile?.content !== current.fileContent
295
+
296
+ if (hasLocalEdits) {
297
+ setExternalFileChangePending(true)
298
+ } else {
299
+ setFileReloadVersion((version) => version + 1)
300
+ }
301
+ return
302
+ }
303
+
304
+ const normalizedEventPath = detail?.path.replace(/\\/g, '/') ?? ''
305
+ const now = Date.now()
306
+
307
+ if (
308
+ !detail ||
309
+ detail.type !== 'filesystem-change' ||
310
+ current.view !== 'file' ||
311
+ !normalizedCurrentPath
312
+ ) {
313
+ return
314
+ }
315
+
316
+ for (const [addedPath, addedAt] of recentAddedFilesRef.current) {
317
+ if (now - addedAt > externalMoveMatchWindowMs) {
318
+ recentAddedFilesRef.current.delete(addedPath)
319
+ }
320
+ }
321
+
322
+ if (detail.action === 'add') {
323
+ recentAddedFilesRef.current.set(normalizedEventPath, now)
324
+ const pendingMove = pendingMovedFileRef.current
325
+
326
+ if (
327
+ pendingMove &&
328
+ normalizedEventPath !== pendingMove.path &&
329
+ getProjectFileName(normalizedEventPath) === pendingMove.fileName
330
+ ) {
331
+ pendingMovedFileRef.current = null
332
+ if (missingFileTimerRef.current !== null) {
333
+ window.clearTimeout(missingFileTimerRef.current)
334
+ missingFileTimerRef.current = null
335
+ }
336
+ setFileError(null)
337
+ workspaceSelectFileRef.current?.(normalizedEventPath)
338
+ return
339
+ }
340
+ }
341
+
342
+ const selectedPathWasRemoved =
343
+ (detail.action === 'unlink' &&
344
+ normalizedEventPath === normalizedCurrentPath) ||
345
+ (detail.action === 'unlinkDir' &&
346
+ normalizedCurrentPath.startsWith(`${normalizedEventPath}/`))
347
+
348
+ if (selectedPathWasRemoved) {
349
+ const hasLocalEdits =
350
+ fileContentOriginRef.current === 'user' &&
351
+ current.projectFile?.content !== current.fileContent
352
+
353
+ if (hasLocalEdits) {
354
+ setExternalFileChangePending(true)
355
+ return
356
+ }
357
+
358
+ const fileName = getProjectFileName(normalizedCurrentPath)
359
+ const movedPath = Array.from(
360
+ recentAddedFilesRef.current.entries()
361
+ )
362
+ .filter(
363
+ ([addedPath, addedAt]) =>
364
+ addedPath !== normalizedCurrentPath &&
365
+ now - addedAt <= externalMoveMatchWindowMs &&
366
+ getProjectFileName(addedPath) === fileName
367
+ )
368
+ .sort((left, right) => right[1] - left[1])[0]?.[0]
369
+
370
+ if (movedPath) {
371
+ setFileError(null)
372
+ workspaceSelectFileRef.current?.(movedPath)
373
+ return
374
+ }
375
+
376
+ pendingMovedFileRef.current = {
377
+ path: normalizedCurrentPath,
378
+ fileName,
379
+ }
380
+ setFileError(null)
381
+
382
+ if (missingFileTimerRef.current !== null) {
383
+ window.clearTimeout(missingFileTimerRef.current)
384
+ }
385
+ missingFileTimerRef.current = window.setTimeout(() => {
386
+ if (pendingMovedFileRef.current?.path !== normalizedCurrentPath) {
387
+ return
388
+ }
389
+
390
+ pendingMovedFileRef.current = null
391
+ missingFileTimerRef.current = null
392
+ setProjectFile(null)
393
+ setFileError('This file was removed from the project.')
394
+ }, externalMoveMatchWindowMs)
395
+ return
396
+ }
397
+
398
+ if (normalizedEventPath !== normalizedCurrentPath) {
399
+ return
400
+ }
401
+
402
+ const hasLocalEdits =
403
+ fileContentOriginRef.current === 'user' &&
404
+ current.projectFile?.content !== current.fileContent
405
+
406
+ if (hasLocalEdits) {
407
+ setExternalFileChangePending(true)
408
+ return
409
+ }
410
+
411
+ setFileReloadVersion((version) => version + 1)
412
+ }
413
+
414
+ window.addEventListener(
415
+ 'visualbuild:filesystem-changed',
416
+ handleFileSystemChange
417
+ )
418
+ return () =>
419
+ window.removeEventListener(
420
+ 'visualbuild:filesystem-changed',
421
+ handleFileSystemChange
422
+ )
423
+ }, [])
424
+
425
+ const generated = useMemo(() => {
426
+ if (view === 'file') {
427
+ return {
428
+ filename: filePath ?? 'No file selected',
429
+ code: fileContent,
430
+ }
431
+ }
432
+
433
+ if (view === 'page') {
434
+ return {
435
+ filename: getPageSourcePath(activePage),
436
+ code: generatePageCode(activePage, customComponents),
437
+ }
438
+ }
439
+
440
+ if (view === 'app') {
441
+ return {
442
+ filename: projectConfig.paths.appFile,
443
+ code: generateAppCode(pages, appShell, {
444
+ appFile: projectConfig.paths.appFile,
445
+ layoutFile: `${projectConfig.paths.layoutsDir}/AppLayout.tsx`,
446
+ emitLayout: projectConfig.generator.emitLayout,
447
+ }),
448
+ }
449
+ }
450
+
451
+ if (view === 'layout') {
452
+ return {
453
+ filename: `${projectConfig.paths.layoutsDir}/AppLayout.tsx`,
454
+ code: generateLayoutCode(appShell, customComponents, {
455
+ layoutFile: `${projectConfig.paths.layoutsDir}/AppLayout.tsx`,
456
+ }),
457
+ }
458
+ }
459
+
460
+ if (view === 'schema') {
461
+ return {
462
+ filename: projectConfig.paths.schemaFile,
463
+ code: generateProjectSchema(pages, appShell, projectName),
464
+ }
465
+ }
466
+
467
+ return {
468
+ filename: projectConfig.paths.schemaFile,
469
+ code: generateProjectSchema(pages, appShell, projectName),
470
+ }
471
+ }, [
472
+ activePage,
473
+ appShell,
474
+ customComponents,
475
+ fileContent,
476
+ filePath,
477
+ pages,
478
+ projectConfig,
479
+ projectName,
480
+ view,
481
+ ])
482
+
483
+ useEffect(() => {
484
+ if (
485
+ view !== 'file' ||
486
+ !filePath ||
487
+ !projectFile ||
488
+ fileLoading ||
489
+ fileContentOriginRef.current === 'user'
490
+ ) {
491
+ return
492
+ }
493
+
494
+ const normalizedPath = filePath.replace(/\\/g, '/')
495
+ const managedPage = pages.find(
496
+ (page) => getPageSourcePath(page) === normalizedPath
497
+ )
498
+
499
+ if (!managedPage) return
500
+
501
+ const nextContent = generatePageCode(managedPage, customComponents)
502
+ const timer = window.setTimeout(() => {
503
+ setFileContent(nextContent)
504
+ fileContentOriginRef.current =
505
+ nextContent === projectFile.content ? 'disk' : 'generated'
506
+ }, 0)
507
+
508
+ return () => window.clearTimeout(timer)
509
+ }, [
510
+ customComponents,
511
+ fileLoading,
512
+ filePath,
513
+ pages,
514
+ projectFile,
515
+ view,
516
+ ])
517
+
518
+ useEffect(() => {
519
+ if (
520
+ view !== 'file' ||
521
+ !filePath ||
522
+ !projectFile ||
523
+ fileLoading ||
524
+ !selectedCustomSource ||
525
+ fileContentOriginRef.current === 'user'
526
+ ) {
527
+ return
528
+ }
529
+
530
+ try {
531
+ const nextContent = syncCustomComponentRoot(projectFile.content, {
532
+ name: selectedCustomSource.definition.name,
533
+ rootTag:
534
+ typeof selectedCustomSource.node.props.wrapperTag === 'string'
535
+ ? selectedCustomSource.node.props.wrapperTag
536
+ : undefined,
537
+ id: selectedCustomSource.node.props.id,
538
+ className: selectedCustomSource.node.props.className,
539
+ attributes: selectedCustomSource.node.props.attributes,
540
+ children: getCustomComponentTreeChildren(
541
+ selectedCustomSource.node
542
+ ),
543
+ })
544
+ const timer = window.setTimeout(() => {
545
+ setFileError(null)
546
+ setFileContent(nextContent)
547
+ fileContentOriginRef.current =
548
+ nextContent === projectFile.content ? 'disk' : 'generated'
549
+ }, 0)
550
+
551
+ return () => window.clearTimeout(timer)
552
+ } catch (error) {
553
+ const timer = window.setTimeout(
554
+ () => setFileError(getErrorMessage(error)),
555
+ 0
556
+ )
557
+ return () => window.clearTimeout(timer)
558
+ }
559
+ }, [
560
+ fileLoading,
561
+ filePath,
562
+ projectFile,
563
+ selectedCustomSource,
564
+ view,
565
+ ])
566
+
567
+ const copyCode = () => {
568
+ void navigator.clipboard?.writeText(generated.code)
569
+ setCopied(true)
570
+ window.setTimeout(() => setCopied(false), 1000)
571
+ }
572
+
573
+ const saveFile = async () => {
574
+ if (!filePath || !projectFile || projectFile.isBinary || projectFile.isTooLarge) {
575
+ return
576
+ }
577
+
578
+ setFileSaveState('saving')
579
+ setFileError(null)
580
+
581
+ try {
582
+ if (selectedCustomSource) {
583
+ const contentOrigin = fileContentOriginRef.current
584
+ await saveProject()
585
+
586
+ if (contentOrigin === 'user') {
587
+ await saveProjectFile(filePath, fileContent)
588
+ }
589
+
590
+ const savedFile = await loadProjectFile(filePath)
591
+ setProjectFile(savedFile)
592
+ setFileContent(savedFile.content)
593
+ } else {
594
+ await saveProjectFile(filePath, fileContent)
595
+ setProjectFile({
596
+ ...projectFile,
597
+ content: fileContent,
598
+ size: new Blob([fileContent]).size,
599
+ })
600
+ }
601
+ fileContentOriginRef.current = 'disk'
602
+ setExternalFileChangePending(false)
603
+ setFileSaveState('saved')
604
+ window.setTimeout(() => setFileSaveState('idle'), 1200)
605
+ } catch (error) {
606
+ setFileError(getErrorMessage(error))
607
+ setFileSaveState('idle')
608
+ }
609
+ }
610
+
611
+ const isFileDirty =
612
+ view === 'file' && projectFile?.content !== fileContent
613
+ const normalizedFilePath = filePath?.replace(/\\/g, '/') ?? ''
614
+ const canRegisterFile =
615
+ view === 'file' &&
616
+ Boolean(projectFile) &&
617
+ normalizedFilePath.startsWith(`${projectConfig.paths.sourceDir}/`) &&
618
+ /\.(jsx|tsx)$/i.test(normalizedFilePath) &&
619
+ !pages.some((page) => getPageSourcePath(page) === normalizedFilePath) &&
620
+ !customComponents.some(
621
+ (component) => component.importPath === normalizedFilePath
622
+ )
623
+
624
+ const openRegistration = () => {
625
+ const name = derivePageName(normalizedFilePath)
626
+
627
+ setRegistrationKind('page')
628
+ setRegisterName(name)
629
+ setRegisterRoute(derivePageRoute(name))
630
+ setRegisterError(null)
631
+ setRegistrationOpen(true)
632
+ }
633
+
634
+ const registerFileAsPage = async () => {
635
+ const name = registerName.trim()
636
+ const routeInput = registerRoute.trim()
637
+ const route = routeInput.startsWith('/') ? routeInput : `/${routeInput}`
638
+
639
+ if (!filePath || !name || !routeInput) {
640
+ setRegisterError('Enter a page name and route')
641
+ return
642
+ }
643
+
644
+ if (
645
+ pages.some((page) => page.name.toLowerCase() === name.toLowerCase())
646
+ ) {
647
+ setRegisterError('Page name already exists')
648
+ return
649
+ }
650
+
651
+ if (pages.some((page) => page.route === route)) {
652
+ setRegisterError('Route already exists')
653
+ return
654
+ }
655
+
656
+ setRegisterBusy(true)
657
+ setRegisterError(null)
658
+
659
+ try {
660
+ if (isFileDirty) {
661
+ await saveProjectFile(filePath, fileContent)
662
+ setProjectFile((current) =>
663
+ current
664
+ ? {
665
+ ...current,
666
+ content: fileContent,
667
+ size: new Blob([fileContent]).size,
668
+ }
669
+ : current
670
+ )
671
+ }
672
+
673
+ const page = await importProjectPage(filePath, name, route)
674
+ registerPage(page)
675
+ setActivePage(page.id)
676
+ await saveProject()
677
+ onViewChange('page')
678
+ onPageRegistered?.(page)
679
+ setRegistrationOpen(false)
680
+ } catch (error) {
681
+ setRegisterError(getErrorMessage(error))
682
+ } finally {
683
+ setRegisterBusy(false)
684
+ }
685
+ }
686
+
687
+ const registerFileAsComponent = async () => {
688
+ if (!filePath) return
689
+
690
+ setRegisterBusy(true)
691
+ setRegisterError(null)
692
+
693
+ try {
694
+ if (isFileDirty) {
695
+ await saveProjectFile(filePath, fileContent)
696
+ setProjectFile((current) =>
697
+ current
698
+ ? {
699
+ ...current,
700
+ content: fileContent,
701
+ size: new Blob([fileContent]).size,
702
+ }
703
+ : current
704
+ )
705
+ }
706
+
707
+ const definition = await registerProjectComponent(filePath)
708
+ registerCustomComponent(definition)
709
+ setRegistrationOpen(false)
710
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
711
+ } catch (error) {
712
+ setRegisterError(getErrorMessage(error))
713
+ } finally {
714
+ setRegisterBusy(false)
715
+ }
716
+ }
717
+
718
+ const panelContent = (
719
+ <>
720
+ <div className="h-10 px-3 border-b border-white/[0.06] flex items-center justify-between gap-3">
721
+ <div className="min-w-0">
722
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
723
+ Code
724
+ </p>
725
+ <p className="text-[10px] text-gray-400 truncate">
726
+ {generated.filename}
727
+ </p>
728
+ </div>
729
+ <div className="flex shrink-0 items-center gap-1">
730
+ {!workspaceOpen && (
731
+ <button
732
+ type="button"
733
+ onClick={() => setWorkspaceOpen(true)}
734
+ className="cursor-pointer rounded bg-white/[0.06] px-2 py-1 text-[10px] font-medium text-gray-300 transition-colors hover:bg-white/[0.1] hover:text-white"
735
+ title="Open a larger code workspace"
736
+ >
737
+ Open editor
738
+ </button>
739
+ )}
740
+ {canRegisterFile && (
741
+ <button
742
+ type="button"
743
+ onClick={openRegistration}
744
+ className="cursor-pointer rounded bg-emerald-500/15 px-2 py-1 text-[10px] font-medium text-emerald-300 transition-colors hover:bg-emerald-500/25 hover:text-emerald-200"
745
+ >
746
+ Register
747
+ </button>
748
+ )}
749
+ {view === 'file' && externalFileChangePending && (
750
+ <button
751
+ type="button"
752
+ onClick={() => {
753
+ setExternalFileChangePending(false)
754
+ setFileReloadVersion((version) => version + 1)
755
+ }}
756
+ className="cursor-pointer rounded bg-amber-500/15 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/25 hover:text-amber-200"
757
+ title="Discard unsaved editor text and load the external file version"
758
+ >
759
+ Reload disk
760
+ </button>
761
+ )}
762
+ {view === 'file' && projectFile && !projectFile.isBinary && !projectFile.isTooLarge && (
763
+ <button
764
+ type="button"
765
+ onClick={() => void saveFile()}
766
+ disabled={!isFileDirty || fileSaveState === 'saving'}
767
+ className="cursor-pointer rounded bg-blue-500 px-2 py-1 text-[10px] font-medium text-white transition-colors hover:bg-blue-400 disabled:cursor-default disabled:bg-white/[0.06] disabled:text-gray-500"
768
+ >
769
+ {fileSaveState === 'saving'
770
+ ? 'Saving...'
771
+ : fileSaveState === 'saved'
772
+ ? 'Saved'
773
+ : 'Save file'}
774
+ </button>
775
+ )}
776
+ <button
777
+ type="button"
778
+ onClick={copyCode}
779
+ className="px-2 py-1 text-[10px] text-gray-300 bg-white/[0.06] hover:bg-white/[0.1] rounded transition-colors cursor-pointer select-none"
780
+ >
781
+ {copied ? 'Copied' : 'Copy'}
782
+ </button>
783
+ </div>
784
+ </div>
785
+
786
+ <div className="px-2 py-2 border-b border-white/[0.06] flex flex-wrap gap-1">
787
+ <CodeTab active={view === 'page'} onClick={() => onViewChange('page')}>
788
+ Page
789
+ </CodeTab>
790
+ <CodeTab active={view === 'app'} onClick={() => onViewChange('app')}>
791
+ App
792
+ </CodeTab>
793
+ {(appShell.header || appShell.footer) && (
794
+ <CodeTab
795
+ active={view === 'layout'}
796
+ onClick={() => onViewChange('layout')}
797
+ >
798
+ Layout
799
+ </CodeTab>
800
+ )}
801
+ <CodeTab
802
+ active={view === 'schema'}
803
+ onClick={() => onViewChange('schema')}
804
+ >
805
+ JSON
806
+ </CodeTab>
807
+ {filePath && (
808
+ <CodeTab active={view === 'file'} onClick={() => onViewChange('file')}>
809
+ File{isFileDirty ? ' *' : ''}
810
+ </CodeTab>
811
+ )}
812
+ </div>
813
+
814
+ {view === 'file' ? (
815
+ <FileEditor
816
+ file={projectFile}
817
+ content={fileContent}
818
+ loading={fileLoading}
819
+ error={fileError}
820
+ onChange={(content) => {
821
+ fileContentOriginRef.current = 'user'
822
+ setFileContent(content)
823
+ }}
824
+ onSave={() => void saveFile()}
825
+ />
826
+ ) : (
827
+ <pre className="flex-1 overflow-auto p-4 text-[11px] leading-5 font-mono text-gray-200 bg-[#0b0d11]">
828
+ <code>{generated.code}</code>
829
+ </pre>
830
+ )}
831
+ {registrationOpen && (
832
+ <RegistrationDialog
833
+ kind={registrationKind}
834
+ componentName={derivePageName(normalizedFilePath)}
835
+ name={registerName}
836
+ route={registerRoute}
837
+ error={registerError}
838
+ busy={registerBusy}
839
+ onKindChange={(kind) => {
840
+ setRegistrationKind(kind)
841
+ setRegisterError(null)
842
+ }}
843
+ onNameChange={setRegisterName}
844
+ onRouteChange={setRegisterRoute}
845
+ onCancel={() => setRegistrationOpen(false)}
846
+ onConfirm={() =>
847
+ void (registrationKind === 'page'
848
+ ? registerFileAsPage()
849
+ : registerFileAsComponent())
850
+ }
851
+ />
852
+ )}
853
+ </>
854
+ )
855
+
856
+ const compactPanel = (
857
+ <aside
858
+ className={`${embedded ? 'h-full w-full' : 'w-[460px] border-l'} relative flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-[#101318]`}
859
+ onClick={() => setSelectedComponent(null)}
860
+ >
861
+ {panelContent}
862
+ </aside>
863
+ )
864
+
865
+ if (!workspaceOpen) {
866
+ return compactPanel
867
+ }
868
+
869
+ return createPortal(
870
+ <div
871
+ data-vb-editor-chrome
872
+ className="fixed inset-0 z-[140] flex bg-black/70 p-3 backdrop-blur-sm"
873
+ role="dialog"
874
+ aria-modal="true"
875
+ aria-label="VisualBuild code workspace"
876
+ >
877
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-white/10 bg-[#0b0d11] shadow-2xl">
878
+ <header className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.08] bg-[#15181e] px-4">
879
+ <div className="min-w-0">
880
+ <p className="text-xs font-semibold text-white">
881
+ VisualBuild Code Editor
882
+ </p>
883
+ <p className="truncate text-[10px] text-gray-500">
884
+ {generated.filename}
885
+ </p>
886
+ </div>
887
+ <button
888
+ type="button"
889
+ onClick={() => setWorkspaceOpen(false)}
890
+ className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
891
+ aria-label="Close code editor"
892
+ title="Close code editor"
893
+ >
894
+ x
895
+ </button>
896
+ </header>
897
+
898
+ <div className="flex min-h-0 flex-1 flex-col sm:flex-row">
899
+ <aside className="flex h-[38%] w-full shrink-0 flex-col overflow-hidden border-b border-white/[0.08] bg-[#101318] sm:h-auto sm:w-[300px] sm:border-r sm:border-b-0">
900
+ <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-3">
901
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
902
+ Project files
903
+ </p>
904
+ </div>
905
+ <div className="min-h-0 flex-1 overflow-hidden">
906
+ <ProjectExplorer
907
+ selectedPath={filePath ?? null}
908
+ onSelectFile={(path) => {
909
+ if (path) onWorkspaceSelectFile?.(path)
910
+ }}
911
+ />
912
+ </div>
913
+ </aside>
914
+
915
+ <section
916
+ className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-[#101318]"
917
+ onClick={() => setSelectedComponent(null)}
918
+ >
919
+ {panelContent}
920
+ </section>
921
+ </div>
922
+ </div>
923
+ </div>,
924
+ document.body
925
+ )
926
+ }
927
+
928
+ function RegistrationDialog({
929
+ kind,
930
+ componentName,
931
+ name,
932
+ route,
933
+ error,
934
+ busy,
935
+ onKindChange,
936
+ onNameChange,
937
+ onRouteChange,
938
+ onCancel,
939
+ onConfirm,
940
+ }: {
941
+ kind: 'page' | 'component'
942
+ componentName: string
943
+ name: string
944
+ route: string
945
+ error: string | null
946
+ busy: boolean
947
+ onKindChange: (kind: 'page' | 'component') => void
948
+ onNameChange: (value: string) => void
949
+ onRouteChange: (value: string) => void
950
+ onCancel: () => void
951
+ onConfirm: () => void
952
+ }) {
953
+ return (
954
+ <div data-vb-editor-chrome className="absolute inset-0 z-20 flex items-center justify-center bg-black/55 p-5 backdrop-blur-sm">
955
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#191d24] p-4 shadow-2xl">
956
+ <h2 className="text-sm font-semibold text-white">Register file</h2>
957
+ <p className="mt-1 text-[11px] leading-4 text-gray-400">
958
+ Add this React source file to VisualBuild as a page or component.
959
+ </p>
960
+ <label className="mt-4 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
961
+ Register as
962
+ <select
963
+ autoFocus
964
+ value={kind}
965
+ onChange={(event) =>
966
+ onKindChange(event.target.value as 'page' | 'component')
967
+ }
968
+ className="mt-1 w-full rounded border border-white/10 bg-[#11151b] px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
969
+ >
970
+ <option value="page">Page</option>
971
+ <option value="component">Component</option>
972
+ </select>
973
+ </label>
974
+ {kind === 'page' ? (
975
+ <>
976
+ <label className="mt-3 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
977
+ Page name
978
+ <input
979
+ value={name}
980
+ onChange={(event) => onNameChange(event.target.value)}
981
+ className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
982
+ />
983
+ </label>
984
+ <label className="mt-3 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
985
+ Route
986
+ <input
987
+ value={route}
988
+ onChange={(event) => onRouteChange(event.target.value)}
989
+ onKeyDown={(event) => {
990
+ if (event.key === 'Enter') onConfirm()
991
+ }}
992
+ className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
993
+ />
994
+ </label>
995
+ </>
996
+ ) : (
997
+ <div className="mt-3 rounded border border-white/[0.08] bg-black/20 px-3 py-2">
998
+ <p className="text-[10px] uppercase tracking-wider text-gray-500">
999
+ Component
1000
+ </p>
1001
+ <p className="mt-1 text-xs font-medium text-white">
1002
+ {componentName}
1003
+ </p>
1004
+ <p className="mt-1 text-[10px] text-gray-500">
1005
+ VisualBuild detects the exported component from the source file.
1006
+ </p>
1007
+ </div>
1008
+ )}
1009
+ {error && <p className="mt-3 text-xs text-red-300">{error}</p>}
1010
+ <div className="mt-4 flex justify-end gap-2">
1011
+ <button
1012
+ type="button"
1013
+ onClick={onCancel}
1014
+ disabled={busy}
1015
+ className="cursor-pointer rounded px-3 py-1.5 text-xs text-gray-400 hover:bg-white/[0.06] hover:text-white disabled:cursor-default"
1016
+ >
1017
+ Keep as file
1018
+ </button>
1019
+ <button
1020
+ type="button"
1021
+ onClick={onConfirm}
1022
+ disabled={busy}
1023
+ className="cursor-pointer rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-400 disabled:cursor-default disabled:bg-blue-500/40"
1024
+ >
1025
+ {busy
1026
+ ? 'Registering...'
1027
+ : kind === 'page'
1028
+ ? 'Register page'
1029
+ : 'Register component'}
1030
+ </button>
1031
+ </div>
1032
+ </div>
1033
+ </div>
1034
+ )
1035
+ }
1036
+
1037
+ function FileEditor({
1038
+ file,
1039
+ content,
1040
+ loading,
1041
+ error,
1042
+ onChange,
1043
+ onSave,
1044
+ }: {
1045
+ file: ProjectFile | null
1046
+ content: string
1047
+ loading: boolean
1048
+ error: string | null
1049
+ onChange: (content: string) => void
1050
+ onSave: () => void
1051
+ }) {
1052
+ if (loading) {
1053
+ return (
1054
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] text-xs text-gray-500">
1055
+ Opening file...
1056
+ </div>
1057
+ )
1058
+ }
1059
+
1060
+ if (error) {
1061
+ return (
1062
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-red-300">
1063
+ {error}
1064
+ </div>
1065
+ )
1066
+ }
1067
+
1068
+ if (file?.isBinary || file?.isTooLarge) {
1069
+ return (
1070
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-gray-400">
1071
+ {file.isBinary
1072
+ ? 'Binary files cannot be edited in the code panel.'
1073
+ : 'This file is larger than the 2 MB editor limit.'}
1074
+ </div>
1075
+ )
1076
+ }
1077
+
1078
+ return (
1079
+ <textarea
1080
+ value={content}
1081
+ onChange={(event) => onChange(event.target.value)}
1082
+ onKeyDown={(event) => {
1083
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
1084
+ event.preventDefault()
1085
+ onSave()
1086
+ }
1087
+ }}
1088
+ spellCheck={false}
1089
+ className="min-h-0 flex-1 resize-none overflow-auto bg-[#0b0d11] p-4 font-mono text-[11px] leading-5 text-gray-200 outline-none"
1090
+ aria-label="Project file editor"
1091
+ />
1092
+ )
1093
+ }
1094
+
1095
+ function CodeTab({
1096
+ active,
1097
+ children,
1098
+ onClick,
1099
+ }: {
1100
+ active: boolean
1101
+ children: React.ReactNode
1102
+ onClick: () => void
1103
+ }) {
1104
+ return (
1105
+ <button
1106
+ type="button"
1107
+ onClick={onClick}
1108
+ className={`px-2 py-1 text-[10px] rounded transition-colors cursor-pointer select-none ${
1109
+ active
1110
+ ? 'bg-accent text-white'
1111
+ : 'text-gray-500 hover:text-gray-200 hover:bg-white/[0.06]'
1112
+ }`}
1113
+ >
1114
+ {children}
1115
+ </button>
1116
+ )
1117
+ }
1118
+
1119
+ function getErrorMessage(error: unknown) {
1120
+ return error instanceof Error ? error.message : 'Project file operation failed'
1121
+ }
1122
+
1123
+ function derivePageName(filePath: string) {
1124
+ const filename = filePath.split('/').pop() ?? 'Page'
1125
+ const baseName = filename
1126
+ .replace(/\.[^.]+$/, '')
1127
+ .replace(/Page$/i, '')
1128
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1129
+ .replace(/[-_]+/g, ' ')
1130
+ .trim()
1131
+
1132
+ return baseName
1133
+ .split(/\s+/)
1134
+ .filter(Boolean)
1135
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1136
+ .join(' ') || 'Page'
1137
+ }
1138
+
1139
+ function derivePageRoute(name: string) {
1140
+ const slug = name
1141
+ .trim()
1142
+ .toLowerCase()
1143
+ .replace(/[^a-z0-9]+/g, '-')
1144
+ .replace(/^-+|-+$/g, '')
1145
+
1146
+ return slug === 'home' ? '/' : `/${slug || 'page'}`
1147
+ }