@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/waiting.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Whether a screen session is working or waiting for a person.
3
+ *
4
+ * Its own module, with NO requires, because two processes need the same answer: the
5
+ * dashboard (in its container) and the host screen runner (a standalone script that must
6
+ * not pull the app's dependency tree in). One copy of the rules, so the badge on the page
7
+ * and the flag on the host can never disagree.
8
+ */
9
+
10
+ /**
11
+ * Is this session WORKING, or WAITING FOR A PERSON?
12
+ *
13
+ * From the outside the two are identical — a detached session with a live process — so a
14
+ * long agent run that has stopped to ask "which of these do you want?" sits there until
15
+ * somebody thinks to look. That is the whole point of this: the Screens page can say it.
16
+ *
17
+ * The decision is made on the TAIL of the visible screen, because a question is drawn at
18
+ * the bottom of a window, never at the top.
19
+ *
20
+ * The strongest signal is a NEGATIVE one, and it is checked first: Claude Code prints
21
+ * `esc to interrupt` in its footer only while it is busy. Observed on a real run —
22
+ *
23
+ * 6 Thinking… (6m 16s · 23.9k tokens)
24
+ * ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt
25
+ *
26
+ * — so its presence means working, whatever else is on screen. Without it, the footer has
27
+ * changed, and only then do the positive patterns get a say.
28
+ *
29
+ * Conservative on purpose. A badge that cries "waiting" on every row is worse than no
30
+ * badge: it trains you to ignore it. Anything unrecognised is reported as neither.
31
+ */
32
+ function waitingState(tail) {
33
+ const text = String(tail || '');
34
+ if (!text.trim()) return { waiting: false, why: '' };
35
+
36
+ // Busy: the interrupt hint, or a spinner line with an elapsed timer.
37
+ if (/esc to interrupt/i.test(text)) return { waiting: false, why: '' };
38
+ if (/\b(Thinking|Working|Running|Compacting)…\s*\(/i.test(text)) return { waiting: false, why: '' };
39
+
40
+ // A choice: a numbered list with a selection caret is what Claude Code draws for a
41
+ // permission question or a plan decision. `❯` is its own marker; `>` is the fallback
42
+ // some terminals get.
43
+ if (/(^|\n)\s*[❯▶>]\s*\d+[.)]\s+\S/.test(text)) return { waiting: true, why: 'waiting on a choice' };
44
+ if (/\b(1[.)]\s+Yes|Yes,\s+and\b|No,\s+and tell)/i.test(text)) return { waiting: true, why: 'waiting on a choice' };
45
+
46
+ // A plain question — but only in the LAST FEW LINES, and only on a line that is
47
+ // shaped like a prompt. An agent's own report says things like "the ticket asks: do
48
+ // you want the picker? I implemented it", and matching that would put a pulsing
49
+ // "waiting for you" badge on a session that is busy writing prose. A prompt is the
50
+ // last thing on the screen and the line ENDS at the question; a mention is followed
51
+ // by more sentence.
52
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
53
+ const last = lines.slice(-3);
54
+ const asksHere = (re) => last.some((l) => re.test(l));
55
+ // A question that has been ANSWERED is not a question. The two yes/no rules allow a few characters
56
+ // after the bracket because a cursor sits there — and a typed `y` is one character, so
57
+ //
58
+ // Do you want to continue? (y/n) y
59
+ // rajab@host:~/workspace/test/ruverify1$
60
+ //
61
+ // went on matching once the shell had moved on, and the hand never went down. A badge that cries
62
+ // "waiting" on a session nobody is blocking is worse than no badge: it trains you to ignore it.
63
+ // So those two ask the LAST line only — an answered prompt always has something after it, if only
64
+ // the shell's own. The rules below keep the three-line window: a numbered choice list and a
65
+ // "press Enter" are drawn with a footer under them.
66
+ const asksLast = (re) => re.test(lines[lines.length - 1] || '');
67
+
68
+ if (asksLast(/\((y\/n|y\/N|Y\/n|yes\/no)\)\s*\S{0,3}$/i)) return { waiting: true, why: 'waiting on yes/no' };
69
+ if (asksLast(/\[(y\/N|Y\/n)\]\s*\S{0,3}$/)) return { waiting: true, why: 'waiting on yes/no' };
70
+ if (asksHere(/^(Do you want|Would you like)\b[^?]{0,80}\?$/i)) return { waiting: true, why: 'waiting on an answer' };
71
+ if (asksHere(/^(Proceed|Continue|Overwrite|Are you sure)\b[^?]{0,40}\?$/i)) return { waiting: true, why: 'waiting on an answer' };
72
+ if (asksHere(/Press (Enter|any key) to (continue|proceed)/i)) return { waiting: true, why: 'waiting for Enter' };
73
+ if (asksHere(/^(Enter (a |the )?(value|name|password|token)|Please enter)\b/i)) return { waiting: true, why: 'waiting for input' };
74
+
75
+ // Claude Code sitting at its own prompt with nothing running: its footer hints are
76
+ // there, the interrupt hint is not. That is a finished agent waiting to be told what
77
+ // is next — worth flagging, more gently than an unanswered question.
78
+ if (/(\? for shortcuts|bypass permissions on|shift\+tab to cycle)/i.test(text)) {
79
+ return { waiting: true, why: 'idle at its prompt' };
80
+ }
81
+
82
+ return { waiting: false, why: '' };
83
+ }
84
+
85
+ module.exports = { waitingState };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Edit and Settings on a home page workspace card, as the project and folder cards carry their tools.
3
+ *
4
+ * The corner holds the backups count too, so the three never overlap. It sits beside the card's link, not in it:
5
+ * a card is one <a>, and a button inside a link is not a button.
6
+ */
7
+
8
+ const { esc } = require('./html');
9
+ const { iconHtml } = require('./icons');
10
+
11
+ const settingsFile = (key) => `workspace.${key}.settings.yml`;
12
+
13
+ /** The corner of one card: its backups count, Edit (the card's icon and description) and Settings (its own file). */
14
+ function cornerHtml(w, { backupCount = 0, backupsUrl = '' } = {}) {
15
+ const file = settingsFile(w.key);
16
+ const backups = backupCount
17
+ ? `<a class="ws-backups" href="${esc(backupsUrl)}" title="View the ${backupCount} backup${backupCount === 1 ? '' : 's'} for ${esc(w.label)}"><span uk-icon="icon: album; ratio: .65"></span> ${backupCount}</a>`
18
+ : '';
19
+ return `<div class="ws-card-corner" role="toolbar" aria-label="${esc(w.label)}">
20
+ ${backups}
21
+ <button type="button" class="ws-card-tool" hx-get="/fragments/workspace-edit/${encodeURIComponent(w.key)}"
22
+ hx-target="#editor-modal-body" hx-swap="innerHTML"
23
+ title="Edit the ${esc(w.label)} card: its icon and description" aria-label="Edit ${esc(w.label)}">${iconHtml('tabler:pencil', 0.75)}</button>
24
+ <a class="ws-card-tool" href="/settings/${encodeURIComponent(file)}"
25
+ data-tabmodal-open="/fragments/settings/modal?tab=${encodeURIComponent(file)}"
26
+ title="${esc(w.label)} settings: ${esc(file)}" aria-label="${esc(w.label)} settings">${iconHtml('tabler:settings', 0.75)}</a>
27
+ </div>`;
28
+ }
29
+
30
+ /** The Edit modal: the same presentation editor the workspace's settings tab shows, on its own. */
31
+ function editModalHtml(w, presentationHtml) {
32
+ return `<div class="editor-card ws-card-edit">
33
+ <h3 class="uk-h4 uk-margin-small-bottom">${iconHtml(w.icon, 1)} Edit the ${esc(w.label)} card</h3>
34
+ <p class="uk-text-meta uk-margin-remove-top">The icon and description on its card and in the rail.
35
+ Everything else is in <a href="/settings/${encodeURIComponent(settingsFile(w.key))}"
36
+ data-tabmodal-open="/fragments/settings/modal?tab=${encodeURIComponent(settingsFile(w.key))}">its settings</a>.</p>
37
+ ${presentationHtml}
38
+ </div>`;
39
+ }
40
+
41
+ module.exports = { cornerHtml, editModalHtml, settingsFile };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Deleting a workspace that holds nothing: the reverse of addWorkspace in server.js.
3
+ *
4
+ * Only a workspace with no content can go. Its folder may hold the tooling a workspace ships
5
+ * (cmd-*.sh, README.md, AGENTS.md, CLAUDE.md, *.code-workspace) and nothing else; it has no backups
6
+ * and no screen session running. The dashboard asks three times before it deletes one.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('./html');
13
+ const { ROOT, CONFIG_DIR, loadWorkspaces } = require('./workspaces');
14
+ const { readListBlock, writeListBlock } = require('./settings-file');
15
+
16
+ const TOOLING_RE = /^(cmd-.+\.sh|README\.md|AGENTS\.md|CLAUDE\.md|.+\.code-workspace)$/;
17
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
18
+ const DDEV_CONFIG = path.join(__dirname, '.ddev', 'config.yaml');
19
+
20
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
21
+
22
+ function liveSessionNames() {
23
+ try {
24
+ const j = JSON.parse(fs.readFileSync(path.join(ROOT, '.screens', 'host-sessions.json'), 'utf8'));
25
+ return (j.sessions || []).map((s) => String(s.session || ''));
26
+ } catch (_) { return []; }
27
+ }
28
+
29
+ function filesIn(dir) {
30
+ try { return fs.readdirSync(dir); } catch (_) { return []; }
31
+ }
32
+
33
+ /** What keeps a workspace in use. Deletable when `reasons` is empty; `tooling` is what deleting removes. */
34
+ function usage(name, { sessions = liveSessionNames() } = {}) {
35
+ const meta = loadWorkspaces()[name];
36
+ if (!meta) return { reasons: ['not a workspace'], tooling: [] };
37
+ const reasons = [];
38
+ const dir = path.join(ROOT, name);
39
+ if (path.resolve(meta.dir) !== dir) reasons.push('its folder is set outside the tree');
40
+ const entries = filesIn(dir);
41
+ const content = entries.filter((f) => !TOOLING_RE.test(f));
42
+ if (content.length) {
43
+ reasons.push(`${plural(content.length, 'item')} in its folder (${content.slice(0, 3).join(', ')}${content.length > 3 ? ', …' : ''})`);
44
+ }
45
+ const backups = filesIn(meta.backupsDir);
46
+ if (backups.length) reasons.push(plural(backups.length, 'backup file'));
47
+ const live = sessions.filter((s) => s.startsWith(`${name}--`));
48
+ if (live.length) reasons.push(`${plural(live.length, 'screen session')} running`);
49
+ const list = readListBlock('settings.yml', 'workspaces');
50
+ if (list && list.items.length < 2) reasons.push('it is the only workspace');
51
+ return { reasons, tooling: entries.filter((f) => TOOLING_RE.test(f)), dir, backupsDir: meta.backupsDir };
52
+ }
53
+
54
+ /** The three answers the last step must carry: the dialog's Yes, the name typed back, and YES. */
55
+ function confirmed(name, form) {
56
+ return String(form.confirmName || '').trim() === name && String(form.confirmYes || '').trim() === 'YES';
57
+ }
58
+
59
+ /** Remove the list entry, the settings file, the folder and the hostname. Checked again here, not trusted from the page. */
60
+ function deleteWorkspace(name) {
61
+ if (!NAME_RE.test(name)) return { error: 'Not a workspace name.' };
62
+ const use = usage(name);
63
+ if (use.reasons.length) return { error: `${name} is in use: ${use.reasons.join('; ')}.` };
64
+
65
+ const notes = [];
66
+ const list = readListBlock('settings.yml', 'workspaces');
67
+ writeListBlock('settings.yml', 'workspaces', list.items.filter((k) => k !== name));
68
+ notes.push('took it out of <code>workspaces:</code>');
69
+
70
+ const settingsFile = path.join(CONFIG_DIR, `workspace.${name}.settings.yml`);
71
+ if (fs.existsSync(settingsFile)) {
72
+ fs.unlinkSync(settingsFile);
73
+ notes.push(`deleted <code>core/config/workspace.${esc(name)}.settings.yml</code>`);
74
+ }
75
+ if (fs.existsSync(use.dir)) {
76
+ use.tooling.forEach((f) => fs.unlinkSync(path.join(use.dir, f)));
77
+ fs.rmdirSync(use.dir);
78
+ notes.push(`deleted the <code>${esc(name)}/</code> folder${use.tooling.length ? ` and its ${plural(use.tooling.length, 'command file')}` : ''}`);
79
+ }
80
+ try { fs.rmdirSync(use.backupsDir); } catch (_) { /* none, or not empty */ }
81
+
82
+ try {
83
+ const cfg = fs.readFileSync(DDEV_CONFIG, 'utf8');
84
+ const next = cfg.split('\n').filter((l) => l.trim() !== `- "*.${name}.workspace"`).join('\n');
85
+ if (next !== cfg) {
86
+ fs.writeFileSync(DDEV_CONFIG, next);
87
+ notes.push(`removed <code>*.${esc(name)}.workspace</code> from the dashboard's DDEV hostnames`);
88
+ }
89
+ } catch (_) { /* no DDEV config here */ }
90
+ return { notes };
91
+ }
92
+
93
+ /** The row's delete control: a button for an unused workspace, a quiet "in use" with the reasons otherwise. */
94
+ function rowControlHtml(name, use) {
95
+ if (use.reasons.length) {
96
+ return `<span class="ws-in-use uk-text-meta" title="Cannot be deleted: ${esc(use.reasons.join('; '))}">in use</span>`;
97
+ }
98
+ return `<button type="button" form="ws-delete-form" class="uk-button uk-button-default uk-button-small ws-delete arm-step"
99
+ hx-post="/actions/workspace-delete" hx-trigger="confirmed-remove"
100
+ hx-vals='{"workspace":"${esc(name)}","step":"name"}' hx-target="#list-editor" hx-swap="outerHTML"
101
+ title="Delete the ${esc(name)} workspace"><span uk-icon="icon: trash; ratio: .7"></span></button>`;
102
+ }
103
+
104
+ /** Steps 2 and 3, shown under the row: type the name, then type YES. The buttons post through the empty
105
+ * #ws-delete-form, so the settings form they sit in is not sent with them. */
106
+ function stepHtml(name, step, use, error) {
107
+ const vals = (s, extra = '') => `hx-post="/actions/workspace-delete" hx-target="#list-editor" hx-swap="outerHTML"
108
+ hx-vals='{"workspace":"${esc(name)}","step":"${s}"${extra}}'`;
109
+ const id = `ws-del-${esc(name)}`;
110
+ const removes = [
111
+ `the <code>${esc(name)}/</code> folder${use.tooling.length ? ` (${plural(use.tooling.length, 'command file')})` : ''}`,
112
+ `<code>core/config/workspace.${esc(name)}.settings.yml</code>`,
113
+ `its line in <code>workspaces:</code>`,
114
+ `the <code>*.${esc(name)}.workspace</code> hostname`,
115
+ ];
116
+ const cancel = `<button type="button" form="ws-delete-form" class="uk-button uk-button-default uk-button-small" ${vals('cancel')}>Cancel</button>`;
117
+ const body = step === 'yes'
118
+ ? `<p><strong>Step 3 of 3.</strong> Type <code>YES</code> to delete the <strong>${esc(name)}</strong> workspace. This cannot be undone.</p>
119
+ <div class="ws-delete-fields">
120
+ <input class="uk-input uk-form-small" id="${id}-yes" name="confirmYes" autocomplete="off" spellcheck="false"
121
+ placeholder="YES" aria-label="Type YES to delete ${esc(name)}">
122
+ <button type="button" form="ws-delete-form" class="uk-button uk-button-danger uk-button-small ws-delete-go"
123
+ ${vals('delete', `,"confirmName":"${esc(name)}"`)} hx-include="#${id}-yes">Delete workspace</button>
124
+ ${cancel}
125
+ </div>`
126
+ : `<p><strong>Step 2 of 3.</strong> Type the workspace name, <code>${esc(name)}</code>, to continue.</p>
127
+ <p class="uk-text-meta">Deleting it removes ${removes.join(', ')}.</p>
128
+ <div class="ws-delete-fields">
129
+ <input class="uk-input uk-form-small" id="${id}-name" name="confirmName" autocomplete="off" spellcheck="false"
130
+ placeholder="${esc(name)}" aria-label="Type the workspace name">
131
+ <button type="button" form="ws-delete-form" class="uk-button uk-button-primary uk-button-small ws-delete-go"
132
+ ${vals('yes')} hx-include="#${id}-name">Continue</button>
133
+ ${cancel}
134
+ </div>`;
135
+ return `<div class="ws-delete-step" role="group" aria-label="Delete the ${esc(name)} workspace">
136
+ ${error ? `<p class="ws-delete-error" role="alert">${esc(error)}</p>` : ''}${body}</div>`;
137
+ }
138
+
139
+ module.exports = { usage, confirmed, deleteWorkspace, rowControlHtml, stepHtml, TOOLING_RE };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Workspace types: what a folder's projects are FOR, and therefore which actions
3
+ * their rows offer.
4
+ *
5
+ * Two layers decide whether a control appears, and they answer different questions:
6
+ *
7
+ * capability does this folder ship the script? (findBackupScript, cmd-tools-testing.sh …)
8
+ * policy does this KIND of folder want it? (this file)
9
+ *
10
+ * Discovery alone was the whole story until now, which meant a folder got every
11
+ * control its scripts could serve — a code-packages folder with a backup script
12
+ * showed DDEV/Upsun surface the moment someone copied the wrong cmd file in.
13
+ * The type is chosen per workspace in workspace.<name>.settings.yml (`type:`),
14
+ * editable from the Settings page; these defaults cover the shipped folders so
15
+ * an unset file behaves exactly as it always did.
16
+ *
17
+ * `kind` (projects/files page layout) is presentation and stays in
18
+ * workspaces.js — a type never changes how a page is laid out, only which
19
+ * actions its project rows carry.
20
+ */
21
+
22
+ const TYPES = {
23
+ sites: {
24
+ label: 'Sites — DDEV projects',
25
+ description: 'Built Drupal sites: DDEV controls, tests, users, the full toolbelt.',
26
+ actions: ['build', 'folder', 'ddev', 'backup', 'clone', 'remove', 'graphify', 'ragify', 'tests', 'checks', 'users'],
27
+ },
28
+ code: {
29
+ label: 'Code — packages and repositories',
30
+ description: 'Modules, themes, libraries, recipes: quality checks and knowledge tools, no site controls.',
31
+ actions: ['build', 'folder', 'backup', 'remove', 'graphify', 'ragify', 'checks'],
32
+ },
33
+ files: {
34
+ label: 'Files — documents and drafts',
35
+ description: 'Markdown and documents; the page is an item list, projects carry no actions.',
36
+ actions: ['folder'],
37
+ },
38
+ media: {
39
+ label: 'Media — recordings',
40
+ description: 'Videos and other media; the page is an item list, projects carry no actions.',
41
+ actions: ['folder'],
42
+ },
43
+ 'ai-items': {
44
+ label: 'AI items — agents, skills, prompts',
45
+ description: 'Claude Code items with their own install/fetch/propose surface.',
46
+ actions: [],
47
+ },
48
+ };
49
+
50
+ // What each shipped folder is, so nothing changes until somebody edits a `type:`.
51
+ // A folder absent here (a new workspace) defaults by its kind: projects → sites.
52
+ const DEFAULT_TYPE_BY_KEY = {
53
+ products: 'sites', dev: 'sites', test: 'sites', demos: 'sites', sandboxes: 'sites',
54
+ projects: 'sites', profiles: 'sites', 'site-templates': 'sites', rag: 'sites', designs: 'sites',
55
+ recipes: 'code', components: 'code', themes: 'code', modules: 'code', libraries: 'code',
56
+ forked: 'code', graphs: 'code',
57
+ docs: 'files', worklogs: 'files', specs: 'files',
58
+ images: 'media', videos: 'media', audio: 'media',
59
+ 'ai-agents': 'ai-items', 'ai-skills': 'ai-items',
60
+ 'ai-commands': 'ai-items', 'ai-prompts': 'ai-items',
61
+ };
62
+
63
+ function isType(name) {
64
+ return Object.prototype.hasOwnProperty.call(TYPES, name);
65
+ }
66
+
67
+ /** The type a workspace resolves to: its own `type:`, the shipped default, or by kind. */
68
+ function resolveType(key, declaredType, kind) {
69
+ if (declaredType && isType(declaredType)) return declaredType;
70
+ if (DEFAULT_TYPE_BY_KEY[key]) return DEFAULT_TYPE_BY_KEY[key];
71
+ return kind === 'files' ? 'files' : 'sites';
72
+ }
73
+
74
+ function typeActions(type) {
75
+ return new Set((TYPES[type] || TYPES.sites).actions);
76
+ }
77
+
78
+ /** An action a plugin brings ('upsun' on sites): allowed on the types it names from now on. */
79
+ function addTypeAction(action, types) {
80
+ for (const type of types || []) {
81
+ if (TYPES[type] && !TYPES[type].actions.includes(action)) TYPES[type].actions.push(action);
82
+ }
83
+ }
84
+
85
+ module.exports = { TYPES, DEFAULT_TYPE_BY_KEY, isType, resolveType, typeActions, addTypeAction };