@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- package/widgets.config.json +132 -27
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rename Watcher
|
|
3
|
+
*
|
|
4
|
+
* Detects file and directory renames under watched directories and updates
|
|
5
|
+
* canvas embed URLs (prototype and canvas widgets) to stay current.
|
|
6
|
+
* Auto-commits the changes with a configurable prefix.
|
|
7
|
+
*
|
|
8
|
+
* Uses snapshot-based diffing: on any fs event, re-scans the watched
|
|
9
|
+
* directories and compares old vs new file sets. Only unambiguous renames
|
|
10
|
+
* (1:1 file or directory mappings) are acted on.
|
|
11
|
+
*
|
|
12
|
+
* Configuration is loaded from config.json in this directory.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { execFileSync } from 'node:child_process'
|
|
18
|
+
import { materializeFromText } from '../canvas/materializer.js'
|
|
19
|
+
|
|
20
|
+
// ─── Logging ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
23
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
24
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
25
|
+
|
|
26
|
+
function log(msg) { console.log(dim(` ◈ rename-watcher: ${msg}`)) }
|
|
27
|
+
function logSuccess(msg) { console.log(green(` ✓ rename-watcher: ${msg}`)) }
|
|
28
|
+
function logWarn(msg) { console.log(yellow(` ⚠ rename-watcher: ${msg}`)) }
|
|
29
|
+
|
|
30
|
+
// ─── Config ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
const configPath = new URL('./config.json', import.meta.url)
|
|
34
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── File scanning ───────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scan a watched directory and return a Set of relative file paths
|
|
41
|
+
* matching the configured extensions and exclusions.
|
|
42
|
+
*/
|
|
43
|
+
function scanDirectory(root, watchEntry, config) {
|
|
44
|
+
const results = new Set()
|
|
45
|
+
const absDir = path.join(root, watchEntry.path)
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(absDir)) return results
|
|
48
|
+
|
|
49
|
+
const excludeDirs = new Set(config.exclude.directories)
|
|
50
|
+
const excludePrefixes = config.exclude.filePrefixes
|
|
51
|
+
const extensions = watchEntry.extensions
|
|
52
|
+
|
|
53
|
+
function walk(dir, rel) {
|
|
54
|
+
let entries
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (excludeDirs.has(entry.name)) continue
|
|
59
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name
|
|
60
|
+
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
walk(path.join(dir, entry.name), relPath)
|
|
63
|
+
} else {
|
|
64
|
+
if (excludePrefixes.some((p) => entry.name.startsWith(p))) continue
|
|
65
|
+
if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
66
|
+
results.add(relPath)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
walk(absDir, '')
|
|
73
|
+
return results
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Route computation ───────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute the route path for a prototype file (relative to src/prototypes/).
|
|
80
|
+
* Mirrors the route regex in src/routes.jsx.
|
|
81
|
+
*/
|
|
82
|
+
function prototypeRoute(relPath) {
|
|
83
|
+
let route = relPath
|
|
84
|
+
.replace(/[^/]*\.folder\//g, '')
|
|
85
|
+
.replace(/\.(jsx|tsx|mdx)$/, '')
|
|
86
|
+
.replace(/\/index$/, '')
|
|
87
|
+
|
|
88
|
+
if (!route.startsWith('/')) route = '/' + route
|
|
89
|
+
return route || '/'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute the route path for a canvas file (relative to src/canvas/).
|
|
94
|
+
* Mirrors the data-plugin.js route inference.
|
|
95
|
+
*/
|
|
96
|
+
function canvasRoute(relPath) {
|
|
97
|
+
const name = path.basename(relPath).replace(/\.canvas\.jsonl$/, '')
|
|
98
|
+
const dirPath = path.dirname(relPath)
|
|
99
|
+
const routeBase = (dirPath === '.' ? '' : dirPath + '/')
|
|
100
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
101
|
+
.replace(/\/$/, '')
|
|
102
|
+
|
|
103
|
+
let route = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
|
|
104
|
+
route = route.replace(/\/+/g, '/').replace(/\/$/, '')
|
|
105
|
+
return route || '/canvas'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function computeRoute(relPath, watchType) {
|
|
109
|
+
if (watchType === 'prototype') return prototypeRoute(relPath)
|
|
110
|
+
if (watchType === 'canvas') return canvasRoute(relPath)
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Rename detection ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get compound extension (e.g. '.canvas.jsonl' not just '.jsonl').
|
|
118
|
+
*/
|
|
119
|
+
function getCompoundExt(filePath) {
|
|
120
|
+
const base = path.basename(filePath)
|
|
121
|
+
const parts = base.split('.')
|
|
122
|
+
if (parts.length >= 3) return '.' + parts.slice(-2).join('.')
|
|
123
|
+
return path.extname(filePath)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function groupByDir(paths) {
|
|
127
|
+
const groups = new Map()
|
|
128
|
+
for (const p of paths) {
|
|
129
|
+
const dir = path.dirname(p)
|
|
130
|
+
if (!groups.has(dir)) groups.set(dir, [])
|
|
131
|
+
groups.get(dir).push(p)
|
|
132
|
+
}
|
|
133
|
+
return groups
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find the longest common directory prefix of an array of paths.
|
|
138
|
+
*/
|
|
139
|
+
function commonDirPrefix(paths) {
|
|
140
|
+
if (paths.length === 0) return ''
|
|
141
|
+
if (paths.length === 1) {
|
|
142
|
+
const dir = path.dirname(paths[0])
|
|
143
|
+
return dir === '.' ? '' : dir + '/'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sorted = [...paths].sort()
|
|
147
|
+
const first = sorted[0]
|
|
148
|
+
const last = sorted[sorted.length - 1]
|
|
149
|
+
|
|
150
|
+
let i = 0
|
|
151
|
+
while (i < first.length && i < last.length && first[i] === last[i]) i++
|
|
152
|
+
|
|
153
|
+
const prefix = first.slice(0, i)
|
|
154
|
+
const lastSlash = prefix.lastIndexOf('/')
|
|
155
|
+
return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Detect renames by diffing old and new file snapshots.
|
|
160
|
+
* Only returns unambiguous renames to avoid false positives.
|
|
161
|
+
*
|
|
162
|
+
* Phase 1 — File renames: same directory, same extension, exactly 1 removed + 1 added.
|
|
163
|
+
* Phase 2 — Directory renames: a single directory-prefix swap explains all remaining changes.
|
|
164
|
+
*/
|
|
165
|
+
function detectRenames(oldSnapshot, newSnapshot, watchType) {
|
|
166
|
+
const removed = [...oldSnapshot].filter((f) => !newSnapshot.has(f))
|
|
167
|
+
const added = [...newSnapshot].filter((f) => !oldSnapshot.has(f))
|
|
168
|
+
|
|
169
|
+
if (removed.length === 0 || added.length === 0) return []
|
|
170
|
+
|
|
171
|
+
const renames = []
|
|
172
|
+
const matchedRemoved = new Set()
|
|
173
|
+
const matchedAdded = new Set()
|
|
174
|
+
|
|
175
|
+
// Phase 1: File-level renames
|
|
176
|
+
const removedByDir = groupByDir(removed)
|
|
177
|
+
const addedByDir = groupByDir(added)
|
|
178
|
+
|
|
179
|
+
for (const [dir, dirRemoved] of removedByDir) {
|
|
180
|
+
const dirAdded = addedByDir.get(dir) || []
|
|
181
|
+
if (dirRemoved.length !== 1 || dirAdded.length !== 1) continue
|
|
182
|
+
if (getCompoundExt(dirRemoved[0]) !== getCompoundExt(dirAdded[0])) continue
|
|
183
|
+
|
|
184
|
+
const oldRoute = computeRoute(dirRemoved[0], watchType)
|
|
185
|
+
const newRoute = computeRoute(dirAdded[0], watchType)
|
|
186
|
+
|
|
187
|
+
if (oldRoute === newRoute) continue
|
|
188
|
+
|
|
189
|
+
renames.push({ oldPath: dirRemoved[0], newPath: dirAdded[0], oldRoute, newRoute })
|
|
190
|
+
matchedRemoved.add(dirRemoved[0])
|
|
191
|
+
matchedAdded.add(dirAdded[0])
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Phase 2: Directory-level renames
|
|
195
|
+
const unmatchedRemoved = removed.filter((f) => !matchedRemoved.has(f))
|
|
196
|
+
const unmatchedAdded = added.filter((f) => !matchedAdded.has(f))
|
|
197
|
+
|
|
198
|
+
if (unmatchedRemoved.length > 0 && unmatchedRemoved.length === unmatchedAdded.length) {
|
|
199
|
+
const oldPrefix = commonDirPrefix(unmatchedRemoved)
|
|
200
|
+
const newPrefix = commonDirPrefix(unmatchedAdded)
|
|
201
|
+
|
|
202
|
+
if (oldPrefix && newPrefix && oldPrefix !== newPrefix) {
|
|
203
|
+
const expectedAdded = new Set(
|
|
204
|
+
unmatchedRemoved.map((f) => newPrefix + f.slice(oldPrefix.length)),
|
|
205
|
+
)
|
|
206
|
+
const allMatch = unmatchedAdded.every((f) => expectedAdded.has(f))
|
|
207
|
+
|
|
208
|
+
if (allMatch) {
|
|
209
|
+
for (const oldFile of unmatchedRemoved) {
|
|
210
|
+
const newFile = newPrefix + oldFile.slice(oldPrefix.length)
|
|
211
|
+
const oldRoute = computeRoute(oldFile, watchType)
|
|
212
|
+
const newRoute = computeRoute(newFile, watchType)
|
|
213
|
+
if (oldRoute !== newRoute) {
|
|
214
|
+
renames.push({ oldPath: oldFile, newPath: newFile, oldRoute, newRoute })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return renames
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Canvas embed updating ───────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Find all .canvas.jsonl files in the project.
|
|
228
|
+
*/
|
|
229
|
+
function findAllCanvasFiles(root) {
|
|
230
|
+
const results = []
|
|
231
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
232
|
+
|
|
233
|
+
function walk(dir) {
|
|
234
|
+
let entries
|
|
235
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
if (ignore.has(entry.name)) continue
|
|
238
|
+
const fullPath = path.join(dir, entry.name)
|
|
239
|
+
if (entry.isDirectory()) walk(fullPath)
|
|
240
|
+
else if (entry.name.endsWith('.canvas.jsonl')) results.push(fullPath)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
walk(root)
|
|
245
|
+
return results
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Rewrite a URL's pathname if it matches a renamed route.
|
|
250
|
+
* Uses segment-boundary matching to avoid partial matches (e.g. /Signup vs /Signup2).
|
|
251
|
+
*/
|
|
252
|
+
function rewriteUrl(src, renames) {
|
|
253
|
+
const hashIdx = src.indexOf('#')
|
|
254
|
+
const queryIdx = src.indexOf('?')
|
|
255
|
+
|
|
256
|
+
let pathname, suffix
|
|
257
|
+
if (hashIdx >= 0 && (queryIdx < 0 || hashIdx < queryIdx)) {
|
|
258
|
+
pathname = src.slice(0, hashIdx)
|
|
259
|
+
suffix = src.slice(hashIdx)
|
|
260
|
+
} else if (queryIdx >= 0) {
|
|
261
|
+
pathname = src.slice(0, queryIdx)
|
|
262
|
+
suffix = src.slice(queryIdx)
|
|
263
|
+
} else {
|
|
264
|
+
pathname = src
|
|
265
|
+
suffix = ''
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const { oldRoute, newRoute } of renames) {
|
|
269
|
+
if (!oldRoute || !newRoute) continue
|
|
270
|
+
|
|
271
|
+
// Exact match
|
|
272
|
+
if (pathname === oldRoute) return newRoute + suffix
|
|
273
|
+
// Segment-boundary prefix match
|
|
274
|
+
if (pathname.startsWith(oldRoute + '/')) {
|
|
275
|
+
return newRoute + pathname.slice(oldRoute.length) + suffix
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return src
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Update widget embed refs for detected renames.
|
|
284
|
+
* Returns the updated widgets array if any changed, null if unchanged.
|
|
285
|
+
*/
|
|
286
|
+
function updateWidgetRefs(widgets, renames) {
|
|
287
|
+
let changed = false
|
|
288
|
+
|
|
289
|
+
const updated = widgets.map((widget) => {
|
|
290
|
+
// Only process embed widget types with a src prop
|
|
291
|
+
if (widget.type !== 'prototype' && widget.type !== 'canvas') return widget
|
|
292
|
+
|
|
293
|
+
const src = widget.props?.src
|
|
294
|
+
if (!src || typeof src !== 'string') return widget
|
|
295
|
+
|
|
296
|
+
const newSrc = rewriteUrl(src, renames)
|
|
297
|
+
if (newSrc === src) return widget
|
|
298
|
+
|
|
299
|
+
changed = true
|
|
300
|
+
return { ...widget, props: { ...widget.props, src: newSrc } }
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return changed ? updated : null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Auto-commit ─────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
// Lock file placed in .git/ during commit. Both the rename watcher and
|
|
309
|
+
// autosync share the same working tree and git index, so this file
|
|
310
|
+
// lets autosync's isRepoBusy() (or any future tool) detect that the
|
|
311
|
+
// rename watcher is mid-commit and defer its own cycle.
|
|
312
|
+
const LOCK_FILENAME = 'storyboard-autofix.lock'
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolve the .git directory, handling both regular repos and worktrees.
|
|
316
|
+
* In a worktree, `.git` is a file pointing to the real git dir.
|
|
317
|
+
*/
|
|
318
|
+
function resolveGitDir(root) {
|
|
319
|
+
const dotGit = path.join(root, '.git')
|
|
320
|
+
try {
|
|
321
|
+
const stat = fs.statSync(dotGit)
|
|
322
|
+
if (stat.isDirectory()) return dotGit
|
|
323
|
+
// Worktree: .git is a file like "gitdir: /path/to/.git/worktrees/name"
|
|
324
|
+
const content = fs.readFileSync(dotGit, 'utf-8').trim()
|
|
325
|
+
const match = content.match(/^gitdir:\s*(.+)$/)
|
|
326
|
+
if (match) return path.resolve(root, match[1])
|
|
327
|
+
} catch { /* fallback */ }
|
|
328
|
+
return dotGit
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check if the repo is in a busy state that would conflict with a commit.
|
|
333
|
+
*
|
|
334
|
+
* Mirrors autosync's isRepoBusy() guards so both systems respect the same
|
|
335
|
+
* signals: index.lock, rebase, merge, cherry-pick, and the autofix lock file.
|
|
336
|
+
* Since both autosync and the rename watcher commit directly on the current
|
|
337
|
+
* branch (same working tree, same index), index.lock is the primary mutex.
|
|
338
|
+
*/
|
|
339
|
+
function isRepoBusy(root) {
|
|
340
|
+
const gitDir = resolveGitDir(root)
|
|
341
|
+
|
|
342
|
+
if (fs.existsSync(path.join(gitDir, 'index.lock'))) {
|
|
343
|
+
logWarn('Auto-commit deferred: index.lock present')
|
|
344
|
+
return true
|
|
345
|
+
}
|
|
346
|
+
if (fs.existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
|
|
347
|
+
logWarn('Auto-commit deferred: merge in progress')
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
if (fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
|
|
351
|
+
logWarn('Auto-commit deferred: cherry-pick in progress')
|
|
352
|
+
return true
|
|
353
|
+
}
|
|
354
|
+
if (fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'))) {
|
|
355
|
+
logWarn('Auto-commit deferred: rebase in progress')
|
|
356
|
+
return true
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check for our own stale lock (crash recovery)
|
|
360
|
+
const lockPath = path.join(gitDir, LOCK_FILENAME)
|
|
361
|
+
if (fs.existsSync(lockPath)) {
|
|
362
|
+
try {
|
|
363
|
+
const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs
|
|
364
|
+
if (lockAge < 10_000) {
|
|
365
|
+
logWarn('Auto-commit deferred: previous autofix still in progress')
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
// Stale lock (>10s) — remove it
|
|
369
|
+
fs.unlinkSync(lockPath)
|
|
370
|
+
} catch { /* race — fine */ }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Auto-commit modified canvas files using git commit --only to isolate
|
|
378
|
+
* from any other staged or unstaged user work.
|
|
379
|
+
*
|
|
380
|
+
* Coordinates with autosync (which shares the same working tree + index) via:
|
|
381
|
+
* - Lock file (.git/storyboard-autofix.lock) to signal mid-commit
|
|
382
|
+
* - Same repo-busy guards autosync checks (index.lock, merge, rebase, cherry-pick)
|
|
383
|
+
* - git commit --only uses a temporary index, so it won't interfere with
|
|
384
|
+
* files autosync may have staged independently
|
|
385
|
+
*
|
|
386
|
+
* If the commit is deferred (busy repo), the canvas JSONL update is still
|
|
387
|
+
* persisted on disk — autosync's next cycle will pick it up naturally.
|
|
388
|
+
*/
|
|
389
|
+
function autocommit(root, modifiedFiles, renames, config) {
|
|
390
|
+
if (!config.autocommit.enabled || modifiedFiles.length === 0) return
|
|
391
|
+
|
|
392
|
+
const relPaths = modifiedFiles.map((f) => path.relative(root, f))
|
|
393
|
+
|
|
394
|
+
if (isRepoBusy(root)) return
|
|
395
|
+
|
|
396
|
+
const gitDir = resolveGitDir(root)
|
|
397
|
+
const lockPath = path.join(gitDir, LOCK_FILENAME)
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// Acquire lock so autosync (or other tools) can defer
|
|
401
|
+
fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`, 'utf-8')
|
|
402
|
+
|
|
403
|
+
execFileSync('git', ['add', '--', ...relPaths], { cwd: root, stdio: 'pipe' })
|
|
404
|
+
|
|
405
|
+
const summary = [...new Set(
|
|
406
|
+
renames
|
|
407
|
+
.filter((r) => r.oldRoute && r.newRoute)
|
|
408
|
+
.map((r) => `${r.oldRoute} → ${r.newRoute}`),
|
|
409
|
+
)].join(', ')
|
|
410
|
+
|
|
411
|
+
const message = `${config.autocommit.prefix} Update embed URLs: ${summary}`
|
|
412
|
+
|
|
413
|
+
execFileSync('git', ['commit', '--only', '--', ...relPaths, '-m', message], {
|
|
414
|
+
cwd: root,
|
|
415
|
+
stdio: 'pipe',
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
logSuccess(`Auto-committed: ${summary}`)
|
|
419
|
+
} catch (err) {
|
|
420
|
+
logWarn(`Auto-commit skipped: ${(err.message || '').split('\n')[0]}`)
|
|
421
|
+
} finally {
|
|
422
|
+
// Release lock
|
|
423
|
+
try { fs.unlinkSync(lockPath) } catch { /* already gone */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Process a detected change — rescan, detect renames, update embeds, commit.
|
|
431
|
+
*/
|
|
432
|
+
function processChange(root, snapshots, config) {
|
|
433
|
+
const allRenames = []
|
|
434
|
+
|
|
435
|
+
for (const entry of config.watch) {
|
|
436
|
+
const oldSnapshot = snapshots.get(entry.path)
|
|
437
|
+
const newSnapshot = scanDirectory(root, entry, config)
|
|
438
|
+
const renames = detectRenames(oldSnapshot, newSnapshot, entry.type)
|
|
439
|
+
if (renames.length > 0) allRenames.push(...renames)
|
|
440
|
+
snapshots.set(entry.path, newSnapshot)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (allRenames.length === 0) return
|
|
444
|
+
|
|
445
|
+
// Deduplicate by route pair
|
|
446
|
+
const uniqueRenames = []
|
|
447
|
+
const seen = new Set()
|
|
448
|
+
for (const r of allRenames) {
|
|
449
|
+
const key = `${r.oldRoute}→${r.newRoute}`
|
|
450
|
+
if (!seen.has(key)) {
|
|
451
|
+
seen.add(key)
|
|
452
|
+
uniqueRenames.push(r)
|
|
453
|
+
log(`Detected: ${r.oldRoute} → ${r.newRoute}`)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Scan all canvas files and update embed references
|
|
458
|
+
const canvasFiles = findAllCanvasFiles(root)
|
|
459
|
+
const modifiedFiles = []
|
|
460
|
+
|
|
461
|
+
for (const canvasFile of canvasFiles) {
|
|
462
|
+
try {
|
|
463
|
+
const text = fs.readFileSync(canvasFile, 'utf-8')
|
|
464
|
+
const state = materializeFromText(text)
|
|
465
|
+
|
|
466
|
+
if (!state.widgets || state.widgets.length === 0) continue
|
|
467
|
+
|
|
468
|
+
const updatedWidgets = updateWidgetRefs(state.widgets, uniqueRenames)
|
|
469
|
+
if (updatedWidgets) {
|
|
470
|
+
const event = {
|
|
471
|
+
event: 'widgets_replaced',
|
|
472
|
+
timestamp: new Date().toISOString(),
|
|
473
|
+
widgets: updatedWidgets,
|
|
474
|
+
}
|
|
475
|
+
fs.appendFileSync(canvasFile, JSON.stringify(event) + '\n', 'utf-8')
|
|
476
|
+
modifiedFiles.push(canvasFile)
|
|
477
|
+
log(`Updated embeds in ${path.relative(root, canvasFile)}`)
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
logWarn(`Failed to update ${path.basename(canvasFile)}: ${err.message}`)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
autocommit(root, modifiedFiles, uniqueRenames, config)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Start the rename watcher.
|
|
489
|
+
* @param {string} root — project root directory
|
|
490
|
+
* @returns {{ close: () => void }}
|
|
491
|
+
*/
|
|
492
|
+
export function startRenameWatcher(root) {
|
|
493
|
+
const config = loadConfig()
|
|
494
|
+
const watchers = []
|
|
495
|
+
const snapshots = new Map()
|
|
496
|
+
|
|
497
|
+
for (const entry of config.watch) {
|
|
498
|
+
snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let debounceTimer = null
|
|
502
|
+
|
|
503
|
+
function handleChange() {
|
|
504
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
505
|
+
debounceTimer = setTimeout(() => {
|
|
506
|
+
try {
|
|
507
|
+
processChange(root, snapshots, config)
|
|
508
|
+
} catch (err) {
|
|
509
|
+
logWarn(`Error: ${err.message}`)
|
|
510
|
+
}
|
|
511
|
+
}, config.debounceMs)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const entry of config.watch) {
|
|
515
|
+
const absDir = path.join(root, entry.path)
|
|
516
|
+
if (!fs.existsSync(absDir)) {
|
|
517
|
+
logWarn(`Skipping ${entry.path} (not found)`)
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const watcher = fs.watch(absDir, { recursive: true }, handleChange)
|
|
523
|
+
watcher.on('error', (err) => logWarn(`${entry.path}: ${err.message}`))
|
|
524
|
+
watchers.push(watcher)
|
|
525
|
+
} catch (err) {
|
|
526
|
+
logWarn(`Could not watch ${entry.path}: ${err.message}`)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
log('Active')
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
close() {
|
|
534
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
535
|
+
for (const w of watchers) w.close()
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
}
|
|
@@ -183,16 +183,14 @@
|
|
|
183
183
|
return `<svg viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="320" height="200" fill="var(--sb--placeholder-bg)" />${lines}${rects}</svg>`
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// Branch switching
|
|
186
|
+
// Branch switching — populated by Vite server plugin when available
|
|
187
187
|
interface Branch { branch: string; folder: string }
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let branches: Branch[] | null = $state(null)
|
|
189
|
+
let branches: Branch[] | null = $state(
|
|
190
|
+
(typeof window !== 'undefined' && Array.isArray((window as any).__SB_BRANCHES__))
|
|
191
|
+
? (window as any).__SB_BRANCHES__
|
|
192
|
+
: null
|
|
193
|
+
)
|
|
196
194
|
|
|
197
195
|
const branchBasePath = $derived(
|
|
198
196
|
(basePath || '/storyboard-source/').replace(/\/branch--[^/]*\/$/, '/')
|
|
@@ -205,15 +203,6 @@
|
|
|
205
203
|
})()
|
|
206
204
|
)
|
|
207
205
|
|
|
208
|
-
$effect(() => {
|
|
209
|
-
fetch(`${branchBasePath}branches.json`)
|
|
210
|
-
.then(r => r.ok ? r.json() : null)
|
|
211
|
-
.then((data: any) => {
|
|
212
|
-
branches = Array.isArray(data) && data.length > 0 ? data : MOCK_BRANCHES
|
|
213
|
-
})
|
|
214
|
-
.catch(() => { branches = MOCK_BRANCHES })
|
|
215
|
-
})
|
|
216
|
-
|
|
217
206
|
function handleBranchChange(e: Event) {
|
|
218
207
|
const folder = (e.target as HTMLSelectElement).value
|
|
219
208
|
if (folder) {
|
|
@@ -45,18 +45,17 @@ export async function handler(ctx) {
|
|
|
45
45
|
|
|
46
46
|
return flows.map(f => {
|
|
47
47
|
const meta = vf.getFlowMeta(f.key)
|
|
48
|
+
let url = vf.resolveFlowRoute(f.key)
|
|
49
|
+
// Re-apply basePath and branch-- prefix so deployed branch builds stay on the correct path
|
|
50
|
+
const prefix = (base || '') + (branchSegment ? `/${branchSegment}` : '')
|
|
51
|
+
if (prefix) url = prefix + url
|
|
48
52
|
return {
|
|
49
53
|
id: f.key,
|
|
50
54
|
label: meta?.title || f.name,
|
|
51
55
|
type: 'radio',
|
|
52
56
|
active: f.key === active,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// Re-apply basePath and branch-- prefix so deployed branch builds stay on the correct path
|
|
56
|
-
const prefix = (base || '') + (branchSegment ? `/${branchSegment}` : '')
|
|
57
|
-
if (prefix) url = prefix + url
|
|
58
|
-
window.location.href = url
|
|
59
|
-
},
|
|
57
|
+
href: url,
|
|
58
|
+
execute: () => { window.location.href = url },
|
|
60
59
|
}
|
|
61
60
|
})
|
|
62
61
|
},
|
package/src/viewfinder.js
CHANGED
|
@@ -114,7 +114,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
114
114
|
icon: meta.icon || null,
|
|
115
115
|
team: meta.team || null,
|
|
116
116
|
tags: meta.tags || null,
|
|
117
|
-
hideFlows: meta.hideFlows ?? raw?.hideFlows ??
|
|
117
|
+
hideFlows: meta.hideFlows ?? raw?.hideFlows ?? true,
|
|
118
118
|
folder: raw?.folder || null,
|
|
119
119
|
isExternal,
|
|
120
120
|
externalUrl: isExternal ? raw.url : null,
|
|
@@ -139,7 +139,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
139
139
|
icon: null,
|
|
140
140
|
team: null,
|
|
141
141
|
tags: null,
|
|
142
|
-
hideFlows:
|
|
142
|
+
hideFlows: true,
|
|
143
143
|
folder: null,
|
|
144
144
|
isExternal: false,
|
|
145
145
|
externalUrl: null,
|
|
@@ -199,21 +199,33 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
199
199
|
const folders = Object.values(folderMap)
|
|
200
200
|
const prototypes = ungrouped
|
|
201
201
|
|
|
202
|
-
// Build canvas entries
|
|
202
|
+
// Build canvas entries — collapse grouped pages into a single entry per group
|
|
203
203
|
const canvasEntries = []
|
|
204
|
+
const seenGroups = new Map() // group name → index in canvasEntries
|
|
204
205
|
for (const canvasName of listCanvases()) {
|
|
205
206
|
const data = getCanvasData(canvasName)
|
|
206
207
|
if (!data) continue
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
const meta = data._canvasMeta
|
|
209
|
+
const group = data._group || null
|
|
210
|
+
|
|
211
|
+
// If this canvas belongs to a group we've already seen, skip it
|
|
212
|
+
// (the first page in the group represents the whole canvas)
|
|
213
|
+
if (group && seenGroups.has(group)) continue
|
|
214
|
+
|
|
215
|
+
const entry = {
|
|
216
|
+
name: meta?.title || data.title || canvasName,
|
|
209
217
|
dirName: canvasName,
|
|
210
|
-
description: data.description || null,
|
|
211
|
-
route: data._route ||
|
|
218
|
+
description: meta?.description || data.description || null,
|
|
219
|
+
route: data._route || `/canvas/${canvasName}`,
|
|
212
220
|
folder: data._folder || null,
|
|
213
221
|
isCanvas: true,
|
|
214
|
-
author: data.author || null,
|
|
222
|
+
author: meta?.author || data.author || null,
|
|
215
223
|
gitAuthor: data.gitAuthor || null,
|
|
216
|
-
|
|
224
|
+
_canvasMeta: meta || null,
|
|
225
|
+
_group: group,
|
|
226
|
+
}
|
|
227
|
+
if (group) seenGroups.set(group, canvasEntries.length)
|
|
228
|
+
canvasEntries.push(entry)
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
// Add canvases to their folders or to ungrouped
|
package/src/viewfinder.test.js
CHANGED
|
@@ -197,7 +197,7 @@ describe('buildPrototypeIndex', () => {
|
|
|
197
197
|
expect(proto.flows).toHaveLength(1)
|
|
198
198
|
})
|
|
199
199
|
|
|
200
|
-
it('defaults hideFlows to
|
|
200
|
+
it('defaults hideFlows to true when not set', () => {
|
|
201
201
|
init({
|
|
202
202
|
flows: { 'Other/flow-a': { meta: { title: 'A' } } },
|
|
203
203
|
objects: {},
|
|
@@ -208,7 +208,7 @@ describe('buildPrototypeIndex', () => {
|
|
|
208
208
|
})
|
|
209
209
|
const { prototypes } = buildPrototypeIndex([])
|
|
210
210
|
const proto = prototypes.find(p => p.dirName === 'Other')
|
|
211
|
-
expect(proto.hideFlows).toBe(
|
|
211
|
+
expect(proto.hideFlows).toBe(true)
|
|
212
212
|
})
|
|
213
213
|
|
|
214
214
|
it('reads hideFlows from top-level prototype metadata (outside meta key)', () => {
|