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

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 (311) 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/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
package/obsidian.js ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * The Obsidian vault a graphed project can have, and the menu entries that make and read it.
3
+ *
4
+ * A vault is made FROM the graph — `cmd-tools-obsidian.sh` reads `graph.json` and writes notes,
5
+ * wikilinks and a JSON Canvas — so it lives inside the graph store at
6
+ * `graphs/<workspace>/<project>/obsidian/` and is thrown away with it. It also means the entries
7
+ * belong in the Graph menu rather than a menu of their own: without a graph there is nothing to
8
+ * convert, and a second button that is disabled most of the time only adds noise to the row.
9
+ *
10
+ * `views.js` builds the row's menu from this and `server.js` serves what the menu points at, so
11
+ * the menu cannot offer a file the server will not find.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const { ROOT } = require('./workspaces');
18
+ const { esc } = require('./html');
19
+ const { graphStoreDir } = require('./graphs');
20
+
21
+ // Where a project's vault lives. Inside its graph, never inside the project.
22
+ function vaultDir(wsKey, project) {
23
+ return path.join(graphStoreDir(wsKey, project), 'obsidian');
24
+ }
25
+
26
+ // The index note is named after the project, with the same sanitising the builder applies, so the
27
+ // server and the builder agree on one filename.
28
+ function indexNote(project) {
29
+ return `${String(project).replace(/[\\/:*?"<>|[\]#^]+/g, '-').replace(/^[ .-]+|[ .-]+$/g, '').slice(0, 120) || 'unnamed'}.md`;
30
+ }
31
+
32
+ // Same memo window as the graph's: a row re-renders once a second while a job runs, and asking the
33
+ // filesystem per render turns a list of twenty projects into a stream of stat calls.
34
+ let _cache = new Map();
35
+ let _cacheAt = 0;
36
+ function hasVault(wsKey, project) {
37
+ const now = Date.now();
38
+ if (now - _cacheAt > 2000) { _cache = new Map(); _cacheAt = now; }
39
+ const k = `${wsKey}/${project}`;
40
+ if (!_cache.has(k)) _cache.set(k, fs.existsSync(path.join(vaultDir(wsKey, project), '.obsidian')));
41
+ return _cache.get(k);
42
+ }
43
+
44
+ /**
45
+ * The Obsidian entries of the Graph menu.
46
+ *
47
+ * Returned as a list of `<li>` strings rather than a menu, because they hang off the Graph button:
48
+ * `graphMenuHtml` decides where they sit and only asks for them once a graph exists.
49
+ */
50
+ function obsidianMenuItems(key, project, vals, busy) {
51
+ const built = hasVault(key, project);
52
+ const items = ['<li class="uk-nav-divider"></li>', '<li class="uk-nav-header">Obsidian</li>'];
53
+
54
+ items.push(`<li><a href hx-post="/actions/obsidian" ${vals()} class="${busy ? 'uk-disabled' : ''}"><span uk-icon="icon: file-edit; ratio: .7"></span> ${built ? 'Rebuild the vault' : 'Build the vault'}${busy ? ' (running…)' : ''}</a></li>`);
55
+
56
+ if (built) {
57
+ // A desktop protocol link, not an HTTP one: it hands the absolute path to the Obsidian app on
58
+ // the machine running the browser. Every reserved character has to be encoded — an unescaped
59
+ // slash makes Obsidian read the path as a vault name and open the wrong thing, or nothing.
60
+ const uri = `obsidian://open?path=${encodeURIComponent(vaultDir(key, project))}`;
61
+ items.push(`<li><a href="${esc(uri)}" title="Opens the Obsidian app on this machine"><span uk-icon="icon: link-external; ratio: .7"></span> Open in Obsidian</a></li>`);
62
+ // The reader first: the vault lives on the machine running the dashboard, which is not
63
+ // necessarily the machine looking at it, and this needs nothing installed.
64
+ items.push(`<li><a href hx-get="/obsidian/${esc(key)}/${esc(project)}/frame?what=view" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: file-text; ratio: .7"></span> Read the notes</a></li>`);
65
+ items.push(`<li><a href hx-get="/obsidian/${esc(key)}/${esc(project)}/frame?what=canvas" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: git-fork; ratio: .7"></span> Open the map</a></li>`);
66
+ items.push(`<li><a href="/obsidian/${esc(key)}/${esc(project)}/download" title="The vault as a .tar.gz, to open on another machine"><span uk-icon="icon: download; ratio: .7"></span> Download the vault</a></li>`);
67
+ items.push(`<li><a href class="uk-text-danger arm-step" data-armed="0" hx-post="/actions/obsidian-remove" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"><span uk-icon="icon: trash; ratio: .7"></span> Delete the vault</a></li>`);
68
+ }
69
+
70
+ return items.join('');
71
+ }
72
+
73
+ module.exports = { vaultDir, hasVault, obsidianMenuItems, indexNote };
74
+
75
+ /* ---------------------------------------------------------------------------
76
+ * The web reader.
77
+ *
78
+ * Obsidian is a desktop app, and the vault lives on the machine running the
79
+ * dashboard — which is not necessarily the machine looking at it. So the vault is
80
+ * also readable in the browser: the notes with their wikilinks working, and the
81
+ * canvas drawn as the map it describes.
82
+ *
83
+ * Everything is served from this app. No CDN, because the graph viewer already
84
+ * needs the internet for vis-network and having one thing in the dashboard that
85
+ * stops working on a train is enough.
86
+ * ------------------------------------------------------------------------- */
87
+
88
+ // The notes, by name. One pass over the vault, because every view needs the full
89
+ // set: the list, and the link resolver that decides which [[targets]] are real.
90
+ function vaultNotes(wsKey, project) {
91
+ const dir = vaultDir(wsKey, project);
92
+ const found = new Map();
93
+ const walk = (d, rel) => {
94
+ let entries;
95
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
96
+ for (const e of entries) {
97
+ if (e.name === '.obsidian') continue;
98
+ const full = path.join(d, e.name);
99
+ if (e.isDirectory()) walk(full, path.join(rel, e.name));
100
+ else if (e.name.endsWith('.md')) found.set(e.name.slice(0, -3), path.join(rel, e.name));
101
+ }
102
+ };
103
+ walk(dir, '');
104
+ return found;
105
+ }
106
+
107
+ /**
108
+ * The note shape this workspace writes, rendered.
109
+ *
110
+ * Not a general markdown parser — a parser that handles every construct would be a
111
+ * dependency and a much larger surface, and these notes are written by
112
+ * fun-obsidian.sh, so their shape is known: frontmatter, a heading, a source line,
113
+ * two link lists, a rule and a footer.
114
+ *
115
+ * Everything is escaped first and marked up second, so a note whose content happens
116
+ * to contain markup is displayed rather than obeyed.
117
+ */
118
+ function renderNote(md, linkHref, exists) {
119
+ let meta = {};
120
+ let body = md;
121
+ const fm = md.match(/^---\n([\s\S]*?)\n---\n?/);
122
+ if (fm) {
123
+ body = md.slice(fm[0].length);
124
+ for (const line of fm[1].split('\n')) {
125
+ const m = line.match(/^([a-z_]+):\s*(.*)$/i);
126
+ if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, '');
127
+ }
128
+ }
129
+
130
+ const inline = (t) => esc(t)
131
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
132
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
133
+ .replace(/\[\[([^\]|#]+)(\|[^\]]+)?\]\]/g, (_, name) => {
134
+ const n = name.trim();
135
+ return exists(n)
136
+ ? `<a class="wl" href="${linkHref(n)}">${esc(n)}</a>`
137
+ : `<span class="wl wl-missing" title="No note of that name in this vault">${esc(n)}</span>`;
138
+ });
139
+
140
+ const out = [];
141
+ let inList = false;
142
+ for (const raw of body.split('\n')) {
143
+ const line = raw.trimEnd();
144
+ if (/^-{3,}$/.test(line)) { if (inList) { out.push('</ul>'); inList = false; } out.push('<hr>'); continue; }
145
+ const li = line.match(/^-\s+(.*)$/);
146
+ if (li) {
147
+ if (!inList) { out.push('<ul class="links">'); inList = true; }
148
+ out.push(`<li>${inline(li[1])}</li>`);
149
+ continue;
150
+ }
151
+ if (inList) { out.push('</ul>'); inList = false; }
152
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
153
+ if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
154
+ if (!line) continue;
155
+ out.push(`<p>${inline(line)}</p>`);
156
+ }
157
+ if (inList) out.push('</ul>');
158
+ return { meta, html: out.join('\n') };
159
+ }
160
+
161
+ // One stylesheet for both views, using the dashboard's own theme tokens so the reader
162
+ // follows whatever theme is set instead of picking its own colours.
163
+ const VAULT_CSS = `
164
+ body { background: var(--surface); color: var(--ink); margin: 0; }
165
+ .vault { display: grid; grid-template-columns: 16rem 1fr; gap: 0; height: calc(100vh - 3.5rem); }
166
+ .vault-list { border-right: 1px solid var(--line); overflow-y: auto; padding: .5rem; }
167
+ .vault-list input { width: 100%; margin-bottom: .5rem; padding: .35rem .5rem;
168
+ border: 1px solid var(--line); border-radius: 4px; background: var(--surface);
169
+ color: var(--ink); }
170
+ .vault-list a { display: block; padding: .25rem .4rem; border-radius: 4px;
171
+ color: var(--ink); text-decoration: none; font-size: .82rem; overflow: hidden;
172
+ text-overflow: ellipsis; white-space: nowrap; }
173
+ .vault-list a:hover { background: var(--surface-sunken); }
174
+ .vault-list a.on { background: var(--link); color: var(--danger-ink); }
175
+ .vault-body { overflow-y: auto; padding: 1.25rem 1.75rem; }
176
+ .vault-body h1 { font-size: 1.5rem; margin: 0 0 .75rem; }
177
+ .vault-body h2 { font-size: 1rem; margin: 1.25rem 0 .4rem; color: var(--text-subtle); }
178
+ .vault-body ul.links { list-style: none; padding-left: 0; }
179
+ .vault-body ul.links li { padding: .15rem 0; }
180
+ .wl { color: var(--link); text-decoration: none; border-bottom: 1px solid transparent; }
181
+ .wl:hover { border-bottom-color: var(--link); }
182
+ .wl-missing { color: var(--text-subtle); border-bottom: 1px dotted var(--text-subtle); }
183
+ .vault-meta { font-size: .78rem; color: var(--text-subtle); margin-bottom: 1rem; }
184
+ .vault-meta code { background: var(--surface-sunken); padding: .05rem .3rem; border-radius: 3px; }
185
+ .vault-bar { display: flex; gap: .5rem; align-items: center; padding: .5rem .75rem;
186
+ border-bottom: 1px solid var(--line); background: var(--surface-raised); }
187
+ .vault-bar a, .vault-bar span { font-size: .82rem; }
188
+ #cv { width: 100%; height: calc(100vh - 7rem); background: var(--surface); cursor: grab; }
189
+ #cv:active { cursor: grabbing; }
190
+ #cv text { font: 15px system-ui, sans-serif; fill: var(--ink); pointer-events: none; }
191
+ #cv .n rect { fill: var(--surface-raised); stroke: var(--line); stroke-width: 1.5; }
192
+ #cv .n:hover rect { stroke: var(--link); stroke-width: 3; }
193
+ #cv .e { stroke: var(--text-subtle); stroke-width: 1.5; fill: none; opacity: .55; }
194
+ .vault-bar button { font-size: .8rem; padding: .15rem .55rem; border: 1px solid var(--line);
195
+ border-radius: 4px; background: var(--surface-raised); color: var(--ink); cursor: pointer; }
196
+ .vault-bar button:hover { border-color: var(--link); }
197
+ `;
198
+
199
+ // A page frame that inherits the dashboard's assembled theme sheet.
200
+ function vaultPage(title, cssVersion, bodyHtml) {
201
+ return `<!doctype html><html><head><meta charset="utf-8">
202
+ <meta name="viewport" content="width=device-width, initial-scale=1">
203
+ <title>${esc(title)}</title>
204
+ <link rel="icon" href="/favicon.ico">
205
+ <link rel="stylesheet" href="/style.css?v=${esc(String(cssVersion))}">
206
+ <style>${VAULT_CSS}</style>
207
+ </head><body>${bodyHtml}</body></html>`;
208
+ }
209
+
210
+ /** The note browser: the list, and one note with its links live. */
211
+ function vaultView(wsKey, project, note, cssVersion) {
212
+ const notes = vaultNotes(wsKey, project);
213
+ const names = [...notes.keys()].sort((a, b) => a.localeCompare(b));
214
+ const current = notes.has(note) ? note : (names.includes(project) ? project : names[0]);
215
+ const base = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/view`;
216
+ const href = (n) => `${base}?note=${encodeURIComponent(n)}`;
217
+
218
+ let rendered = { meta: {}, html: '<p class="uk-text-meta">This vault has no notes.</p>' };
219
+ if (current) {
220
+ try {
221
+ rendered = renderNote(fs.readFileSync(path.join(vaultDir(wsKey, project), notes.get(current)), 'utf8'),
222
+ href, (n) => notes.has(n));
223
+ } catch (_) {
224
+ rendered = { meta: {}, html: '<p class="msg error">That note could not be read.</p>' };
225
+ }
226
+ }
227
+
228
+ const meta = rendered.meta;
229
+ const metaLine = [
230
+ meta.source_file ? `<code>${esc(meta.source_file)}</code>` : '',
231
+ meta.source_location ? esc(meta.source_location) : '',
232
+ meta.cluster !== undefined ? `cluster ${esc(meta.cluster)}` : '',
233
+ meta.tags ? esc(meta.tags) : '',
234
+ ].filter(Boolean).join(' · ');
235
+
236
+ return vaultPage(`${current || project} · ${project}`, cssVersion, `
237
+ <div class="vault-bar">
238
+ <strong>${esc(project)}</strong>
239
+ <span class="uk-text-meta">${names.length} notes</span>
240
+ <a href="/obsidian/${esc(wsKey)}/${esc(project)}/canvas">Map</a>
241
+ <a href="/obsidian/${esc(wsKey)}/${esc(project)}/download">Download</a>
242
+ <a href="obsidian://open?path=${encodeURIComponent(vaultDir(wsKey, project))}">Open in the Obsidian app</a>
243
+ <span style="margin-left:auto"><a href="/">${esc(wsKey)} workspace</a></span>
244
+ </div>
245
+ <div class="vault">
246
+ <nav class="vault-list">
247
+ <input id="q" type="search" placeholder="Filter ${names.length} notes…" autocomplete="off">
248
+ <div id="ls">
249
+ ${names.map((n) => `<a href="${href(n)}" class="${n === current ? 'on' : ''}" data-n="${esc(n.toLowerCase())}">${esc(n)}</a>`).join('')}
250
+ </div>
251
+ </nav>
252
+ <article class="vault-body">
253
+ ${metaLine ? `<div class="vault-meta">${metaLine}</div>` : ''}
254
+ ${rendered.html}
255
+ </article>
256
+ </div>
257
+ <script>
258
+ // Filtering is the one thing worth scripting here: every link is a real URL, so
259
+ // the reader works with scripting off apart from this.
260
+ var q = document.getElementById('q'), ls = document.getElementById('ls');
261
+ q.addEventListener('input', function () {
262
+ var v = q.value.toLowerCase();
263
+ for (var i = 0; i < ls.children.length; i++) {
264
+ var a = ls.children[i];
265
+ a.style.display = a.dataset.n.indexOf(v) === -1 ? 'none' : '';
266
+ }
267
+ });
268
+ </script>`);
269
+ }
270
+
271
+ /**
272
+ * The canvas, drawn.
273
+ *
274
+ * The .canvas file already carries a position for every node — the builder laid them
275
+ * out cluster by cluster — so this is a straight projection into SVG rather than a
276
+ * layout algorithm. That also means the web map and the map Obsidian's Canvas shows
277
+ * are the same picture, which is the point of storing coordinates at all.
278
+ */
279
+ function vaultCanvas(wsKey, project, cssVersion) {
280
+ const file = path.join(vaultDir(wsKey, project), `${indexNote(project).slice(0, -3)}.canvas`);
281
+ let canvas;
282
+ try {
283
+ canvas = JSON.parse(fs.readFileSync(file, 'utf8'));
284
+ } catch (_) {
285
+ return vaultPage(`${project} map`, cssVersion,
286
+ '<div class="vault-bar"><span class="msg error">This vault has no canvas. Rebuild it.</span></div>');
287
+ }
288
+
289
+ const nodes = canvas.nodes || [];
290
+ const edges = canvas.edges || [];
291
+ const at = new Map(nodes.map((n) => [n.id, n]));
292
+ const pad = 60;
293
+ const xs = nodes.map((n) => n.x); const ys = nodes.map((n) => n.y);
294
+ const minX = Math.min(...xs, 0) - pad; const minY = Math.min(...ys, 0) - pad;
295
+ const w = Math.max(...xs.map((x, i) => x + nodes[i].width), 100) - minX + pad;
296
+ const h = Math.max(...ys.map((y, i) => y + nodes[i].height), 100) - minY + pad;
297
+
298
+ // The colour a node carries is a JSON Canvas preset number, not a CSS colour.
299
+ const PRESET = { 1: '#e05252', 2: '#e08c52', 3: '#d9c04a', 4: '#4fa373', 5: '#4f9fa3', 6: '#8b6fc4' };
300
+
301
+ const view = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/view`;
302
+ const noteOf = (n) => (n.file || '').replace(/^notes\//, '').replace(/\.md$/, '');
303
+
304
+ const edgeSvg = edges.map((e) => {
305
+ const a = at.get(e.fromNode); const b = at.get(e.toNode);
306
+ if (!a || !b) return '';
307
+ const x1 = a.x + a.width; const y1 = a.y + a.height / 2;
308
+ const x2 = b.x; const y2 = b.y + b.height / 2;
309
+ const mid = (x1 + x2) / 2;
310
+ return `<path class="e" d="M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}"/>`;
311
+ }).join('');
312
+
313
+ const nodeSvg = nodes.map((n) => {
314
+ const name = noteOf(n);
315
+ const label = name.length > 30 ? `${name.slice(0, 29)}…` : name;
316
+ const stroke = PRESET[n.color] || '';
317
+ return `<a class="n" href="${view}?note=${encodeURIComponent(name)}"><title>${esc(name)}</title>
318
+ <rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="6"${stroke ? ` style="stroke:${stroke}"` : ''}/>
319
+ <text x="${n.x + 12}" y="${n.y + n.height / 2 + 4}">${esc(label)}</text></a>`;
320
+ }).join('');
321
+
322
+ return vaultPage(`${project} map`, cssVersion, `
323
+ <div class="vault-bar">
324
+ <strong>${esc(project)}</strong>
325
+ <span class="uk-text-meta">${nodes.length} on the map · ${edges.length} links</span>
326
+ <a href="${view}">Notes</a>
327
+ <a href="obsidian://open?path=${encodeURIComponent(vaultDir(wsKey, project))}">Open in the Obsidian app</a>
328
+ <button id="fit" type="button">Fit</button>
329
+ <button id="one" type="button">100%</button>
330
+ <span class="uk-text-meta" style="margin-left:auto">drag to pan · scroll to zoom · click a node to read it</span>
331
+ </div>
332
+ <svg id="cv" viewBox="${minX} ${minY} ${w} ${h}">
333
+ <g id="vp">${edgeSvg}${nodeSvg}</g>
334
+ </svg>
335
+ <script>
336
+ // Pan and zoom by moving the viewBox, so the drawing stays vector-sharp and the
337
+ // links keep working — a CSS transform would break hit testing on the anchors.
338
+ (function () {
339
+ var svg = document.getElementById('cv');
340
+ var full = { x: ${minX}, y: ${minY}, w: ${w}, h: ${h} };
341
+ var vb = { x: full.x, y: full.y, w: full.w, h: full.h };
342
+ function apply() { svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); }
343
+ function set(o) { vb.x = o.x; vb.y = o.y; vb.w = o.w; vb.h = o.h;
344
+ svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); }
345
+ function fit() { set(full); }
346
+ // 100%: one user unit per CSS pixel, anchored at the top-left of the map.
347
+ function oneToOne() {
348
+ set({ x: full.x, y: full.y, w: svg.clientWidth, h: svg.clientHeight });
349
+ }
350
+ document.getElementById('fit').addEventListener('click', fit);
351
+ document.getElementById('one').addEventListener('click', oneToOne);
352
+ // Fit only when the whole map already fits comfortably; otherwise open readable.
353
+ if (full.w <= svg.clientWidth * 1.4 && full.h <= svg.clientHeight * 1.4) { fit(); } else { oneToOne(); }
354
+ var drag = null, moved = false;
355
+ svg.addEventListener('pointerdown', function (e) { drag = { x: e.clientX, y: e.clientY }; moved = false; });
356
+ window.addEventListener('pointerup', function () { drag = null; });
357
+ window.addEventListener('pointermove', function (e) {
358
+ if (!drag) return;
359
+ var k = vb.w / svg.clientWidth;
360
+ vb.x -= (e.clientX - drag.x) * k; vb.y -= (e.clientY - drag.y) * k;
361
+ drag = { x: e.clientX, y: e.clientY };
362
+ if (Math.abs(e.movementX) + Math.abs(e.movementY) > 2) moved = true;
363
+ apply();
364
+ });
365
+ // A drag that ends over a node must not also open it.
366
+ svg.addEventListener('click', function (e) { if (moved) { e.preventDefault(); moved = false; } }, true);
367
+ svg.addEventListener('wheel', function (e) {
368
+ e.preventDefault();
369
+ var f = e.deltaY > 0 ? 1.12 : 0.89;
370
+ var r = svg.getBoundingClientRect();
371
+ var mx = vb.x + (e.clientX - r.left) / r.width * vb.w;
372
+ var my = vb.y + (e.clientY - r.top) / r.height * vb.h;
373
+ vb.x = mx - (mx - vb.x) * f; vb.y = my - (my - vb.y) * f;
374
+ vb.w *= f; vb.h *= f;
375
+ apply();
376
+ }, { passive: false });
377
+ })();
378
+ </script>`);
379
+ }
380
+
381
+ /**
382
+ * The modal wrapper: one of the two views in an iframe, filling the dialog.
383
+ *
384
+ * An iframe rather than the page's markup inlined, because both views are whole documents — own
385
+ * stylesheet link, own script, own pan/zoom state — and splicing that into the dashboard would
386
+ * put two competing sets of handlers on one document.
387
+ */
388
+ function vaultFrame(wsKey, project, what) {
389
+ const src = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/${what === 'canvas' ? 'canvas' : 'view'}`;
390
+ const other = what === 'canvas' ? 'view' : 'canvas';
391
+ return `<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small-bottom">
392
+ <h3 class="uk-margin-remove">${esc(project)} <span class="uk-text-meta">${what === 'canvas' ? 'map' : 'notes'}</span></h3>
393
+ <div>
394
+ <a class="uk-button uk-button-default uk-button-small" href hx-get="/obsidian/${esc(wsKey)}/${esc(project)}/frame?what=${other}" hx-target="#editor-modal-body" hx-swap="innerHTML">${other === 'canvas' ? 'Map' : 'Notes'}</a>
395
+ <a class="uk-button uk-button-default uk-button-small" href="${esc(src)}" target="_blank">Open full screen</a>
396
+ </div>
397
+ </div>
398
+ <iframe src="${esc(src)}" title="${esc(project)}" style="width:100%;height:74vh;border:1px solid var(--line);border-radius:4px;background:var(--surface)"></iframe>`;
399
+ }
400
+
401
+ module.exports.vaultFrame = vaultFrame;
402
+ module.exports.vaultNotes = vaultNotes;
403
+ module.exports.renderNote = renderNote;
404
+ module.exports.vaultView = vaultView;
405
+ module.exports.vaultCanvas = vaultCanvas;
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@webship.co/workspace",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-alpha1",
4
+ "description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin \u2014 required with npm, then activated, installed and configured.",
5
+ "license": "ISC",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/webship/workspace.git",
9
+ "directory": "core/workspace-app"
10
+ },
11
+ "main": "server.js",
12
+ "files": [
13
+ "*.js",
14
+ "actions",
15
+ "commands",
16
+ "docs",
17
+ "public",
18
+ "scripts",
19
+ "plugins/workspace-notes",
20
+ "screenrc",
21
+ "PLUGINS.md",
22
+ "WORKSPACES.md",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "start": "node server.js",
27
+ "test": "npm run test:unit && npm run test:browser",
28
+ "test:unit": "node --test \"tests/unit/*.test.js\" \"plugins/*/tests/*.test.js\"",
29
+ "test:browser": "cucumber-js --config cucumber.js"
30
+ },
31
+ "dependencies": {
32
+ "@xterm/addon-fit": "^0.10.0",
33
+ "@xterm/addon-unicode11": "^0.9.0",
34
+ "@xterm/addon-web-links": "^0.11.0",
35
+ "@xterm/xterm": "^5.5.0",
36
+ "ace-builds": "^1.44.0",
37
+ "htmx.org": "^4.0.0",
38
+ "js-yaml": "^5.2.1",
39
+ "ws": "^8.21.3"
40
+ },
41
+ "devDependencies": {
42
+ "webship-js": "^2.0.4"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "keywords": [],
48
+ "author": ""
49
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What the command palette can go to: the workspaces, a few pages, and every project folder of the
3
+ * workspaces that hold projects. One small JSON document, kept for ten seconds, read by public/palette.js.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { loadWorkspaces, hubDomain } = require('./workspaces');
9
+
10
+ const MAX_PROJECTS = 800;
11
+ const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,100}$/;
12
+ let cache = null;
13
+ let cachedAt = 0;
14
+
15
+ const PAGES = [
16
+ { label: 'Screens', url: '/screens', hint: 'Detached sessions' },
17
+ { label: 'Commands', url: '/commands', hint: 'Every command the dashboard can run' },
18
+ { label: 'Settings', url: '/settings', hint: 'core/config' },
19
+ { label: 'Help', url: '/help', hint: 'The user guide' },
20
+ ];
21
+
22
+ function buildIndex() {
23
+ const hub = hubDomain();
24
+ const workspaces = [];
25
+ const projects = [];
26
+ for (const [key, meta] of Object.entries(loadWorkspaces())) {
27
+ workspaces.push({ key, label: meta.label || key, url: `https://${key}.${hub}/`, kind: meta.kind || 'projects' });
28
+ if (meta.kind === 'files' || projects.length >= MAX_PROJECTS) continue;
29
+ let entries = [];
30
+ try { entries = fs.readdirSync(meta.dir, { withFileTypes: true }); } catch (_) { continue; }
31
+ for (const e of entries) {
32
+ if (!e.isDirectory() || e.name.startsWith('.') || !NAME_RE.test(e.name) || projects.length >= MAX_PROJECTS) continue;
33
+ const ddev = fs.existsSync(path.join(meta.dir, e.name, '.ddev', 'config.yaml'));
34
+ projects.push({ ws: key, name: e.name, ddev, url: ddev ? `https://${e.name}.${key}.${hub}/` : `https://${key}.${hub}/` });
35
+ }
36
+ }
37
+ return { workspaces, pages: PAGES, projects };
38
+ }
39
+
40
+ function paletteIndex() {
41
+ const now = Date.now();
42
+ if (!cache || now - cachedAt > 10000) { cache = buildIndex(); cachedAt = now; }
43
+ return cache;
44
+ }
45
+
46
+ module.exports = { paletteIndex, buildIndex, PAGES };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The browser webship-js drives.
3
+ *
4
+ * Not a Playwright test-runner config: webship-js reads `browser`, `launchOptions` and
5
+ * `contextOptions` from here and launches the browser itself — cucumber-js is the runner.
6
+ */
7
+
8
+ module.exports = {
9
+ // Chromium only. The suite asserts behaviour, not rendering differences between engines, and
10
+ // three browsers is three downloads for no extra answer.
11
+ browser: 'chromium',
12
+ launchOptions: {
13
+ // Headless unless something asks otherwise: `HEADLESS=false ddev test-dashboard browser` shows
14
+ // the run, which is the only time watching it is worth the window.
15
+ headless: process.env.HEADLESS !== 'false',
16
+ slowMo: Number(process.env.SLOW_MO || 0),
17
+ },
18
+ contextOptions: {
19
+ viewport: { width: 1440, height: 900 },
20
+ // DDEV signs its own certificates, and a browser launched here has no reason to trust one.
21
+ ignoreHTTPSErrors: true,
22
+ },
23
+ };