@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Commands → Rail: the actions rail, arranged.
3
+ *
4
+ * One page per scope — the global rail, then each workspace — showing the groups that scope has actually
5
+ * drawn, with their entries. Drag an entry to another group, or into Hidden; drag a group to move the
6
+ * whole group; add a link or a button of your own. It is the same shape as Commands → Buttons, because it
7
+ * is the same question asked about a different strip of the screen.
8
+ *
9
+ * What it can list is what the rail has SEEN (rail-layout.js): the rail differs per page — a files
10
+ * workspace has Sync, a project workspace has Screens and Run — so the entries offered for a scope are
11
+ * the ones its pages have rendered. Open a workspace once and its rail is arrangeable.
12
+ */
13
+
14
+ const { esc } = require('./html');
15
+ const { loadWorkspaces } = require('./workspaces');
16
+ const rail = require('./navigation');
17
+ const cc = require('./commands');
18
+
19
+ const icon = (name, ratio = 0.7) => `<span uk-icon="icon: ${name}; ratio: ${ratio}"></span>`;
20
+
21
+ /** The scope picker: global first, then every workspace, each saying whether it has anything of its own. */
22
+ function scopeTabsHtml(scope) {
23
+ const raw = rail.loadLayout().raw || {};
24
+ const has = (s) => {
25
+ const l = rail.scopeLayout(raw, s);
26
+ return l.hidden.length || l.groups.length || l.items.length || Object.keys(l.order).length;
27
+ };
28
+ const tab = (s, label) => `<a class="uk-button uk-button-${scope === s ? 'primary' : 'default'} uk-button-small settings-tab"
29
+ href="/settings/navigation?scope=${encodeURIComponent(s)}"${scope === s ? ' aria-current="page"' : ''}
30
+ title="${esc(s === 'global' ? 'The rail on every page' : `The rail on the ${label} pages, applied after the global one`)}">${esc(label)}${has(s) ? ' •' : ''}</a>`;
31
+ const workspaces = Object.values(loadWorkspaces());
32
+ return `<p class="settings-tabs nav-scopes">
33
+ ${tab('global', 'Every page')}
34
+ ${workspaces.map((w) => tab(w.key, w.label)).join('')}
35
+ </p>`;
36
+ }
37
+
38
+ /** One entry, as a card that can be dragged between groups. */
39
+ function entryCard(id, label, group, custom, shownAs) {
40
+ return `<li class="nav-entry" data-id="${esc(id)}">
41
+ <input type="hidden" name="item" value="${esc(`${group}:${id}`)}">
42
+ <span class="nav-handle" title="Drag to move it, or into Hidden">${icon('table')}</span>
43
+ <span class="nav-entry-label">${esc(label)}</span>
44
+ <!-- How much of the entry is shown, in whichever place it sits: that is about how much room the
45
+ entry is worth, not about which strip of the screen it is on. Both halves stay in the markup
46
+ whichever is picked, so a screen reader still reads the name — this is presentation only. -->
47
+ <select class="nav-entry-show" name="show" title="How much of ${esc(label)} is shown"
48
+ aria-label="How much of ${esc(label)} is shown">
49
+ <option value="${esc(id)}:both"${shownAs === 'both' ? ' selected' : ''}>icon + text</option>
50
+ <option value="${esc(id)}:icon"${shownAs === 'icon' ? ' selected' : ''}>icon only</option>
51
+ <option value="${esc(id)}:text"${shownAs === 'text' ? ' selected' : ''}>text only</option>
52
+ </select>
53
+ ${custom ? `<span class="uk-label nav-yours" title="An entry you added">yours</span>
54
+ <button type="button" class="uk-button uk-button-link nav-remove arm-step" title="Remove ${esc(label)}"
55
+ hx-post="/actions/nav-remove" hx-vals='${esc(JSON.stringify({ id, scope: custom.scope }))}'
56
+ hx-include="#nav-form" hx-target="#nav-editor" hx-swap="outerHTML"
57
+ hx-on::before:request="window.navSyncGroups && window.navSyncGroups()"
58
+ hx-trigger="confirmed-remove">${icon('trash')}</button>` : ''}
59
+ </li>`;
60
+ }
61
+
62
+ /**
63
+ * The editor for one scope. Everything is in one form: dragging is not saved until Save, and the two
64
+ * add forms post the arrangement with them, so a drag is never lost by adding something.
65
+ */
66
+ function navEditorHtml(scope, message) {
67
+ const { raw, mtime } = rail.loadLayout();
68
+ const own = rail.scopeLayout(raw, scope);
69
+ const seen = rail.seenFor(scope);
70
+ const hidden = new Set(own.hidden);
71
+ const customById = new Map(own.items.map((it) => [it.id, it]));
72
+
73
+ // The groups this scope has drawn, in the order it has them, then anything the layout names that the
74
+ // rail has not drawn lately — kept, so a hidden entry survives a page that has not been opened yet.
75
+ // What each region holds: what the layout put there, then what the navigation has actually DRAWN
76
+ // there. Without the second half a placement the dashboard ships — the assistant, the version, the
77
+ // screens count — is invisible here, so saving wrote it into a sidebar group and the default it was
78
+ // sitting on stopped applying. The editor has to be able to see what it is arranging.
79
+ const regionIds = Object.fromEntries(rail.REGIONS.map((r) => {
80
+ const drawn = Object.keys(((seen[`topbar-${r}`]) || {}).items || {});
81
+ return [r, [...new Set([...own.topbar[r], ...drawn])].filter((id) => !hidden.has(id))];
82
+ }));
83
+ const inTopbar = new Set(rail.REGIONS.flatMap((r) => regionIds[r]));
84
+ // The sidebar's own groups. The topbar regions are recorded in the same registry so their entries keep
85
+ // their labels, but they are drawn as their own lists below, not as sidebar groups.
86
+ const groupIds = [...new Set([...own.groups, ...Object.keys(seen)])]
87
+ .filter((g) => !rail.TOPBAR_GROUPS.includes(g))
88
+ .filter((g) => seen[g] || own.order[g]);
89
+ const groups = groupIds.map((g) => {
90
+ const label = (seen[g] && seen[g].label) || g;
91
+ const ids = [...new Set([...(own.order[g] || []), ...Object.keys((seen[g] || {}).items || {})])]
92
+ .filter((id) => !hidden.has(id) && !inTopbar.has(id));
93
+ return { id: g, label, ids };
94
+ });
95
+ // An entry you added shows in the group it joins, the same rule the rail applies — without this it
96
+ // renders on the rail but not here, so it could be neither moved nor removed.
97
+ const byId = new Map(groups.map((g) => [g.id, g]));
98
+ for (const item of own.items) {
99
+ if (hidden.has(item.id) || inTopbar.has(item.id)) continue;
100
+ const asked = rail.regionOf(item.group);
101
+ if (asked) { regionIds[asked].push(item.id); inTopbar.add(item.id); continue; }
102
+ const wanted = rail.isId(item.group) ? item.group : 'yours';
103
+ const g = byId.get(wanted);
104
+ if (g) { if (!g.ids.includes(item.id)) g.ids.push(item.id); continue; }
105
+ const made = { id: wanted, label: wanted === 'yours' ? 'Yours' : item.group, ids: [item.id] };
106
+ groups.push(made);
107
+ byId.set(wanted, made);
108
+ }
109
+
110
+ const seenItem = (id) => {
111
+ for (const g of Object.values(seen)) if (g.items && g.items[id] !== undefined) return g.items[id];
112
+ return undefined;
113
+ };
114
+ const labelOf = (id) => {
115
+ const v = seenItem(id);
116
+ if (v !== undefined) return rail.seenLabel(v);
117
+ const c = customById.get(id);
118
+ return c ? c.label : id;
119
+ };
120
+ // What this entry ships as, so the control can offer to put back what the dashboard left out.
121
+ const shipsAs = (id) => rail.seenShips(seenItem(id));
122
+
123
+ // What it is right now: the layout's answer if there is one, otherwise the way the entry ships.
124
+ const shownAs = (id) => {
125
+ const asked = own.show[id];
126
+ if (asked) return asked === 'both' ? 'both' : asked;
127
+ return shipsAs(id) || 'both';
128
+ };
129
+ const hiddenCards = own.hidden.map((id) => entryCard(id, labelOf(id), 'hidden',
130
+ customById.has(id) ? { scope } : null, shownAs(id))).join('');
131
+
132
+ const commands = cc.loadCatalog().commands
133
+ .filter((c) => c.on === 'workspace' || c.on === 'project')
134
+ .map((c) => `<option value="${esc(c.id)}">${esc(c.label)} — ${esc(cc.ON[c.on] || c.on)}</option>`).join('');
135
+
136
+ return `<div id="nav-editor">
137
+ ${message || ''}
138
+ ${scopeTabsHtml(scope)}
139
+ <p class="uk-text-meta">${scope === 'global'
140
+ ? 'The navigation on every page. A workspace can add to this, or hide something from it, on its own tab.'
141
+ : `The navigation on the ${esc((loadWorkspaces()[scope] || {}).label || scope)} pages. Applied after the global one.`}
142
+ Drag an entry between the sidebar groups, up into a region of the top bar, or into Hidden; drag a
143
+ group by its handle to move the whole group. Each entry says how much of itself is shown — the icon,
144
+ the text, or both — wherever it sits. Hiding is presentation only: what an entry opened still answers.</p>
145
+
146
+ <!-- The group each entry posts is rewritten from the list it sits in, just before the request:
147
+ dragging moves the element, not the value it was rendered with. -->
148
+ <form id="nav-form" hx-post="/actions/nav-save" hx-target="#nav-editor" hx-swap="outerHTML"
149
+ hx-on::before:request="window.navSyncGroups && window.navSyncGroups()">
150
+ <input type="hidden" name="scope" value="${esc(scope)}">
151
+ <input type="hidden" name="mtime" value="${mtime}">
152
+ <section class="nav-place nav-place-topbar">
153
+ <header class="nav-place-head"><strong>Navigation topbar</strong>
154
+ <span class="uk-text-meta">· across the top of every page, in three regions</span></header>
155
+ <div class="nav-regions">
156
+ ${rail.REGIONS.map((r) => `<div class="nav-region" data-region="${r}">
157
+ <span class="nav-region-label">${esc(rail.TOPBAR_LABELS[r].replace('Topbar ', ''))}</span>
158
+ <ol class="nav-entries" data-group="topbar-${r}" uk-sortable="group: nav-entries; handle: .nav-handle">
159
+ ${regionIds[r]
160
+ .map((id) => entryCard(id, labelOf(id), `topbar-${r}`, customById.has(id) ? { scope } : null, shownAs(id))).join('')}
161
+ </ol>
162
+ </div>`).join('')}
163
+ </div>
164
+ </section>
165
+
166
+ <header class="nav-place-head"><strong>Navigation sidebar</strong>
167
+ <span class="uk-text-meta">· the rail down the right of every page</span></header>
168
+ <ol class="nav-groups" uk-sortable="handle: .nav-handle-group">
169
+ ${groups.map((g) => `<li class="nav-group" data-group="${esc(g.id)}">
170
+ <input type="hidden" name="group" value="${esc(g.id)}">
171
+ <header class="nav-group-head">
172
+ <span class="nav-handle nav-handle-group" title="Drag to move this group">${icon('table')}</span>
173
+ <strong>${esc(g.label)}</strong>
174
+ </header>
175
+ <ol class="nav-entries" data-group="${esc(g.id)}" uk-sortable="group: nav-entries; handle: .nav-handle">
176
+ ${g.ids.map((id) => entryCard(id, labelOf(id), g.id, customById.has(id) ? { scope } : null, shownAs(id))).join('')}
177
+ </ol>
178
+ </li>`).join('')}
179
+ </ol>
180
+
181
+ <section class="nav-group nav-group-hidden">
182
+ <header class="nav-group-head"><strong>Hidden</strong> <span class="uk-text-meta">· not shown on these pages</span></header>
183
+ <ol class="nav-entries" data-group="hidden" uk-sortable="group: nav-entries; handle: .nav-handle">${hiddenCards}</ol>
184
+ </section>
185
+
186
+ <p class="nav-save">
187
+ <button class="uk-button uk-button-primary" type="submit">${icon('check')} Save the navigation</button>
188
+ <!-- Adding opens in the editor modal, like every other form on a long page here: the add card
189
+ sat under a list of groups that can run past the fold, so the way to put something IN the
190
+ navigation was the one thing you had to scroll to find. The form still posts the whole
191
+ arrangement with it (hx-include), so a drag that has not been saved is not lost by adding. -->
192
+ <button class="uk-button uk-button-default" type="button" title="Add a link or a command button"
193
+ hx-get="/fragments/nav-add?scope=${encodeURIComponent(scope)}"
194
+ hx-target="#editor-modal-body" hx-swap="innerHTML"
195
+ hx-on::after:request="event.stopPropagation()">${icon('plus')} Add to this navigation</button>
196
+ <button class="uk-button uk-button-default arm-step" type="button" title="Reset this scope"
197
+ hx-post="/actions/nav-reset" hx-vals='${esc(JSON.stringify({ scope }))}'
198
+ hx-target="#nav-editor" hx-swap="outerHTML" hx-trigger="confirmed-remove">${icon('refresh')} Reset</button>
199
+ </p>
200
+ </form>
201
+
202
+ </div>`;
203
+ }
204
+
205
+
206
+ /**
207
+ * The Add form, drawn into the editor modal.
208
+ *
209
+ * It posts the whole arrangement with it (`hx-include="#nav-form"`, which is still on the page behind
210
+ * the modal), so a drag that has not been saved yet is not lost by adding something. The answer is the
211
+ * editor, which is what the modal is covering — so the modal closes on the way out, success or not: a
212
+ * refusal comes back as the editor with the reason at the top of it.
213
+ */
214
+ function navAddFormHtml(scope) {
215
+ const { raw } = rail.loadLayout();
216
+ const seen = rail.seenFor(scope);
217
+ const own = rail.scopeLayout(raw, scope);
218
+ const groups = [...new Set([...own.groups, ...Object.keys(seen)])]
219
+ .filter((g) => !rail.TOPBAR_GROUPS.includes(g))
220
+ .map((g) => ({ id: g, label: (seen[g] && seen[g].label) || g }));
221
+ const commands = cc.loadCatalog().commands
222
+ .filter((c) => c.on === 'workspace' || c.on === 'project')
223
+ .map((c) => `<option value="${esc(c.id)}">${esc(c.label)} — ${esc(cc.ON[c.on] || c.on)}</option>`).join('');
224
+
225
+ return `<div class="editor-card nav-add">
226
+ <h3 class="uk-margin-small-bottom">Add to this navigation</h3>
227
+ <p class="uk-text-meta">A link goes somewhere; a button runs a command from the catalog, with the same
228
+ confirm dialog and the same history a row action has. An address outside this dashboard opens in a new tab.</p>
229
+ <form class="nav-add-form" hx-post="/actions/nav-add" hx-include="#nav-form"
230
+ hx-target="#nav-editor" hx-swap="outerHTML"
231
+ hx-on::before:request="window.navSyncGroups && window.navSyncGroups()"
232
+ hx-on::after:request="window.UIkit && UIkit.modal('#editor-modal').hide()">
233
+ <div class="uk-grid-small uk-child-width-1-2@s" uk-grid>
234
+ <div><label class="uk-form-label" for="nav-add-label">Label</label>
235
+ <input class="uk-input uk-form-small" id="nav-add-label" name="new_label" required placeholder="Jira" maxlength="40"></div>
236
+ <div><label class="uk-form-label" for="nav-add-icon">Icon</label>
237
+ <input class="uk-input uk-form-small" id="nav-add-icon" name="new_icon" placeholder="link" maxlength="40"
238
+ title="A UIKit icon name, or tabler:<name>"></div>
239
+ <div><label class="uk-form-label" for="nav-add-group">Where it goes</label>
240
+ <select class="uk-select uk-form-small" id="nav-add-group" name="new_group">
241
+ <optgroup label="The navigation topbar">
242
+ ${rail.REGIONS.map((r) => `<option value="topbar-${r}">${esc(rail.TOPBAR_LABELS[r])}</option>`).join('')}
243
+ </optgroup>
244
+ <optgroup label="The navigation sidebar">
245
+ <option value="yours" selected>Yours — a group of its own, at the end</option>
246
+ ${groups.map((g) => `<option value="${esc(g.id)}">${esc(g.label)}</option>`).join('')}
247
+ </optgroup>
248
+ </select></div>
249
+ <div><label class="uk-form-label" for="nav-add-href">The address it opens</label>
250
+ <input class="uk-input uk-form-small" id="nav-add-href" name="new_href" placeholder="https://CHANGE_ME.atlassian.net"></div>
251
+ <div><label class="uk-form-label" for="nav-add-use">…or the command it runs</label>
252
+ <select class="uk-select uk-form-small" id="nav-add-use" name="new_use">
253
+ <option value="">— a link, not a command —</option>
254
+ ${commands}
255
+ </select></div>
256
+ <div><label class="uk-form-label uk-margin-small-top">
257
+ <input class="uk-checkbox" type="checkbox" name="new_confirm" value="1"> Ask before it runs</label></div>
258
+ </div>
259
+ <p><button class="uk-button uk-button-primary" type="submit">${icon('plus')} Add it</button></p>
260
+ </form>
261
+ </div>`;
262
+ }
263
+
264
+ module.exports = { navEditorHtml, navAddFormHtml, scopeTabsHtml };
package/navigation.js ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The navigation, as a layout: which of its entries are shown, WHERE, in what order, and what you added.
3
+ *
4
+ * There are two places an entry can be. The **navigation sidebar** is the rail down the right of every
5
+ * page, grouped by concern. The **navigation topbar** is the bar across the top, in three regions —
6
+ * start (beside the wordmark), center, end (before the theme toggle) — which is where the few things
7
+ * someone reaches for on every page belong, and where a bare icon reads as a tool rather than a link.
8
+ * An entry moves between them by being dragged, and an entry added can be put straight into either.
9
+ *
10
+ * The rail is on every page and it is built in code — a group per concern (Create, Sync, Screens, Run,
11
+ * Archives, Go) with the page's own actions first and the navigation last. That is a good default and a
12
+ * bad fixed set: a machine that never deploys does not want Upsun in it, a team lives in Jira and would
13
+ * put it there, and which of nineteen workspaces you actually open differs per person.
14
+ *
15
+ * So it is arranged the way the project rows are (row-layout.js), with the same shape and the same
16
+ * rules — one file for this machine, holding only what someone changed:
17
+ *
18
+ * core/config/navigation.yml
19
+ * global: applies on every page
20
+ * hidden: [ids…] entries not shown
21
+ * groups: [group ids…] the order of the sidebar groups; anything unlisted keeps its place after them
22
+ * order: { <group>: [ids…] } the order inside one group
23
+ * topbar: { start: [ids…], center: [ids…], end: [ids…] } entries moved up to the top bar
24
+ * show: { <id>: icon | text } entries shown as the icon alone, or as the label alone —
25
+ * absent means both, which is what every entry is until someone says
26
+ * items: [item…] entries you added
27
+ * workspaces:
28
+ * dev: { the same four keys } applied after global, on that workspace's pages
29
+ *
30
+ * An added entry is a link or a button:
31
+ *
32
+ * id, label, icon, group, description
33
+ * href: where it goes (a link) — external URLs open in a new tab
34
+ * use: <command id> — runs that command of the catalog instead, like a row action does
35
+ * confirm: true → asks in the dialog first
36
+ *
37
+ * The defaults live in the code that builds the rail, so an entry added to the dashboard later appears
38
+ * in its own group until someone moves it, and deleting the file is a full reset. `sync-tooling.sh`
39
+ * copies only `workspace.*.settings.yml` into `core/config`, so a `--pull` never touches this.
40
+ *
41
+ * **What the editor can offer is what has been SEEN.** The rail differs per page — a files workspace has
42
+ * Sync, a project workspace has Screens and Run — so nothing here can enumerate the entries from first
43
+ * principles without rendering every page. Each render records what it drew into
44
+ * `core/config/rail.seen.json`, per scope, and the editor lists that. An id that is no longer drawn stays
45
+ * in the file and does nothing, which is what makes a hidden entry survive a dashboard that renamed it.
46
+ */
47
+
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const yaml = require('js-yaml');
51
+
52
+ const { esc } = require('./html');
53
+ const { CONFIG_DIR } = require('./workspaces');
54
+
55
+ // Overridable so a test can arrange a navigation without touching the machine's own, the way
56
+ // row-layout.js is. Read through the functions, never captured at require time.
57
+ const layoutFile = () => process.env.NAVIGATION_FILE || path.join(CONFIG_DIR, 'navigation.yml');
58
+ const seenFile = () => process.env.NAVIGATION_SEEN_FILE || path.join(CONFIG_DIR, 'navigation.seen.json');
59
+
60
+ // An id is a slug: it names an entry in a YAML file a person edits, and it ends up in a CSS selector
61
+ // and a query string. Anything else is refused rather than escaped into unreadability.
62
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
63
+ const isId = (v) => ID_RE.test(String(v || ''));
64
+
65
+ // Where an entry can go besides a sidebar group. These are group ids in the editor — a list you drag
66
+ // into — so the whole arrangement stays one form and one drag, rather than a placement dropdown per
67
+ // entry. `hidden` is the fourth of them: not shown anywhere.
68
+ const REGIONS = ['start', 'center', 'end'];
69
+ const TOPBAR_GROUPS = REGIONS.map((r) => `topbar-${r}`);
70
+ const RESERVED = new Set([...TOPBAR_GROUPS, 'hidden']);
71
+ /** 'topbar-center' → 'center'; anything else → null. */
72
+ const regionOf = (group) => (TOPBAR_GROUPS.includes(String(group)) ? String(group).slice('topbar-'.length) : null);
73
+ const emptyTopbar = () => Object.fromEntries(REGIONS.map((r) => [r, []]));
74
+ const TOPBAR_LABELS = { start: 'Topbar start', center: 'Topbar center', end: 'Topbar end' };
75
+
76
+ /**
77
+ * The id of a rail entry, derived from its label. Counts are stripped — "Backups (2)" and "Run a tool
78
+ * (13)" change with the tree, and a layout keyed on them would forget itself the moment a backup was
79
+ * taken. A caller with a better id passes one.
80
+ */
81
+ function slugId(label) {
82
+ return String(label)
83
+ .replace(/\s*\([^)]*\)\s*$/, '')
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, '-')
86
+ .replace(/^-+|-+$/g, '')
87
+ .slice(0, 48) || 'item';
88
+ }
89
+
90
+ /** Which layout applies on a page: a workspace's own, or the global one. */
91
+ function scopeFor(context) {
92
+ const m = String(context || '').match(/^(?:workspace|backups):([a-z0-9_-]+)$/);
93
+ return m ? m[1] : 'global';
94
+ }
95
+
96
+ const EMPTY = { hidden: [], groups: [], order: {}, topbar: emptyTopbar(), show: {}, items: [] };
97
+
98
+ function readYaml() {
99
+ try {
100
+ return yaml.load(fs.readFileSync(layoutFile(), 'utf8')) || {};
101
+ } catch (_) {
102
+ return {};
103
+ }
104
+ }
105
+
106
+ /** One scope's settings, with every key present so callers never test for undefined. */
107
+ function scopeLayout(raw, scope) {
108
+ const src = (scope === 'global' ? raw.global : ((raw.workspaces || {})[scope])) || {};
109
+ return {
110
+ hidden: (Array.isArray(src.hidden) ? src.hidden : []).filter(isId),
111
+ groups: (Array.isArray(src.groups) ? src.groups : []).filter(isId),
112
+ order: Object.fromEntries(Object.entries(src.order || {})
113
+ .filter(([g, list]) => isId(g) && Array.isArray(list))
114
+ .map(([g, list]) => [g, list.filter(isId)])),
115
+ topbar: Object.fromEntries(REGIONS.map((r) => {
116
+ const list = ((src.topbar || {})[r]);
117
+ return [r, (Array.isArray(list) ? list : []).filter(isId)];
118
+ })),
119
+ show: Object.fromEntries(Object.entries(src.show || {})
120
+ .filter(([id, how]) => isId(id) && (how === 'icon' || how === 'text' || how === 'both'))),
121
+ items: (Array.isArray(src.items) ? src.items : []).filter((it) => it && isId(it.id) && it.label),
122
+ };
123
+ }
124
+
125
+ let memo = { at: 0, mtime: 0, raw: null };
126
+
127
+ function loadLayout() {
128
+ const now = Date.now();
129
+ let mtime = 0;
130
+ try { mtime = fs.statSync(layoutFile()).mtimeMs; } catch (_) { mtime = 0; }
131
+ // Re-read when the file moved, and at most every 2s otherwise: this is on the critical path of every
132
+ // page, and workspaces.js memoises its own config the same way.
133
+ if (!memo.raw || mtime !== memo.mtime || now - memo.at > 2000) {
134
+ memo = { at: now, mtime, raw: readYaml() };
135
+ }
136
+ return { raw: memo.raw, mtime: memo.mtime };
137
+ }
138
+
139
+ /** The two layouts that apply on a page, global first. A workspace's own settings are applied after it. */
140
+ function layoutsFor(scope) {
141
+ const { raw } = loadLayout();
142
+ const global = scopeLayout(raw, 'global');
143
+ return scope === 'global' ? [global] : [global, scopeLayout(raw, scope)];
144
+ }
145
+
146
+ /** Every scope the editor offers: the global one, then each workspace, in the order the cards are in. */
147
+ function navScopes(workspaceKeys) {
148
+ return ['global', ...workspaceKeys];
149
+ }
150
+
151
+ /* ------------------------------------------------------------------ *
152
+ * What the rail has drawn, so the editor can offer it
153
+ * ------------------------------------------------------------------ */
154
+
155
+ function readSeen() {
156
+ try {
157
+ const seen = JSON.parse(fs.readFileSync(seenFile(), 'utf8'));
158
+ return seen && typeof seen === 'object' ? seen : {};
159
+ } catch (_) {
160
+ return {};
161
+ }
162
+ }
163
+
164
+ let seenMemo = null;
165
+
166
+ /**
167
+ * Record what one page's rail drew. Written only when something is new, because this runs on every page
168
+ * render and a file written per request would be a write per page view for no gain.
169
+ */
170
+ function recordSeen(scope, arranged) {
171
+ const seen = seenMemo || (seenMemo = readSeen());
172
+ const bucket = seen[scope] || (seen[scope] = {});
173
+ let changed = false;
174
+ // The top bar is recorded the same way, under its region: an entry moved up there is in no sidebar
175
+ // group any more, and the editor reads its label out of this file.
176
+ const groups = [
177
+ ...arranged.groups,
178
+ ...REGIONS.map((r) => ({ id: `topbar-${r}`, label: TOPBAR_LABELS[r], items: arranged.topbar[r] })),
179
+ ];
180
+ for (const g of groups) {
181
+ const entry = bucket[g.id] || (bucket[g.id] = { label: g.label, items: {} });
182
+ if (entry.label !== g.label) { entry.label = g.label; changed = true; }
183
+ // Rebuilt in the order this page DREW them, with anything seen before appended after — so a hidden
184
+ // entry, or one only another page draws, survives, and the editor lists them in the order they
185
+ // actually appear. Kept as insertion order rather than a list because that is what this file has
186
+ // always been. Only what the dashboard itself builds is recorded: an entry someone added is already
187
+ // in the layout, and recording it too leaves a ghost behind once it is removed.
188
+ // An entry that SHIPS one way — the assistant is its mark and nothing else — records that here, so
189
+ // the editor knows what "both" would be overriding and can offer to put the label back. A plain
190
+ // string is the common case and stays one; only an entry with a default of its own grows an object.
191
+ const items = {};
192
+ for (const it of g.items) if (!it.custom) items[it.id] = it.ships ? { label: it.label, show: it.ships } : it.label;
193
+ for (const [id, was] of Object.entries(entry.items)) if (!(id in items)) items[id] = was;
194
+ if (JSON.stringify(items) !== JSON.stringify(entry.items)) { entry.items = items; changed = true; }
195
+ }
196
+ if (!changed) return;
197
+ try {
198
+ fs.mkdirSync(path.dirname(seenFile()), { recursive: true });
199
+ fs.writeFileSync(seenFile(), JSON.stringify(seen, null, 2));
200
+ } catch (_) { /* a rail that cannot record itself still renders */ }
201
+ }
202
+
203
+ /** A seen item is a label, or a label with the way that entry ships. */
204
+ const seenLabel = (v) => (v && typeof v === 'object' ? v.label : v);
205
+ const seenShips = (v) => (v && typeof v === 'object' ? (v.show || '') : '');
206
+
207
+ /** What the editor lists for one scope: every group and entry that scope has actually drawn. */
208
+ function seenFor(scope) {
209
+ const seen = seenMemo || (seenMemo = readSeen());
210
+ return seen[scope] || {};
211
+ }
212
+
213
+ /* ------------------------------------------------------------------ *
214
+ * Applying a layout to the groups the page built
215
+ * ------------------------------------------------------------------ */
216
+
217
+ /**
218
+ * An entry someone added, as the rail's own markup. A link goes where it points; a `use:` runs a command
219
+ * of the catalog through the same endpoint a row action does, so there is one runner and one history.
220
+ */
221
+ function customItemHtml(item) {
222
+ const icon = isId(item.icon) || /^tabler:[a-z0-9-]+$/.test(String(item.icon || '')) ? item.icon : 'link';
223
+ const label = `<span uk-icon="icon: ${esc(String(icon).replace(/^tabler:/, ''))}; ratio: .8"></span>`
224
+ + `<span class="action-label">${esc(item.label)}</span>`;
225
+ const title = esc(item.description || item.label);
226
+ if (item.use) {
227
+ // The same route, the same confirm dialog and the same history as a row action's `use:` step.
228
+ const vals = esc(JSON.stringify({ id: `rail:${item.id}`, use: item.use }));
229
+ return `<button class="uk-button uk-button-default action-btn${item.confirm ? ' arm-step' : ''}" type="button"`
230
+ + ` title="${title}" hx-post="/actions/nav-run" hx-vals='${vals}'`
231
+ + ` hx-target="#webship-workspace-output" hx-swap="innerHTML"`
232
+ + `${item.confirm ? ' hx-trigger="confirmed-remove"' : ''}>${label}</button>`;
233
+ }
234
+ const href = String(item.href || '');
235
+ // An address off this dashboard opens in a new tab: the rail is navigation within the workspace, and
236
+ // replacing the page you are working on with an external site is not what a rail entry should do.
237
+ const external = /^https?:\/\//i.test(href) && !href.includes('.ddev.site');
238
+ return `<a class="uk-button uk-button-default action-btn" href="${esc(href)}" title="${title}"`
239
+ + `${external ? ' target="_blank" rel="noopener"' : ''}>${label}</a>`;
240
+ }
241
+
242
+ /**
243
+ * Hide, reorder and add — global first, then the workspace's own.
244
+ *
245
+ * Everything here is presentation: a hidden entry's route still answers exactly as it did, and an entry
246
+ * the dashboard no longer draws simply does not appear, rather than leaving a hole or an error.
247
+ */
248
+ function arrange(groups, scope) {
249
+ const layouts = layoutsFor(scope);
250
+ const hidden = new Set(layouts.flatMap((l) => l.hidden));
251
+ const groupOrder = layouts.map((l) => l.groups).filter((g) => g.length).pop() || [];
252
+ const itemOrder = Object.assign({}, ...layouts.map((l) => l.order));
253
+ const added = layouts.flatMap((l) => l.items);
254
+
255
+ // Which entries were moved to the top bar, and to which region. A later scope wins outright: a
256
+ // workspace that puts something in the topbar means that page, not "as well as wherever global had it".
257
+ const region = new Map();
258
+ for (const l of layouts) {
259
+ for (const r of REGIONS) for (const id of l.topbar[r]) region.set(id, r);
260
+ }
261
+ // Every id the layout has an opinion about. An entry the dashboard SHIPS in the top bar (an entry with
262
+ // its own `topbar:`) stays there until someone says otherwise — and naming it anywhere, even by
263
+ // dragging it into a sidebar group, is saying otherwise.
264
+ const named = new Set([
265
+ ...hidden,
266
+ ...region.keys(),
267
+ ...layouts.flatMap((l) => Object.values(l.order).flat()),
268
+ ]);
269
+ const topbar = emptyTopbar();
270
+ // The icon alone, the label alone, or both — in whichever place the entry sits, because that is about
271
+ // how much room the entry is worth, not about which strip of the screen it is on. Both is what every
272
+ // entry is until someone says otherwise, and an entry that ships one way (the assistant is its mark
273
+ // and nothing else) keeps it until the layout names it.
274
+ const show = Object.assign({}, ...layouts.map((l) => l.show));
275
+ // `ships` is what the dashboard built the entry as; `show` is what it ends up as. An explicit `both`
276
+ // in the layout is what turns a shipped default back off, which is why both is written down when it
277
+ // overrides something and left out when it does not.
278
+ const shownAs = (it) => {
279
+ const asked = show[it.id];
280
+ if (asked) return asked === 'both' ? '' : asked;
281
+ return it.show || '';
282
+ };
283
+
284
+ let out = groups.map((g) => ({
285
+ ...g,
286
+ items: g.items.filter((it) => !hidden.has(it.id))
287
+ .map((it) => ({ ...it, ships: it.show || '', show: shownAs(it) })),
288
+ }));
289
+
290
+ // An added entry joins the group it names, or makes one of its own at the end — a group that exists
291
+ // only on some pages (Sync, Screens) must not swallow an entry on the pages that lack it. Naming a
292
+ // topbar region instead puts it up there.
293
+ for (const item of added) {
294
+ if (hidden.has(item.id)) continue;
295
+ const entry = { id: item.id, label: item.label, html: customItemHtml(item), custom: true };
296
+ entry.show = shownAs(entry);
297
+ const asked = regionOf(item.group);
298
+ if (asked && !region.has(item.id)) region.set(item.id, asked);
299
+ if (region.has(item.id)) { topbar[region.get(item.id)].push(entry); continue; }
300
+ const wanted = isId(item.group) ? item.group : 'yours';
301
+ const target = out.find((g) => g.id === wanted);
302
+ if (target) target.items.push(entry);
303
+ else out.push({ id: wanted, label: item.group_label || (wanted === 'yours' ? 'Yours' : item.group), items: [entry] });
304
+ }
305
+
306
+ // Anything else the layout moved up leaves the group it was built in. The sidebar and the topbar are
307
+ // one arrangement, so an entry is in exactly one of them.
308
+ const placeOf = (it) => region.get(it.id)
309
+ || (!named.has(it.id) && REGIONS.includes(it.topbar) ? it.topbar : null);
310
+ for (const g of out) {
311
+ const moved = g.items.filter((it) => placeOf(it) && !it.custom);
312
+ if (!moved.length) continue;
313
+ g.items = g.items.filter((it) => !placeOf(it) || it.custom);
314
+ for (const it of moved) topbar[placeOf(it)].push(it);
315
+ }
316
+
317
+ for (const g of out) {
318
+ const order = itemOrder[g.id];
319
+ if (!order || !order.length) continue;
320
+ const rank = new Map(order.map((id, i) => [id, i]));
321
+ // Anything unlisted keeps its place AFTER what was ordered, in the order the page built it: a new
322
+ // entry in the dashboard must not disappear into the middle of somebody's arrangement.
323
+ g.items = [...g.items].sort((a, b) =>
324
+ (rank.has(a.id) ? rank.get(a.id) : order.length + 1) - (rank.has(b.id) ? rank.get(b.id) : order.length + 1));
325
+ }
326
+
327
+ // Each region is ordered by the list that put the entries there, newest scope last.
328
+ const wanted = emptyTopbar();
329
+ for (const l of layouts) for (const r of REGIONS) for (const id of l.topbar[r]) if (!wanted[r].includes(id)) wanted[r].push(id);
330
+ for (const r of REGIONS) {
331
+ const rank = new Map(wanted[r].map((id, i) => [id, i]));
332
+ topbar[r].sort((a, b) =>
333
+ (rank.has(a.id) ? rank.get(a.id) : wanted[r].length + 1) - (rank.has(b.id) ? rank.get(b.id) : wanted[r].length + 1));
334
+ }
335
+
336
+ if (groupOrder.length) {
337
+ const rank = new Map(groupOrder.map((id, i) => [id, i]));
338
+ out = [...out].sort((a, b) =>
339
+ (rank.has(a.id) ? rank.get(a.id) : groupOrder.length + 1) - (rank.has(b.id) ? rank.get(b.id) : groupOrder.length + 1));
340
+ }
341
+
342
+ return { groups: out.filter((g) => g.items.length && !hidden.has(g.id)), topbar };
343
+ }
344
+
345
+ /* ------------------------------------------------------------------ *
346
+ * Writing
347
+ * ------------------------------------------------------------------ */
348
+
349
+ function saveLayout(raw) {
350
+ const body = '# The navigation: what the sidebar and the top bar show, in what order, and what you added.\n'
351
+ + '# Written by Commands → Navigation. Only what you changed is here; deleting this file is a full reset.\n'
352
+ + '# Never synced: sync-tooling.sh copies only workspace.*.settings.yml into core/config.\n'
353
+ + yaml.dump(raw, { lineWidth: 100 });
354
+ fs.mkdirSync(path.dirname(layoutFile()), { recursive: true });
355
+ fs.writeFileSync(layoutFile(), body);
356
+ memo = { at: 0, mtime: 0, raw: null };
357
+ return fs.statSync(layoutFile()).mtimeMs;
358
+ }
359
+
360
+ /** The whole file, for a writer that is about to change one scope in it. */
361
+ function rawLayout() {
362
+ const { raw, mtime } = loadLayout();
363
+ return { raw: JSON.parse(JSON.stringify(raw || {})), mtime };
364
+ }
365
+
366
+ /** Put one scope's settings back into the whole file, dropping what is empty so the file stays readable. */
367
+ function withScope(raw, scope, settings) {
368
+ const clean = {};
369
+ if (settings.hidden && settings.hidden.length) clean.hidden = settings.hidden;
370
+ if (settings.groups && settings.groups.length) clean.groups = settings.groups;
371
+ if (settings.order && Object.keys(settings.order).length) clean.order = settings.order;
372
+ const topbar = Object.fromEntries(REGIONS.map((r) => [r, (settings.topbar || {})[r] || []]).filter(([, v]) => v.length));
373
+ if (Object.keys(topbar).length) clean.topbar = topbar;
374
+ if (settings.show && Object.keys(settings.show).length) clean.show = settings.show;
375
+ if (settings.items && settings.items.length) clean.items = settings.items;
376
+ const out = { ...raw };
377
+ if (scope === 'global') {
378
+ if (Object.keys(clean).length) out.global = clean; else delete out.global;
379
+ } else {
380
+ const ws = { ...(out.workspaces || {}) };
381
+ if (Object.keys(clean).length) ws[scope] = clean; else delete ws[scope];
382
+ if (Object.keys(ws).length) out.workspaces = ws; else delete out.workspaces;
383
+ }
384
+ return out;
385
+ }
386
+
387
+ module.exports = {
388
+ layoutFile, seenFile,
389
+ isId, slugId, scopeFor, navScopes,
390
+ REGIONS, TOPBAR_GROUPS, TOPBAR_LABELS, RESERVED, regionOf, emptyTopbar,
391
+ loadLayout, scopeLayout, layoutsFor, rawLayout, withScope, saveLayout,
392
+ arrange, customItemHtml, seenLabel, seenShips,
393
+ recordSeen, seenFor,
394
+ };
package/notes.js ADDED
Binary file