@dfosco/storyboard 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +14128 -10953
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/dist/tiny-canvas.css +1 -1
- package/package.json +138 -128
- package/scaffold/AGENTS.md +9 -7
- package/scaffold/README.md +18 -0
- package/scaffold/deploy.yml +21 -0
- package/scaffold/gitignore +8 -14
- package/scaffold/preview.yml +40 -0
- package/scaffold/{manifest.json → scaffold.config.json} +73 -3
- package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/scaffold/src/_prototype.jsx +17 -0
- package/scaffold/src/_story.jsx +13 -0
- package/scaffold/src/library/README.md +34 -0
- package/scaffold/src/library/_app.jsx +41 -0
- package/scaffold/src/library/_app.module.css +2 -0
- package/scaffold/src/library/create.jsx +10 -0
- package/scaffold/src/library/index.jsx +14 -0
- package/scaffold/src/library/mount.jsx +51 -0
- package/scaffold/src/library/prototypes-entry.jsx +57 -0
- package/scaffold/src/library/routes.jsx +205 -0
- package/scaffold/src/library/viewfinder.jsx +13 -0
- package/scaffold/src/library/workspace.jsx +17 -0
- package/scripts/aggregate-artifacts.mjs +257 -0
- package/src/canvas/style.css +16 -16
- package/src/core/artifact/operations.js +54 -15
- package/src/core/artifact/schemas/canvas.schema.json +7 -1
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/hot-pool.js +21 -33
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/selectedWidgets.js +1 -1
- package/src/core/canvas/server.js +376 -100
- package/src/core/canvas/server.test.js +472 -2
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/artifact.js +2 -0
- package/src/core/cli/buildManifest.js +34 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/create.js +151 -23
- package/src/core/cli/dev.js +23 -35
- package/src/core/cli/index.js +21 -0
- package/src/core/cli/schemas.js +8 -4
- package/src/core/cli/serverUrl.js +24 -0
- package/src/core/cli/setup.js +14 -25
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/cli/updateVersion.js +42 -2
- package/src/core/cli/workleaf.js +140 -0
- package/src/core/comments/api.js +1 -1
- package/src/core/comments/queries.js +3 -0
- package/src/core/comments/ui/CommentWindow.jsx +3 -2
- package/src/core/comments/ui/comment-layout.css +14 -14
- package/src/core/data/artifactIndex.js +235 -0
- package/src/core/data/artifactIndex.test.js +203 -0
- package/src/core/data/artifactManifest.js +290 -0
- package/src/core/data/artifactManifest.test.js +207 -0
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/index.js +24 -1
- package/src/core/inspector/highlighter.js +1 -1
- package/src/core/inspector/mouseMode.js +2 -2
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
- package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
- package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
- package/src/core/lib/components/ui/dialog/index.js +4 -34
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
- package/src/core/lib/components/ui/label/label.jsx +1 -2
- package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
- package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
- package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
- package/src/core/lib/components/ui/panel/panel.jsx +11 -2
- package/src/core/lib/components/ui/separator/separator.jsx +6 -4
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
- package/src/core/modes/modes.css +4 -4
- package/src/core/mountStoryboardCore.js +287 -83
- package/src/core/mountStoryboardCore.test.js +198 -0
- package/src/core/rename-watcher/config.json +1 -1
- package/src/core/rename-watcher/watcher.js +330 -37
- package/src/core/rename-watcher/watcher.test.js +253 -0
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +247 -0
- package/src/core/scaffold.js +4 -97
- package/src/core/stores/canvasInteractionStore.js +120 -0
- package/src/core/stores/canvasInteractionStore.test.js +126 -0
- package/src/core/stores/configSchema.js +64 -8
- package/src/core/stores/configSchema.test.js +3 -0
- package/src/core/stores/customerModeConfig.js +24 -3
- package/src/core/stores/customerModeConfig.test.js +46 -0
- package/src/core/stores/presentationStore.js +118 -0
- package/src/core/stores/presentationStore.test.js +114 -0
- package/src/core/stores/recentArtifacts.js +49 -2
- package/src/core/stores/recentArtifacts.test.js +59 -6
- package/src/core/stores/themeBootstrap.js +264 -0
- package/src/core/stores/themeBootstrap.test.js +211 -0
- package/src/core/stores/themeStore.test.js +154 -0
- package/src/core/stores/themeStore.ts +235 -220
- package/src/core/stores/uiConfig.js +10 -2
- package/src/core/styles/generateThemesCss.js +63 -0
- package/src/core/styles/tailwind.css +150 -94
- package/src/core/styles/workspace-chrome.css +13 -0
- package/src/core/tools/handlers/paletteTheme.js +11 -8
- package/src/core/ui/ActionMenuButton.jsx +2 -1
- package/src/core/ui/AutosyncMenuButton.css +7 -7
- package/src/core/ui/BranchSelect.module.css +5 -5
- package/src/core/ui/CanvasCreateMenu.jsx +1 -19
- package/src/core/ui/ChromeSlot.jsx +92 -0
- package/src/core/ui/ChromeSlot.test.jsx +140 -0
- package/src/core/ui/CoreUIBar.css +5 -5
- package/src/core/ui/CoreUIBar.jsx +170 -142
- package/src/core/ui/InspectorPanel.css +5 -5
- package/src/core/ui/InspectorPanel.jsx +9 -9
- package/src/core/ui/PwaInstallBanner.css +5 -5
- package/src/core/ui/PwaInstallBanner.jsx +5 -5
- package/src/core/ui/ThemeMenuButton.jsx +45 -30
- package/src/core/ui/core-ui-colors.css +28 -66
- package/src/core/ui/sidepanel.css +12 -12
- package/src/core/vite/server-plugin.js +13 -0
- package/src/core/workleaf/cleanup.js +102 -0
- package/src/core/workleaf/create.js +199 -0
- package/src/core/workleaf/delete.js +101 -0
- package/src/core/workleaf/list.js +57 -0
- package/src/core/workleaf/merge.js +238 -0
- package/src/core/workleaf/paths.js +146 -0
- package/src/core/workleaf/paths.test.js +230 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +130 -34
- package/src/core/workshop/features/createFlow/server.js +5 -1
- package/src/core/workshop/features/createPrototype/server.js +5 -1
- package/src/core/workshop/features/templateIndex.js +28 -1
- package/src/core/worktree/port.js +48 -5
- package/src/core/worktree/port.test.js +42 -0
- package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
- package/src/internals/ArtifactForm/ArtifactForm.module.css +51 -69
- package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
- package/src/internals/AuthModal/AuthModal.module.css +34 -34
- package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
- package/src/internals/BranchBar/BranchBar.module.css +4 -4
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +41 -29
- package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
- package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
- package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
- package/src/internals/CommandPalette/command-palette.css +14 -14
- package/src/internals/CommandPalette/createHelpers.js +30 -0
- package/src/internals/CreatePage/CreatePage.jsx +57 -0
- package/src/internals/CreatePage/CreatePage.module.css +34 -0
- package/src/internals/Dialog.jsx +51 -0
- package/src/internals/FlowError.module.css +5 -5
- package/src/internals/PrototypeErrorBoundary.module.css +15 -15
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +534 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
- package/src/internals/Tooltip.jsx +39 -0
- package/src/internals/Viewfinder.jsx +229 -64
- package/src/internals/Viewfinder.module.css +253 -182
- package/src/internals/canvas/CanvasControls.module.css +11 -11
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
- package/src/internals/canvas/CanvasPage.jsx +358 -217
- package/src/internals/canvas/CanvasPage.module.css +20 -19
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/CanvasToolbar.module.css +11 -11
- package/src/internals/canvas/ConnectorLayer.jsx +39 -2
- package/src/internals/canvas/ConnectorLayer.module.css +10 -10
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +41 -27
- package/src/internals/canvas/canvasApi.js +40 -5
- package/src/internals/canvas/canvasTheme.js +22 -111
- package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
- package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +140 -0
- package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
- package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
- package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
- package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
- package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
- package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
- package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
- package/src/internals/canvas/widgets/LinkPreview.module.css +79 -80
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
- package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
- package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +92 -17
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +118 -4
- package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
- package/src/internals/canvas/widgets/StorySetWidget.jsx +53 -25
- package/src/internals/canvas/widgets/StoryWidget.jsx +71 -56
- package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
- package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
- package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
- package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
- package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
- package/src/internals/canvas/widgets/WidgetChrome.module.css +76 -61
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
- package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
- package/src/internals/canvas/widgets/embedTheme.js +12 -104
- package/src/internals/canvas/widgets/expandUtils.js +19 -19
- package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
- package/src/internals/canvas/widgets/widgetConfig.js +5 -5
- package/src/internals/context.jsx +22 -1
- package/src/internals/context.test.jsx +47 -0
- package/src/internals/hooks/useArtifactIndex.js +31 -0
- package/src/internals/hooks/useArtifactIndex.test.js +45 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
- package/src/internals/index.js +6 -0
- package/src/internals/loadConsumerWrapper.js +60 -0
- package/src/internals/story/ComponentSetPage.jsx +75 -34
- package/src/internals/story/ComponentSetPage.module.css +14 -14
- package/src/internals/story/StoryPage.jsx +16 -25
- package/src/internals/story/StoryPage.module.css +2 -2
- package/src/internals/vite/data-plugin.js +317 -149
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
- package/terminal.config.json +1 -1
- package/src/core/lib/components/ui/card/card-action.jsx +0 -11
- package/src/core/lib/components/ui/card/card-content.jsx +0 -11
- package/src/core/lib/components/ui/card/card-description.jsx +0 -11
- package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
- package/src/core/lib/components/ui/card/card-header.jsx +0 -19
- package/src/core/lib/components/ui/card/card-title.jsx +0 -11
- package/src/core/lib/components/ui/card/card.jsx +0 -17
- package/src/core/lib/components/ui/card/index.js +0 -25
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/index.js +0 -13
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
- package/src/core/lib/components/ui/popover/index.js +0 -28
- package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
- package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
- package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
- package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
- package/src/core/lib/components/ui/popover/popover.jsx +0 -4
- package/src/core/lib/components/ui/select/index.js +0 -37
- package/src/core/lib/components/ui/select/select-content.jsx +0 -30
- package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
- package/src/core/lib/components/ui/select/select-group.jsx +0 -15
- package/src/core/lib/components/ui/select/select-item.jsx +0 -26
- package/src/core/lib/components/ui/select/select-label.jsx +0 -11
- package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
- package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
- package/src/core/lib/components/ui/select/select.jsx +0 -4
- package/src/core/lib/components/ui/sheet/index.js +0 -34
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
- package/src/core/lib/components/ui/toggle/index.js +0 -8
- package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
- package/src/core/lib/components/ui/toggle-group/index.js +0 -10
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
- package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
- package/src/internals/canvas/WebGLContextPool.jsx +0 -291
- package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
- package/src/internals/canvas/componentIsolate.jsx +0 -144
- package/src/internals/canvas/componentSetIsolate.jsx +0 -300
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
- package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
- package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
- package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
- package/src/primer/ThemeSync.jsx +0 -73
- package/src/primer/index.js +0 -1
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* GET /list — list all canvases
|
|
13
13
|
* GET /folders — list canvas folders
|
|
14
14
|
* PUT /update — append update events (widgets, sources, settings)
|
|
15
|
+
* POST /undo — append the inverse of a previously-applied event
|
|
16
|
+
* POST /redo — re-apply an undone event (mirror of /undo)
|
|
15
17
|
* PUT /rename-page — rename a canvas page file
|
|
16
18
|
* PUT /reorder-pages — save page order for a canvas folder
|
|
17
19
|
* GET /page-order — read page order for a folder
|
|
@@ -38,7 +40,9 @@ import fs from 'node:fs'
|
|
|
38
40
|
import path from 'node:path'
|
|
39
41
|
import { Buffer } from 'node:buffer'
|
|
40
42
|
import { createHash } from 'node:crypto'
|
|
41
|
-
import { materializeFromText, serializeEvent } from './materializer.js'
|
|
43
|
+
import { materializeFromText, parseCanvasJsonl, serializeEvent } from './materializer.js'
|
|
44
|
+
import { buildInverseEvent } from './undoRedo.js'
|
|
45
|
+
import { compactCanvas } from './compact.js'
|
|
42
46
|
import { toCanvasId, parseCanvasId } from './identity.js'
|
|
43
47
|
import {
|
|
44
48
|
GH_INSTALL_URL,
|
|
@@ -137,7 +141,7 @@ function writeFolderMeta(folderDir, meta) {
|
|
|
137
141
|
*/
|
|
138
142
|
function findCanvasFiles(root) {
|
|
139
143
|
const results = []
|
|
140
|
-
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
144
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs'])
|
|
141
145
|
|
|
142
146
|
function walk(dir, rel) {
|
|
143
147
|
let entries
|
|
@@ -164,7 +168,7 @@ function findCanvasFiles(root) {
|
|
|
164
168
|
*/
|
|
165
169
|
function findStoryFiles(root) {
|
|
166
170
|
const results = []
|
|
167
|
-
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
|
|
171
|
+
const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs'])
|
|
168
172
|
const ROUTABLE_DIRS = ['src/canvas', 'src/components']
|
|
169
173
|
|
|
170
174
|
function walk(dir, rel) {
|
|
@@ -240,6 +244,53 @@ function appendEventRaw(filePath, event) {
|
|
|
240
244
|
fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Generate a short random event id. Stamped on every canvas event so
|
|
249
|
+
* undo/redo can reference the inverse target by id alone.
|
|
250
|
+
*/
|
|
251
|
+
function generateEventId() {
|
|
252
|
+
return `evt_${Math.random().toString(36).slice(2, 10)}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Hard ceiling for runaway sessions: when a JSONL file grows past this size,
|
|
257
|
+
* compact it once (collapses history into a single `canvas_created` baseline).
|
|
258
|
+
* Set above the normal startup-compaction threshold (500 KB) so most files
|
|
259
|
+
* never hit it — only marathon sessions that don't restart the dev server.
|
|
260
|
+
*/
|
|
261
|
+
const SOFT_COMPACT_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
262
|
+
|
|
263
|
+
/** Per-file lock to avoid compacting the same file repeatedly on bursts. */
|
|
264
|
+
const inFlightCompactions = new Set()
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check the file size after a write; if past the runaway threshold, kick off
|
|
268
|
+
* a one-shot compaction. Best-effort — failures are swallowed and logged.
|
|
269
|
+
*/
|
|
270
|
+
function maybeCompactRunawayFile(filePath) {
|
|
271
|
+
if (inFlightCompactions.has(filePath)) return
|
|
272
|
+
let size
|
|
273
|
+
try { size = fs.statSync(filePath).size } catch { return }
|
|
274
|
+
if (size < SOFT_COMPACT_THRESHOLD_BYTES) return
|
|
275
|
+
inFlightCompactions.add(filePath)
|
|
276
|
+
// Run on the next tick so this append's HMR push fires first and the
|
|
277
|
+
// compaction's HMR push lands after — the client treats both consistently
|
|
278
|
+
// (compaction's push is a single canvas_created event that reconciles).
|
|
279
|
+
setImmediate(() => {
|
|
280
|
+
try {
|
|
281
|
+
const result = compactCanvas(filePath, { force: true })
|
|
282
|
+
devLog().logEvent('info', `[canvas] runaway compaction ${path.basename(filePath)}`, {
|
|
283
|
+
before: result.before,
|
|
284
|
+
after: result.after,
|
|
285
|
+
})
|
|
286
|
+
} catch (err) {
|
|
287
|
+
devLog().logEvent('warn', `[canvas] runaway compaction failed for ${filePath}`, { error: err.message })
|
|
288
|
+
} finally {
|
|
289
|
+
inFlightCompactions.delete(filePath)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
243
294
|
/**
|
|
244
295
|
* Generate a unique widget ID.
|
|
245
296
|
*/
|
|
@@ -702,13 +753,25 @@ export function createCanvasHandler(ctx) {
|
|
|
702
753
|
// Append an event to an existing canvas file.
|
|
703
754
|
// Marks the file in the write guard so the data plugin's watcher handler
|
|
704
755
|
// skips sending a duplicate HMR event (the server pushes its own via
|
|
705
|
-
// pushCanvasUpdate after the write).
|
|
756
|
+
// pushCanvasUpdate after the write). Stamps a stable random `id` so
|
|
757
|
+
// undo/redo markers can target events by id.
|
|
758
|
+
//
|
|
759
|
+
// Also runs the runaway-session hard ceiling: if the file grows past
|
|
760
|
+
// SOFT_COMPACT_THRESHOLD after writing, kick off a one-shot compaction
|
|
761
|
+
// to keep the JSONL → HMR → React pipeline responsive. The user's per-tab
|
|
762
|
+
// undo stack will lose pre-compaction targets (post-compaction undo calls
|
|
763
|
+
// return 404 which the client interprets as "clear the stack").
|
|
706
764
|
function appendEvent(filePath, event) {
|
|
765
|
+
if (event && typeof event === 'object' && !event.id) {
|
|
766
|
+
event.id = generateEventId()
|
|
767
|
+
}
|
|
707
768
|
markCanvasWrite(filePath)
|
|
708
769
|
appendEventRaw(filePath, event)
|
|
770
|
+
maybeCompactRunawayFile(filePath)
|
|
709
771
|
// Unmark after enough time for the watcher to fire and be suppressed.
|
|
710
772
|
// macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
|
|
711
773
|
setTimeout(() => unmarkCanvasWrite(filePath), 1000)
|
|
774
|
+
return event.id
|
|
712
775
|
}
|
|
713
776
|
|
|
714
777
|
/**
|
|
@@ -783,18 +846,18 @@ export function createCanvasHandler(ctx) {
|
|
|
783
846
|
}
|
|
784
847
|
|
|
785
848
|
/**
|
|
786
|
-
* Non-mutating probe of a hot pool — returns
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
849
|
+
* Non-mutating probe of a hot pool — returns readiness without claiming
|
|
850
|
+
* a slot. Use this when the canvas API just needs to tell the client
|
|
851
|
+
* whether a hot session is available; the actual session claim happens
|
|
852
|
+
* later in terminal-server when the WebSocket connects.
|
|
790
853
|
* @param {Object|null} hotPool — HotPoolManager instance
|
|
791
854
|
* @param {string} poolId — pool to peek at
|
|
792
855
|
* @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
|
|
793
|
-
* @returns {{ ready: boolean
|
|
856
|
+
* @returns {{ ready: boolean }}
|
|
794
857
|
*/
|
|
795
858
|
function peekPool(hotPool, poolId, mode) {
|
|
796
|
-
if (!hotPool || mode === 'cold') return { ready: false
|
|
797
|
-
if (!hotPool.has(poolId)) return { ready: false
|
|
859
|
+
if (!hotPool || mode === 'cold') return { ready: false }
|
|
860
|
+
if (!hotPool.has(poolId)) return { ready: false }
|
|
798
861
|
return hotPool.peek(poolId)
|
|
799
862
|
}
|
|
800
863
|
|
|
@@ -879,15 +942,20 @@ export function createCanvasHandler(ctx) {
|
|
|
879
942
|
if (routePath === '/folders' && method === 'GET') {
|
|
880
943
|
const canvasDir = path.join(root, 'src', 'canvas')
|
|
881
944
|
let folders = []
|
|
945
|
+
let entries = []
|
|
882
946
|
try {
|
|
883
947
|
if (fs.existsSync(canvasDir)) {
|
|
884
|
-
const
|
|
885
|
-
// .folder directories (
|
|
886
|
-
|
|
948
|
+
const dirEntries = fs.readdirSync(canvasDir, { withFileTypes: true })
|
|
949
|
+
// .folder directories — workspace grouping (sibling canvases shown
|
|
950
|
+
// under a folder header in the workspace, each addressable on its
|
|
951
|
+
// own /canvas/<folder>/<name> route).
|
|
952
|
+
const folderDirs = dirEntries
|
|
887
953
|
.filter((d) => d.isDirectory() && d.name.endsWith('.folder'))
|
|
888
954
|
.map((d) => d.name.replace('.folder', ''))
|
|
889
|
-
// Plain directories containing .canvas.jsonl files
|
|
890
|
-
|
|
955
|
+
// Plain directories containing .canvas.jsonl files — multi-page
|
|
956
|
+
// canvas grouping (each .canvas.jsonl is a "page" of the parent
|
|
957
|
+
// group, surfaced via PageSelector inside the canvas).
|
|
958
|
+
const plainDirs = dirEntries
|
|
891
959
|
.filter((d) => {
|
|
892
960
|
if (!d.isDirectory() || d.name.endsWith('.folder') || d.name.startsWith('_')) return false
|
|
893
961
|
const files = fs.readdirSync(path.join(canvasDir, d.name))
|
|
@@ -895,9 +963,16 @@ export function createCanvasHandler(ctx) {
|
|
|
895
963
|
})
|
|
896
964
|
.map((d) => d.name)
|
|
897
965
|
folders = [...folderDirs, ...plainDirs]
|
|
966
|
+
entries = [
|
|
967
|
+
...folderDirs.map((name) => ({ name, kind: 'workspace' })),
|
|
968
|
+
...plainDirs.map((name) => ({ name, kind: 'pages' })),
|
|
969
|
+
]
|
|
898
970
|
}
|
|
899
971
|
} catch { /* empty */ }
|
|
900
|
-
|
|
972
|
+
// `folders` is the legacy flat name list; `entries` is the new tagged
|
|
973
|
+
// list. Clients can prefer `entries` when they need to know whether a
|
|
974
|
+
// folder is a `.folder/` (workspace) or plain (multi-page) dir.
|
|
975
|
+
sendJson(res, 200, { folders, entries })
|
|
901
976
|
return
|
|
902
977
|
}
|
|
903
978
|
|
|
@@ -1007,6 +1082,9 @@ export function createCanvasHandler(ctx) {
|
|
|
1007
1082
|
|
|
1008
1083
|
try {
|
|
1009
1084
|
const ts = new Date().toISOString()
|
|
1085
|
+
// Snapshot of pre-update state so we can stamp prev* payloads on
|
|
1086
|
+
// source_updated and settings_updated events without re-reading.
|
|
1087
|
+
const current = readCanvas(filePath)
|
|
1010
1088
|
|
|
1011
1089
|
if (widgets) {
|
|
1012
1090
|
// Guard against accidental canvas wipes. Any widget IDs present in
|
|
@@ -1015,7 +1093,6 @@ export function createCanvasHandler(ctx) {
|
|
|
1015
1093
|
// This protects against stale-state writers (e.g. a debounced client
|
|
1016
1094
|
// save fired before an HMR push containing newly-added widgets was
|
|
1017
1095
|
// reconciled) silently wiping freshly-created widgets.
|
|
1018
|
-
const current = readCanvas(filePath)
|
|
1019
1096
|
const currentWidgets = current.widgets || []
|
|
1020
1097
|
if (body.replaceAll !== true) {
|
|
1021
1098
|
const incomingIds = new Set(widgets.map((w) => w && w.id).filter(Boolean))
|
|
@@ -1032,14 +1109,23 @@ export function createCanvasHandler(ctx) {
|
|
|
1032
1109
|
}
|
|
1033
1110
|
}
|
|
1034
1111
|
const stamped = stampBoundsAll(widgets)
|
|
1112
|
+
// widgets_replaced is not undoable — emitted only by compaction or
|
|
1113
|
+
// explicit replaceAll. The client should use PATCH/POST/DELETE for
|
|
1114
|
+
// granular, undoable changes.
|
|
1035
1115
|
appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
|
|
1036
1116
|
}
|
|
1037
1117
|
|
|
1038
1118
|
if (sources) {
|
|
1039
|
-
appendEvent(filePath, {
|
|
1119
|
+
appendEvent(filePath, {
|
|
1120
|
+
event: 'source_updated',
|
|
1121
|
+
timestamp: ts,
|
|
1122
|
+
sources,
|
|
1123
|
+
prevSources: Array.isArray(current.sources) ? current.sources : [],
|
|
1124
|
+
})
|
|
1040
1125
|
}
|
|
1041
1126
|
|
|
1042
1127
|
if (connectors) {
|
|
1128
|
+
// connectors_replaced is not undoable — emitted only by compaction.
|
|
1043
1129
|
appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
|
|
1044
1130
|
}
|
|
1045
1131
|
|
|
@@ -1051,7 +1137,17 @@ export function createCanvasHandler(ctx) {
|
|
|
1051
1137
|
}
|
|
1052
1138
|
}
|
|
1053
1139
|
if (Object.keys(filtered).length > 0) {
|
|
1054
|
-
|
|
1140
|
+
// Capture prior values for the keys being changed.
|
|
1141
|
+
const prevSettings = {}
|
|
1142
|
+
for (const key of Object.keys(filtered)) {
|
|
1143
|
+
prevSettings[key] = current[key]
|
|
1144
|
+
}
|
|
1145
|
+
appendEvent(filePath, {
|
|
1146
|
+
event: 'settings_updated',
|
|
1147
|
+
timestamp: ts,
|
|
1148
|
+
settings: filtered,
|
|
1149
|
+
prevSettings,
|
|
1150
|
+
})
|
|
1055
1151
|
}
|
|
1056
1152
|
}
|
|
1057
1153
|
|
|
@@ -1063,6 +1159,61 @@ export function createCanvasHandler(ctx) {
|
|
|
1063
1159
|
return
|
|
1064
1160
|
}
|
|
1065
1161
|
|
|
1162
|
+
// POST /undo — append the inverse of a previously-applied event, tagged
|
|
1163
|
+
// with meta.kind = 'undo' / meta.of = <targetEventId>. Body:
|
|
1164
|
+
// { name, eventId }
|
|
1165
|
+
// Returns { success, eventId, inverseEvent: {...} }.
|
|
1166
|
+
//
|
|
1167
|
+
// POST /redo behaves identically but stamps meta.kind = 'redo'. Per
|
|
1168
|
+
// convention the client passes the id of the *undo* event when redoing
|
|
1169
|
+
// (so the redo cancels the undo and brings back the original change).
|
|
1170
|
+
if ((routePath === '/undo' || routePath === '/redo') && method === 'POST') {
|
|
1171
|
+
const { name, eventId } = body
|
|
1172
|
+
const kind = routePath === '/undo' ? 'undo' : 'redo'
|
|
1173
|
+
|
|
1174
|
+
if (!name || !eventId) {
|
|
1175
|
+
sendJson(res, 400, { error: 'Canvas name and eventId are required' })
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const filePath = findCanvasPath(root, name)
|
|
1180
|
+
if (!filePath) {
|
|
1181
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1182
|
+
return
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
const text = fs.readFileSync(filePath, 'utf-8')
|
|
1187
|
+
const events = parseCanvasJsonl(text)
|
|
1188
|
+
const target = events.find((evt) => evt && evt.id === eventId)
|
|
1189
|
+
if (!target) {
|
|
1190
|
+
sendJson(res, 404, { error: `Event "${eventId}" not found in canvas "${name}"` })
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const inverse = buildInverseEvent(target, kind)
|
|
1195
|
+
if (!inverse) {
|
|
1196
|
+
sendJson(res, 400, {
|
|
1197
|
+
error: `Event "${eventId}" (type "${target.event}") cannot be ${kind === 'undo' ? 'undone' : 'redone'}: missing inverse payload or unsupported event type`,
|
|
1198
|
+
})
|
|
1199
|
+
return
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
inverse.timestamp = new Date().toISOString()
|
|
1203
|
+
const inverseId = appendEvent(filePath, inverse)
|
|
1204
|
+
|
|
1205
|
+
sendJson(res, 200, {
|
|
1206
|
+
success: true,
|
|
1207
|
+
eventId: inverseId,
|
|
1208
|
+
inverseEvent: { ...inverse, id: inverseId },
|
|
1209
|
+
})
|
|
1210
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
sendJson(res, 500, { error: `Failed to ${kind} event: ${err.message}` })
|
|
1213
|
+
}
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1066
1217
|
// POST /widget — append a widget_added event
|
|
1067
1218
|
if (routePath === '/widget' && method === 'POST') {
|
|
1068
1219
|
const { name, type, props = {}, pool, near, direction, resolve, source, gap } = body
|
|
@@ -1134,11 +1285,11 @@ export function createCanvasHandler(ctx) {
|
|
|
1134
1285
|
|
|
1135
1286
|
// Hot pool readiness probe for terminal/agent widgets — non-mutating.
|
|
1136
1287
|
// The actual session claim happens later in terminal-server when the
|
|
1137
|
-
// WS connects. Probing here only tells the client whether
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
let hotProbe = { ready: false
|
|
1288
|
+
// WS connects. Probing here only tells the client whether a hot
|
|
1289
|
+
// session is available. (Previously this called acquireFromPool,
|
|
1290
|
+
// which leaked a #acquired slot per widget creation because no
|
|
1291
|
+
// consume/release ever fired against this acquisition.)
|
|
1292
|
+
let hotProbe = { ready: false }
|
|
1142
1293
|
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
1143
1294
|
const poolId = resolvePoolId(type, props)
|
|
1144
1295
|
hotProbe = peekPool(hotPool, poolId, pool)
|
|
@@ -1150,14 +1301,14 @@ export function createCanvasHandler(ctx) {
|
|
|
1150
1301
|
|
|
1151
1302
|
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1152
1303
|
|
|
1153
|
-
appendEvent(filePath, {
|
|
1304
|
+
const eventId = appendEvent(filePath, {
|
|
1154
1305
|
event: 'widget_added',
|
|
1155
1306
|
timestamp: new Date().toISOString(),
|
|
1156
1307
|
widget,
|
|
1157
1308
|
})
|
|
1158
1309
|
|
|
1159
|
-
const response = { success: true, widget }
|
|
1160
|
-
if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null
|
|
1310
|
+
const response = { success: true, widget, eventId }
|
|
1311
|
+
if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null }
|
|
1161
1312
|
sendJson(res, 201, response)
|
|
1162
1313
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1163
1314
|
} catch (err) {
|
|
@@ -1190,10 +1341,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1190
1341
|
return
|
|
1191
1342
|
}
|
|
1192
1343
|
|
|
1193
|
-
appendEvent(filePath, {
|
|
1344
|
+
const eventId = appendEvent(filePath, {
|
|
1194
1345
|
event: 'widget_removed',
|
|
1195
1346
|
timestamp: new Date().toISOString(),
|
|
1196
1347
|
widgetId,
|
|
1348
|
+
// Carry the full removed widget so undo can re-add it without
|
|
1349
|
+
// walking history. Symmetric to widget_added.widget.
|
|
1350
|
+
widget,
|
|
1197
1351
|
})
|
|
1198
1352
|
|
|
1199
1353
|
// Orphan terminal session when a terminal widget is deleted (not killed)
|
|
@@ -1206,7 +1360,7 @@ export function createCanvasHandler(ctx) {
|
|
|
1206
1360
|
}
|
|
1207
1361
|
}
|
|
1208
1362
|
|
|
1209
|
-
sendJson(res, 200, { success: true, removed: 1 })
|
|
1363
|
+
sendJson(res, 200, { success: true, removed: 1, eventId })
|
|
1210
1364
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1211
1365
|
} catch (err) {
|
|
1212
1366
|
sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
|
|
@@ -1242,25 +1396,34 @@ export function createCanvasHandler(ctx) {
|
|
|
1242
1396
|
}
|
|
1243
1397
|
|
|
1244
1398
|
const ts = new Date().toISOString()
|
|
1399
|
+
const eventIds = []
|
|
1245
1400
|
|
|
1246
1401
|
if (props) {
|
|
1247
|
-
|
|
1402
|
+
// Capture only the keys being changed so undo restores them
|
|
1403
|
+
// precisely. Missing keys mean "this prop didn't exist before".
|
|
1404
|
+
const prevProps = {}
|
|
1405
|
+
for (const key of Object.keys(props)) {
|
|
1406
|
+
prevProps[key] = widget.props ? widget.props[key] : undefined
|
|
1407
|
+
}
|
|
1408
|
+
eventIds.push(appendEvent(filePath, {
|
|
1248
1409
|
event: 'widget_updated',
|
|
1249
1410
|
timestamp: ts,
|
|
1250
1411
|
widgetId,
|
|
1251
1412
|
props,
|
|
1252
|
-
|
|
1413
|
+
prevProps,
|
|
1414
|
+
}))
|
|
1253
1415
|
}
|
|
1254
1416
|
|
|
1255
1417
|
if (position) {
|
|
1256
1418
|
// Merge with existing position so partial updates (only --x or --y) are safe
|
|
1257
1419
|
const mergedPosition = { ...widget.position, ...position }
|
|
1258
|
-
appendEvent(filePath, {
|
|
1420
|
+
eventIds.push(appendEvent(filePath, {
|
|
1259
1421
|
event: 'widget_moved',
|
|
1260
1422
|
timestamp: ts,
|
|
1261
1423
|
widgetId,
|
|
1262
1424
|
position: mergedPosition,
|
|
1263
|
-
|
|
1425
|
+
prevPosition: widget.position || { x: 0, y: 0 },
|
|
1426
|
+
}))
|
|
1264
1427
|
}
|
|
1265
1428
|
|
|
1266
1429
|
// Return the merged widget for convenience
|
|
@@ -1269,7 +1432,7 @@ export function createCanvasHandler(ctx) {
|
|
|
1269
1432
|
props: { ...widget.props, ...(props || {}) },
|
|
1270
1433
|
position: position ? { ...widget.position, ...position } : widget.position,
|
|
1271
1434
|
}
|
|
1272
|
-
sendJson(res, 200, { success: true, widget: merged })
|
|
1435
|
+
sendJson(res, 200, { success: true, widget: merged, eventIds })
|
|
1273
1436
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1274
1437
|
} catch (err) {
|
|
1275
1438
|
sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
|
|
@@ -1417,13 +1580,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1417
1580
|
meta: meta && typeof meta === 'object' ? { ...meta } : {},
|
|
1418
1581
|
}
|
|
1419
1582
|
|
|
1420
|
-
appendEvent(filePath, {
|
|
1583
|
+
const eventId = appendEvent(filePath, {
|
|
1421
1584
|
event: 'connector_added',
|
|
1422
1585
|
timestamp: new Date().toISOString(),
|
|
1423
1586
|
connector,
|
|
1424
1587
|
})
|
|
1425
1588
|
|
|
1426
|
-
sendJson(res, 201, { success: true, connector })
|
|
1589
|
+
sendJson(res, 201, { success: true, connector, eventId })
|
|
1427
1590
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1428
1591
|
} catch (err) {
|
|
1429
1592
|
sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
|
|
@@ -1469,14 +1632,28 @@ export function createCanvasHandler(ctx) {
|
|
|
1469
1632
|
if (startAnchor) updates.startAnchor = startAnchor
|
|
1470
1633
|
if (endAnchor) updates.endAnchor = endAnchor
|
|
1471
1634
|
|
|
1472
|
-
|
|
1635
|
+
// Capture the previous values for the keys we're changing so undo
|
|
1636
|
+
// can restore them precisely.
|
|
1637
|
+
const prevUpdates = {}
|
|
1638
|
+
if (meta) {
|
|
1639
|
+
const prevMeta = {}
|
|
1640
|
+
for (const key of Object.keys(meta)) {
|
|
1641
|
+
prevMeta[key] = connector.meta ? connector.meta[key] : undefined
|
|
1642
|
+
}
|
|
1643
|
+
prevUpdates.meta = prevMeta
|
|
1644
|
+
}
|
|
1645
|
+
if (startAnchor) prevUpdates.startAnchor = connector.start?.anchor
|
|
1646
|
+
if (endAnchor) prevUpdates.endAnchor = connector.end?.anchor
|
|
1647
|
+
|
|
1648
|
+
const eventId = appendEvent(filePath, {
|
|
1473
1649
|
event: 'connector_updated',
|
|
1474
1650
|
timestamp: new Date().toISOString(),
|
|
1475
1651
|
connectorId,
|
|
1476
1652
|
updates,
|
|
1653
|
+
prevUpdates,
|
|
1477
1654
|
})
|
|
1478
1655
|
|
|
1479
|
-
sendJson(res, 200, { success: true })
|
|
1656
|
+
sendJson(res, 200, { success: true, eventId })
|
|
1480
1657
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1481
1658
|
|
|
1482
1659
|
// Inject messaging skill into both terminals when mode changes
|
|
@@ -1562,8 +1739,8 @@ export function createCanvasHandler(ctx) {
|
|
|
1562
1739
|
|
|
1563
1740
|
try {
|
|
1564
1741
|
const data = readCanvas(filePath)
|
|
1565
|
-
const
|
|
1566
|
-
if (!
|
|
1742
|
+
const existing = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1743
|
+
if (!existing) {
|
|
1567
1744
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1568
1745
|
return
|
|
1569
1746
|
}
|
|
@@ -1576,14 +1753,17 @@ export function createCanvasHandler(ctx) {
|
|
|
1576
1753
|
return out
|
|
1577
1754
|
})
|
|
1578
1755
|
|
|
1579
|
-
appendEvent(filePath, {
|
|
1756
|
+
const eventId = appendEvent(filePath, {
|
|
1580
1757
|
event: 'connector_waypoints_set',
|
|
1581
1758
|
timestamp: new Date().toISOString(),
|
|
1582
1759
|
connectorId,
|
|
1583
1760
|
waypoints: normalized,
|
|
1761
|
+
// null prevWaypoints means "this connector had no manual routing
|
|
1762
|
+
// before"; undo emits connector_waypoints_cleared in that case.
|
|
1763
|
+
prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
|
|
1584
1764
|
})
|
|
1585
1765
|
|
|
1586
|
-
sendJson(res, 200, { success: true, waypoints: normalized })
|
|
1766
|
+
sendJson(res, 200, { success: true, waypoints: normalized, eventId })
|
|
1587
1767
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1588
1768
|
} catch (err) {
|
|
1589
1769
|
sendJson(res, 500, { error: `Failed to set waypoints: ${err.message}` })
|
|
@@ -1608,19 +1788,20 @@ export function createCanvasHandler(ctx) {
|
|
|
1608
1788
|
|
|
1609
1789
|
try {
|
|
1610
1790
|
const data = readCanvas(filePath)
|
|
1611
|
-
const
|
|
1612
|
-
if (!
|
|
1791
|
+
const existing = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1792
|
+
if (!existing) {
|
|
1613
1793
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1614
1794
|
return
|
|
1615
1795
|
}
|
|
1616
1796
|
|
|
1617
|
-
appendEvent(filePath, {
|
|
1797
|
+
const eventId = appendEvent(filePath, {
|
|
1618
1798
|
event: 'connector_waypoints_cleared',
|
|
1619
1799
|
timestamp: new Date().toISOString(),
|
|
1620
1800
|
connectorId,
|
|
1801
|
+
prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
|
|
1621
1802
|
})
|
|
1622
1803
|
|
|
1623
|
-
sendJson(res, 200, { success: true })
|
|
1804
|
+
sendJson(res, 200, { success: true, eventId })
|
|
1624
1805
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1625
1806
|
} catch (err) {
|
|
1626
1807
|
sendJson(res, 500, { error: `Failed to clear waypoints: ${err.message}` })
|
|
@@ -1645,19 +1826,22 @@ export function createCanvasHandler(ctx) {
|
|
|
1645
1826
|
|
|
1646
1827
|
try {
|
|
1647
1828
|
const data = readCanvas(filePath)
|
|
1648
|
-
const
|
|
1649
|
-
if (!
|
|
1829
|
+
const connector = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1830
|
+
if (!connector) {
|
|
1650
1831
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1651
1832
|
return
|
|
1652
1833
|
}
|
|
1653
1834
|
|
|
1654
|
-
appendEvent(filePath, {
|
|
1835
|
+
const eventId = appendEvent(filePath, {
|
|
1655
1836
|
event: 'connector_removed',
|
|
1656
1837
|
timestamp: new Date().toISOString(),
|
|
1657
1838
|
connectorId,
|
|
1839
|
+
// Carry the full removed connector so undo can re-add it without
|
|
1840
|
+
// walking history. Symmetric to connector_added.connector.
|
|
1841
|
+
connector,
|
|
1658
1842
|
})
|
|
1659
1843
|
|
|
1660
|
-
sendJson(res, 200, { success: true, removed: 1 })
|
|
1844
|
+
sendJson(res, 200, { success: true, removed: 1, eventId })
|
|
1661
1845
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1662
1846
|
} catch (err) {
|
|
1663
1847
|
sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
|
|
@@ -1892,7 +2076,7 @@ export function createCanvasHandler(ctx) {
|
|
|
1892
2076
|
const widgetId = generateWidgetId(type)
|
|
1893
2077
|
await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
|
|
1894
2078
|
|
|
1895
|
-
let hotProbe = { ready: false
|
|
2079
|
+
let hotProbe = { ready: false }
|
|
1896
2080
|
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
1897
2081
|
const poolId = resolvePoolId(type, props)
|
|
1898
2082
|
hotProbe = peekPool(hotPool, poolId, pool)
|
|
@@ -1901,15 +2085,15 @@ export function createCanvasHandler(ctx) {
|
|
|
1901
2085
|
|
|
1902
2086
|
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1903
2087
|
|
|
1904
|
-
appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
2088
|
+
const eventId = appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
1905
2089
|
|
|
1906
2090
|
widgetIds.add(widgetId)
|
|
1907
2091
|
widgetMap.set(widgetId, widget)
|
|
1908
2092
|
refs[String(i)] = widgetId
|
|
1909
2093
|
if (ref) refs[ref] = widgetId
|
|
1910
2094
|
|
|
1911
|
-
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
|
|
1912
|
-
if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null
|
|
2095
|
+
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget, eventId }
|
|
2096
|
+
if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null }
|
|
1913
2097
|
results.push(result)
|
|
1914
2098
|
break
|
|
1915
2099
|
}
|
|
@@ -1921,12 +2105,16 @@ export function createCanvasHandler(ctx) {
|
|
|
1921
2105
|
if (!props) throw new Error('props is required')
|
|
1922
2106
|
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1923
2107
|
|
|
1924
|
-
appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
|
|
1925
|
-
|
|
1926
2108
|
const existing = widgetMap.get(widgetId)
|
|
2109
|
+
const prevProps = {}
|
|
2110
|
+
for (const key of Object.keys(props)) {
|
|
2111
|
+
prevProps[key] = existing?.props ? existing.props[key] : undefined
|
|
2112
|
+
}
|
|
2113
|
+
const eventId = appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props, prevProps })
|
|
2114
|
+
|
|
1927
2115
|
if (existing) existing.props = { ...existing.props, ...props }
|
|
1928
2116
|
|
|
1929
|
-
results.push({ index: i, op: 'update-widget', widgetId, success: true })
|
|
2117
|
+
results.push({ index: i, op: 'update-widget', widgetId, success: true, eventId })
|
|
1930
2118
|
break
|
|
1931
2119
|
}
|
|
1932
2120
|
|
|
@@ -1939,12 +2127,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1939
2127
|
|
|
1940
2128
|
const existing = widgetMap.get(widgetId)
|
|
1941
2129
|
const mergedPosition = { ...(existing?.position || {}), ...position }
|
|
2130
|
+
const prevPosition = existing?.position || { x: 0, y: 0 }
|
|
1942
2131
|
|
|
1943
|
-
appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
|
|
2132
|
+
const eventId = appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition, prevPosition })
|
|
1944
2133
|
|
|
1945
2134
|
if (existing) existing.position = mergedPosition
|
|
1946
2135
|
|
|
1947
|
-
results.push({ index: i, op: 'move-widget', widgetId, success: true })
|
|
2136
|
+
results.push({ index: i, op: 'move-widget', widgetId, success: true, eventId })
|
|
1948
2137
|
break
|
|
1949
2138
|
}
|
|
1950
2139
|
|
|
@@ -1953,12 +2142,18 @@ export function createCanvasHandler(ctx) {
|
|
|
1953
2142
|
if (!widgetId) throw new Error('widgetId is required')
|
|
1954
2143
|
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1955
2144
|
|
|
1956
|
-
|
|
2145
|
+
const existing = widgetMap.get(widgetId)
|
|
2146
|
+
const eventId = appendEvent(filePath, {
|
|
2147
|
+
event: 'widget_removed',
|
|
2148
|
+
timestamp: ts,
|
|
2149
|
+
widgetId,
|
|
2150
|
+
widget: existing,
|
|
2151
|
+
})
|
|
1957
2152
|
|
|
1958
2153
|
widgetIds.delete(widgetId)
|
|
1959
2154
|
widgetMap.delete(widgetId)
|
|
1960
2155
|
|
|
1961
|
-
results.push({ index: i, op: 'delete-widget', widgetId, success: true })
|
|
2156
|
+
results.push({ index: i, op: 'delete-widget', widgetId, success: true, eventId })
|
|
1962
2157
|
break
|
|
1963
2158
|
}
|
|
1964
2159
|
|
|
@@ -1993,14 +2188,14 @@ export function createCanvasHandler(ctx) {
|
|
|
1993
2188
|
meta: op.meta && typeof op.meta === 'object' ? { ...op.meta } : {},
|
|
1994
2189
|
}
|
|
1995
2190
|
|
|
1996
|
-
appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
2191
|
+
const eventId = appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
1997
2192
|
|
|
1998
2193
|
connectorIds.add(connectorId)
|
|
1999
2194
|
connectorMap.set(connectorId, connector)
|
|
2000
2195
|
refs[String(i)] = connectorId
|
|
2001
2196
|
if (ref) refs[ref] = connectorId
|
|
2002
2197
|
|
|
2003
|
-
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
|
|
2198
|
+
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true, eventId })
|
|
2004
2199
|
break
|
|
2005
2200
|
}
|
|
2006
2201
|
|
|
@@ -2009,11 +2204,17 @@ export function createCanvasHandler(ctx) {
|
|
|
2009
2204
|
if (!connectorId) throw new Error('connectorId is required')
|
|
2010
2205
|
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
2011
2206
|
|
|
2012
|
-
|
|
2207
|
+
const existing = connectorMap.get(connectorId)
|
|
2208
|
+
const eventId = appendEvent(filePath, {
|
|
2209
|
+
event: 'connector_removed',
|
|
2210
|
+
timestamp: ts,
|
|
2211
|
+
connectorId,
|
|
2212
|
+
connector: existing,
|
|
2213
|
+
})
|
|
2013
2214
|
connectorIds.delete(connectorId)
|
|
2014
2215
|
connectorMap.delete(connectorId)
|
|
2015
2216
|
|
|
2016
|
-
results.push({ index: i, op: 'delete-connector', connectorId, success: true })
|
|
2217
|
+
results.push({ index: i, op: 'delete-connector', connectorId, success: true, eventId })
|
|
2017
2218
|
break
|
|
2018
2219
|
}
|
|
2019
2220
|
|
|
@@ -2024,12 +2225,22 @@ export function createCanvasHandler(ctx) {
|
|
|
2024
2225
|
if (!meta) throw new Error('meta is required')
|
|
2025
2226
|
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
2026
2227
|
|
|
2027
|
-
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
|
|
2028
|
-
|
|
2029
2228
|
const existing = connectorMap.get(connectorId)
|
|
2229
|
+
const prevMeta = {}
|
|
2230
|
+
for (const key of Object.keys(meta)) {
|
|
2231
|
+
prevMeta[key] = existing?.meta ? existing.meta[key] : undefined
|
|
2232
|
+
}
|
|
2233
|
+
const eventId = appendEvent(filePath, {
|
|
2234
|
+
event: 'connector_updated',
|
|
2235
|
+
timestamp: ts,
|
|
2236
|
+
connectorId,
|
|
2237
|
+
updates: { meta },
|
|
2238
|
+
prevUpdates: { meta: prevMeta },
|
|
2239
|
+
})
|
|
2240
|
+
|
|
2030
2241
|
if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
|
|
2031
2242
|
|
|
2032
|
-
results.push({ index: i, op: 'update-connector', connectorId, success: true })
|
|
2243
|
+
results.push({ index: i, op: 'update-connector', connectorId, success: true, eventId })
|
|
2033
2244
|
break
|
|
2034
2245
|
}
|
|
2035
2246
|
|
|
@@ -2397,6 +2608,7 @@ export function createCanvasHandler(ctx) {
|
|
|
2397
2608
|
name,
|
|
2398
2609
|
title,
|
|
2399
2610
|
folder,
|
|
2611
|
+
folderKind,
|
|
2400
2612
|
convertFrom,
|
|
2401
2613
|
author,
|
|
2402
2614
|
description,
|
|
@@ -2531,7 +2743,12 @@ export function createCanvasHandler(ctx) {
|
|
|
2531
2743
|
}
|
|
2532
2744
|
|
|
2533
2745
|
// ── Standard canvas creation ─────────────────────────────────────
|
|
2534
|
-
// Determine target directory
|
|
2746
|
+
// Determine target directory. When `folder` is set:
|
|
2747
|
+
// - existing `<folder>.folder/` wins → workspace grouping
|
|
2748
|
+
// - existing `<folder>/` wins → multi-page canvas grouping
|
|
2749
|
+
// - otherwise: create a new dir; `folderKind` controls which kind.
|
|
2750
|
+
// Default kind is "pages" for backward compatibility (the legacy
|
|
2751
|
+
// behavior). Pass `"workspace"` to create `<folder>.folder/`.
|
|
2535
2752
|
const canvasDir = path.join(root, 'src', 'canvas')
|
|
2536
2753
|
let targetDir = canvasDir
|
|
2537
2754
|
|
|
@@ -2540,25 +2757,28 @@ export function createCanvasHandler(ctx) {
|
|
|
2540
2757
|
const plainDir = path.join(canvasDir, folder)
|
|
2541
2758
|
|
|
2542
2759
|
if (fs.existsSync(dotFolderDir)) {
|
|
2543
|
-
// Existing .folder/ directory
|
|
2760
|
+
// Existing .folder/ directory (workspace grouping)
|
|
2544
2761
|
targetDir = dotFolderDir
|
|
2545
2762
|
} else if (fs.existsSync(plainDir) && fs.statSync(plainDir).isDirectory()) {
|
|
2546
|
-
// Existing plain directory
|
|
2763
|
+
// Existing plain directory (multi-page grouping)
|
|
2547
2764
|
targetDir = plainDir
|
|
2548
2765
|
} else {
|
|
2549
|
-
// Create new
|
|
2766
|
+
// Create a new directory according to the requested kind.
|
|
2767
|
+
const useWorkspace = folderKind === 'workspace'
|
|
2768
|
+
const newDir = useWorkspace ? dotFolderDir : plainDir
|
|
2550
2769
|
try {
|
|
2551
|
-
fs.mkdirSync(
|
|
2552
|
-
// Write .meta.json if meta was provided
|
|
2770
|
+
fs.mkdirSync(newDir, { recursive: true })
|
|
2771
|
+
// Write .meta.json if meta was provided (only meaningful for
|
|
2772
|
+
// multi-page groups today; harmless inside a .folder/).
|
|
2553
2773
|
if (meta && typeof meta === 'object') {
|
|
2554
|
-
const metaPath = path.join(
|
|
2774
|
+
const metaPath = path.join(newDir, `${folder}.meta.json`)
|
|
2555
2775
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
|
|
2556
2776
|
}
|
|
2557
2777
|
} catch (err) {
|
|
2558
2778
|
sendJson(res, 500, { error: `Failed to create directory: ${err.message}` })
|
|
2559
2779
|
return
|
|
2560
2780
|
}
|
|
2561
|
-
targetDir =
|
|
2781
|
+
targetDir = newDir
|
|
2562
2782
|
}
|
|
2563
2783
|
}
|
|
2564
2784
|
|
|
@@ -2763,16 +2983,56 @@ export function Default() {
|
|
|
2763
2983
|
const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
|
|
2764
2984
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
|
|
2765
2985
|
|
|
2766
|
-
// Route snapshot uploads (snapshot-* prefix) to the snapshots directory
|
|
2986
|
+
// Route snapshot uploads (snapshot-* prefix) to the snapshots directory.
|
|
2987
|
+
// Canvases inside a `drafts/` segment go to images/drafts/ (gitignored)
|
|
2988
|
+
// so draft-only images don't leak into git via assets/canvas/images/.
|
|
2989
|
+
function isDraftCanvasId(canvasName) {
|
|
2990
|
+
if (!canvasName || typeof canvasName !== 'string') return false
|
|
2991
|
+
return canvasName.split('/').some((seg) => seg === 'drafts')
|
|
2992
|
+
}
|
|
2767
2993
|
function resolveWriteDir(canvasName) {
|
|
2768
|
-
|
|
2994
|
+
if (canvasName?.startsWith('snapshot-')) return snapshotsDir
|
|
2995
|
+
if (isDraftCanvasId(canvasName)) return path.join(imagesDir, 'drafts')
|
|
2996
|
+
return imagesDir
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// Validate a filename coming from the client. Accepts a bare basename
|
|
3000
|
+
// (`foo.png`) or one optional `drafts/` segment (`drafts/foo.png`).
|
|
3001
|
+
// Returns { dirSegment: '' | 'drafts', basename } or null if invalid.
|
|
3002
|
+
function parseValidFilename(filename) {
|
|
3003
|
+
if (!filename || typeof filename !== 'string') return null
|
|
3004
|
+
if (filename.includes('..') || filename.includes('\\')) return null
|
|
3005
|
+
const segments = filename.split('/')
|
|
3006
|
+
if (segments.length === 1) {
|
|
3007
|
+
return segments[0] ? { dirSegment: '', basename: segments[0] } : null
|
|
3008
|
+
}
|
|
3009
|
+
if (segments.length === 2 && segments[0] === 'drafts' && segments[1]) {
|
|
3010
|
+
return { dirSegment: 'drafts', basename: segments[1] }
|
|
3011
|
+
}
|
|
3012
|
+
return null
|
|
2769
3013
|
}
|
|
2770
3014
|
|
|
3015
|
+
// Resolve a stored filename to an absolute path on disk.
|
|
3016
|
+
//
|
|
3017
|
+
// `filename` may be either a bare basename (`foo.png`) or one optional
|
|
3018
|
+
// `drafts/` segment followed by a basename (`drafts/foo.png`). The
|
|
3019
|
+
// `drafts/` form is reserved for images that belong to draft canvases
|
|
3020
|
+
// and resolves only into images/drafts/. Bare basenames intentionally
|
|
3021
|
+
// do NOT fall back to images/drafts/ — that would let a public canvas
|
|
3022
|
+
// serve a draft-only asset just by sharing a basename.
|
|
3023
|
+
//
|
|
3024
|
+
// Returns null when the filename is malformed or the file is not found.
|
|
2771
3025
|
function resolveImagePath(filename) {
|
|
2772
|
-
|
|
2773
|
-
|
|
3026
|
+
const parsed = parseValidFilename(filename)
|
|
3027
|
+
if (!parsed) return null
|
|
3028
|
+
if (parsed.dirSegment === 'drafts') {
|
|
3029
|
+
const draftPath = path.join(imagesDir, 'drafts', parsed.basename)
|
|
3030
|
+
if (fs.existsSync(draftPath)) return draftPath
|
|
3031
|
+
return null
|
|
3032
|
+
}
|
|
3033
|
+
const snapshotPath = path.join(snapshotsDir, parsed.basename)
|
|
2774
3034
|
if (fs.existsSync(snapshotPath)) return snapshotPath
|
|
2775
|
-
const imagePath = path.join(imagesDir,
|
|
3035
|
+
const imagePath = path.join(imagesDir, parsed.basename)
|
|
2776
3036
|
if (fs.existsSync(imagePath)) return imagePath
|
|
2777
3037
|
return null
|
|
2778
3038
|
}
|
|
@@ -2831,26 +3091,33 @@ export function Default() {
|
|
|
2831
3091
|
filename = `${prefix}${dateStr}${suffix}.${ext}`
|
|
2832
3092
|
}
|
|
2833
3093
|
const targetDir = resolveWriteDir(canvasName || '')
|
|
3094
|
+
// Return the filename with a `drafts/` prefix when the write went to
|
|
3095
|
+
// the drafts subdirectory, so widget `props.src` includes the
|
|
3096
|
+
// directory and the GET route can locate the file unambiguously.
|
|
3097
|
+
const responseFilename =
|
|
3098
|
+
targetDir === path.join(imagesDir, 'drafts') ? `drafts/${filename}` : filename
|
|
2834
3099
|
|
|
2835
3100
|
try {
|
|
2836
3101
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
2837
3102
|
fs.writeFileSync(path.join(targetDir, filename), buffer)
|
|
2838
|
-
sendJson(res, 201, { success: true, filename })
|
|
3103
|
+
sendJson(res, 201, { success: true, filename: responseFilename })
|
|
2839
3104
|
} catch (err) {
|
|
2840
3105
|
sendJson(res, 500, { error: `Failed to save image: ${err.message}` })
|
|
2841
3106
|
}
|
|
2842
3107
|
return
|
|
2843
3108
|
}
|
|
2844
3109
|
|
|
2845
|
-
// GET /images/<filename> — serve an image file
|
|
3110
|
+
// GET /images/<filename> — serve an image file. Filename may include
|
|
3111
|
+
// an optional `drafts/` directory segment (e.g. `drafts/foo.png`).
|
|
2846
3112
|
if (routePath.startsWith('/images/') && method === 'GET') {
|
|
2847
3113
|
// Strip query string (e.g. ?v=123 cache busters) from filename
|
|
2848
3114
|
let filename = routePath.slice('/images/'.length)
|
|
2849
3115
|
const qIdx = filename.indexOf('?')
|
|
2850
3116
|
if (qIdx !== -1) filename = filename.slice(0, qIdx)
|
|
2851
3117
|
|
|
2852
|
-
//
|
|
2853
|
-
|
|
3118
|
+
// Validate (allows optional `drafts/` segment, rejects deeper paths
|
|
3119
|
+
// and path traversal)
|
|
3120
|
+
if (!parseValidFilename(filename)) {
|
|
2854
3121
|
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2855
3122
|
return
|
|
2856
3123
|
}
|
|
@@ -2878,7 +3145,8 @@ export function Default() {
|
|
|
2878
3145
|
return
|
|
2879
3146
|
}
|
|
2880
3147
|
|
|
2881
|
-
// POST /image/duplicate — copy an image file with a new timestamped name
|
|
3148
|
+
// POST /image/duplicate — copy an image file with a new timestamped name.
|
|
3149
|
+
// Accepts a bare basename or one optional `drafts/` segment.
|
|
2882
3150
|
if (routePath === '/image/duplicate' && method === 'POST') {
|
|
2883
3151
|
const { filename } = body
|
|
2884
3152
|
|
|
@@ -2887,7 +3155,8 @@ export function Default() {
|
|
|
2887
3155
|
return
|
|
2888
3156
|
}
|
|
2889
3157
|
|
|
2890
|
-
|
|
3158
|
+
const parsed = parseValidFilename(filename)
|
|
3159
|
+
if (!parsed) {
|
|
2891
3160
|
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2892
3161
|
return
|
|
2893
3162
|
}
|
|
@@ -2899,20 +3168,23 @@ export function Default() {
|
|
|
2899
3168
|
}
|
|
2900
3169
|
|
|
2901
3170
|
try {
|
|
2902
|
-
const
|
|
3171
|
+
const base = parsed.basename
|
|
3172
|
+
const ext = path.extname(base)
|
|
2903
3173
|
const now = new Date()
|
|
2904
3174
|
const pad = (n) => String(n).padStart(2, '0')
|
|
2905
3175
|
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
2906
|
-
// Preserve privacy prefix
|
|
2907
|
-
const prefix =
|
|
2908
|
-
const baseName =
|
|
3176
|
+
// Preserve privacy prefix on the basename
|
|
3177
|
+
const prefix = base.startsWith('~') ? '~' : ''
|
|
3178
|
+
const baseName = base.replace(/^~/, '').replace(ext, '')
|
|
2909
3179
|
// Extract canvas prefix (everything before the date pattern or the full base)
|
|
2910
3180
|
const canvasMatch = baseName.match(/^(.+?--)\d{4}-/)
|
|
2911
3181
|
const canvasPrefix = canvasMatch ? canvasMatch[1] : ''
|
|
2912
|
-
const
|
|
3182
|
+
const newBase = `${prefix}${canvasPrefix}${dateStr}${ext}`
|
|
2913
3183
|
const targetDir = path.dirname(sourcePath)
|
|
2914
|
-
fs.copyFileSync(sourcePath, path.join(targetDir,
|
|
2915
|
-
|
|
3184
|
+
fs.copyFileSync(sourcePath, path.join(targetDir, newBase))
|
|
3185
|
+
// Return the filename with the same drafts/ prefix as the source
|
|
3186
|
+
const responseFilename = parsed.dirSegment === 'drafts' ? `drafts/${newBase}` : newBase
|
|
3187
|
+
sendJson(res, 201, { success: true, filename: responseFilename })
|
|
2916
3188
|
} catch (err) {
|
|
2917
3189
|
sendJson(res, 500, { error: `Failed to duplicate image: ${err.message}` })
|
|
2918
3190
|
}
|
|
@@ -2928,24 +3200,28 @@ export function Default() {
|
|
|
2928
3200
|
return
|
|
2929
3201
|
}
|
|
2930
3202
|
|
|
2931
|
-
|
|
3203
|
+
const parsed = parseValidFilename(filename)
|
|
3204
|
+
if (!parsed) {
|
|
2932
3205
|
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2933
3206
|
return
|
|
2934
3207
|
}
|
|
2935
3208
|
|
|
2936
|
-
const
|
|
2937
|
-
const
|
|
3209
|
+
const base = parsed.basename
|
|
3210
|
+
const isPrivate = base.startsWith('~')
|
|
3211
|
+
const newBase = isPrivate ? base.slice(1) : `~${base}`
|
|
2938
3212
|
const oldPath = resolveImagePath(filename)
|
|
2939
3213
|
if (!oldPath) {
|
|
2940
3214
|
sendJson(res, 404, { error: 'Image not found' })
|
|
2941
3215
|
return
|
|
2942
3216
|
}
|
|
2943
3217
|
const parentDir = path.dirname(oldPath)
|
|
2944
|
-
const newPath = path.join(parentDir,
|
|
3218
|
+
const newPath = path.join(parentDir, newBase)
|
|
3219
|
+
// Preserve the `drafts/` segment on the returned filename
|
|
3220
|
+
const responseFilename = parsed.dirSegment === 'drafts' ? `drafts/${newBase}` : newBase
|
|
2945
3221
|
|
|
2946
3222
|
try {
|
|
2947
3223
|
fs.renameSync(oldPath, newPath)
|
|
2948
|
-
sendJson(res, 200, { success: true, filename:
|
|
3224
|
+
sendJson(res, 200, { success: true, filename: responseFilename, private: !isPrivate })
|
|
2949
3225
|
} catch (err) {
|
|
2950
3226
|
sendJson(res, 500, { error: `Failed to toggle private: ${err.message}` })
|
|
2951
3227
|
}
|