@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,124 @@
1
+ /**
2
+ * The add-ons panel for one project: what it has, what it could have, and everything else there is.
3
+ *
4
+ * Rendering lives here rather than in server.js, the way the commands and navigation pages do: the
5
+ * route resolves the project and calls one function, and what a panel looks like is one file to open.
6
+ */
7
+
8
+ const { esc } = require('./html');
9
+ const { humanTime } = require('./format');
10
+ const addons = require('./ddev-addons');
11
+
12
+ const PER_PAGE = 24;
13
+
14
+ /**
15
+ * @param {object} ctx workspace, project, projectDir, q (a search), page
16
+ */
17
+ async function addonsPanelHtml({ workspace, project, projectDir, q = '', page = 1 }) {
18
+ const installed = addons.installedAddons(projectDir);
19
+ const have = new Set(installed.map((a) => a.repository.toLowerCase()));
20
+ const registry = await addons.availableAddons();
21
+ const vals = (extra) => esc(JSON.stringify({ workspace, projectName: project, ...extra }));
22
+ const panelUrl = `/fragments/projects/addons/${encodeURIComponent(workspace)}/${encodeURIComponent(project)}`;
23
+
24
+ const installedRows = installed.map((a) => `
25
+ <li>
26
+ <div class="addon-line">
27
+ <span class="addon-name">${esc(a.name)}
28
+ <span class="uk-text-meta">· ${esc(a.repository)}${a.version ? ` · ${esc(a.version)}` : ''}${
29
+ a.installed ? ` · added ${esc(humanTime(new Date(a.installed)))}` : ''}</span></span>
30
+ <span class="addon-actions">
31
+ <button class="uk-button uk-button-default uk-button-small" hx-post="/actions/ddev-addon-update"
32
+ hx-vals='${vals({ addon: a.name })}' hx-target="#webship-workspace-output" hx-swap="innerHTML"
33
+ title="Take the newest release of ${esc(a.repository)} into this project">Update</button>
34
+ <button class="uk-button uk-button-default uk-button-small arm-step" hx-post="/actions/ddev-addon-remove"
35
+ hx-vals='${vals({ addon: a.name, confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML"
36
+ hx-trigger="confirmed-remove" title="Remove ${esc(a.name)} and the ${a.files.length} file(s) it owns">Remove</button>
37
+ </span>
38
+ </div>
39
+ </li>`).join('');
40
+
41
+ // A recommendation is a line with a reason on it. Anything already installed drops out of the list
42
+ // rather than being offered again.
43
+ const suggestions = addons.RECOMMENDED.filter((r) => !have.has(r.repo.toLowerCase()));
44
+ const suggestionRows = suggestions.map((r) => {
45
+ const known = (registry.list || []).find((x) => x.repo.toLowerCase() === r.repo.toLowerCase());
46
+ return `
47
+ <li>
48
+ <div class="addon-line">
49
+ <span class="addon-name">${esc(r.repo)}${known && known.stars ? ` <span class="uk-text-meta">⭐ ${known.stars}</span>` : ''}</span>
50
+ <span class="addon-actions">${installButton(r.repo, vals, !!known, 'unknown')}</span>
51
+ </div>
52
+ <p class="uk-text-meta addon-why">${esc(r.why)}</p>
53
+ </li>`;
54
+ }).join('');
55
+
56
+ const matches = addons.searchAddons(registry.list || [], q);
57
+ const pages = Math.max(1, Math.ceil(matches.length / PER_PAGE));
58
+ const at = Math.min(Math.max(1, Number(page) || 1), pages);
59
+ const slice = matches.slice((at - 1) * PER_PAGE, at * PER_PAGE);
60
+ const searchRows = slice.map((r) => `
61
+ <li>
62
+ <div class="addon-line">
63
+ <span class="addon-name">${esc(r.repo)}${r.stars ? ` <span class="uk-text-meta">⭐ ${r.stars}</span>` : ''}${
64
+ have.has(r.repo.toLowerCase()) ? ' <span class="uk-label uk-label-success">installed</span>' : ''}</span>
65
+ <span class="addon-actions">${have.has(r.repo.toLowerCase()) ? '' : installButton(r.repo, vals, !r.truncated)}</span>
66
+ </div>
67
+ ${r.description ? `<p class="uk-text-meta addon-why">${esc(r.description)}</p>` : ''}
68
+ </li>`).join('');
69
+
70
+ const pager = pages > 1 ? `
71
+ <p class="uk-text-meta addon-pager">
72
+ ${at > 1 ? `<a href hx-get="${panelUrl}?q=${encodeURIComponent(q)}&amp;page=${at - 1}" hx-target="#editor-modal-body" hx-swap="innerHTML">‹ previous</a>` : ''}
73
+ <span>${(at - 1) * PER_PAGE + 1}–${(at - 1) * PER_PAGE + slice.length} of ${matches.length}</span>
74
+ ${at < pages ? `<a href hx-get="${panelUrl}?q=${encodeURIComponent(q)}&amp;page=${at + 1}" hx-target="#editor-modal-body" hx-swap="innerHTML">next ›</a>` : ''}
75
+ </p>` : '';
76
+
77
+ return `
78
+ <div class="editor-card">
79
+ <h3 class="uk-margin-small-bottom">Add-ons for ${esc(project)}</h3>
80
+ <p class="uk-text-meta">An add-on is a repository DDEV copies into the project — a service, a tool, or a
81
+ whole development setup. Installing one writes files into <code>.ddev/</code> and needs a
82
+ <strong>ddev restart</strong> before it does anything; removing one takes its files away again.
83
+ ${registry.error ? `<br><strong>The registry could not be read:</strong> ${esc(registry.error)}` : ''}</p>
84
+
85
+ <h4 class="uk-margin-small-bottom">Installed <span class="uk-badge">${installed.length}</span></h4>
86
+ ${installed.length
87
+ ? `<ul class="uk-list uk-list-divider addon-list">${installedRows}</ul>`
88
+ : '<p class="uk-text-meta">None yet — DDEV records what it installs in <code>.ddev/addon-metadata/</code>, and this reads that.</p>'}
89
+
90
+ ${suggestions.length ? `<h4 class="uk-margin-small-bottom">Worth having here <span class="uk-badge">${suggestions.length}</span></h4>
91
+ <ul class="uk-list uk-list-divider addon-list addon-suggested">${suggestionRows}</ul>` : ''}
92
+
93
+ <h4 class="uk-margin-small-bottom">Everything in the registry <span class="uk-badge">${(registry.list || []).length}</span></h4>
94
+ <form class="addon-search uk-margin-small" hx-get="${panelUrl}" hx-target="#editor-modal-body" hx-swap="innerHTML"
95
+ hx-trigger="submit, keyup[key=='Enter'] from:find input">
96
+ <input class="uk-input uk-form-small" type="search" name="q" value="${esc(q)}"
97
+ placeholder="Search the add-on registry — redis, solr, playwright…" aria-label="Search add-ons">
98
+ <button class="uk-button uk-button-default uk-button-small" type="submit">Search</button>
99
+ </form>
100
+ ${matches.length
101
+ ? `<ul class="uk-list uk-list-divider addon-list">${searchRows}</ul>${pager}`
102
+ : `<p class="uk-text-meta">Nothing in the registry matches “${esc(q)}”.</p>`}
103
+ </div>`;
104
+ }
105
+
106
+ /**
107
+ * Install, or say why not.
108
+ *
109
+ * `ddev add-on list` truncates long names with `…`, and a truncated name cannot be installed — so
110
+ * when the registry had to be read from that table, those rows say so rather than offering a button
111
+ * that would fail.
112
+ */
113
+ function installButton(repo, vals, installable, why = 'truncated') {
114
+ if (!installable) {
115
+ return why === 'unknown'
116
+ ? `<span class="uk-label uk-label-warning" title="The registry does not list ${esc(repo)} — it may have moved or been renamed.">not in the registry</span>`
117
+ : '<span class="uk-label uk-label-warning" title="The registry listing truncated this name, so it cannot be installed from here. Use: ddev add-on get &lt;owner/repo&gt;">name truncated</span>';
118
+ }
119
+ return `<button class="uk-button uk-button-primary uk-button-small" hx-post="/actions/ddev-addon-get"
120
+ hx-vals='${vals({ addon: repo })}' hx-target="#webship-workspace-output" hx-swap="innerHTML"
121
+ title="Install ${esc(repo)} into this project">Install</button>`;
122
+ }
123
+
124
+ module.exports = { addonsPanelHtml, PER_PAGE };
package/ddev-addons.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * DDEV add-ons: what this project has installed, and what it could.
3
+ *
4
+ * An add-on is a repository DDEV copies into a project — a service (`ddev-redis`, `ddev-solr`), a
5
+ * tool (`ddev-adminer`, `ddev-playwright`), or a whole development setup (`ddev-drupal-contrib`).
6
+ * There are 294 of them in the registry, and until now the only way to add one here was a terminal.
7
+ *
8
+ * **Installed is read off disk.** DDEV records every add-on it installs in
9
+ * `.ddev/addon-metadata/<name>/manifest.yaml` — the name, the repository, the version, when, and the
10
+ * files it owns — so listing them is a readdir and a project row costs no subprocess.
11
+ *
12
+ * **Available comes from the registry, not from `ddev add-on list`.** That command renders a table
13
+ * and truncates: 38 of the 294 names come back ending in `…`, and a truncated name cannot be
14
+ * installed. The registry IS the GitHub topic `ddev-get`, so that is what is asked, through the `gh`
15
+ * already in this container — full names, descriptions and star counts, cached, with the table as a
16
+ * fallback that marks what it could not read rather than pretending.
17
+ *
18
+ * **A name from the browser is never passed through.** `ddev add-on get` also accepts a URL or a
19
+ * local path, which is a download-and-run from anywhere; only an `owner/repo` that the registry
20
+ * listing actually contains is allowed to reach it.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const { run } = require('./jobs');
27
+
28
+ // `owner/repo`, which is the only shape `ddev add-on get` is given here. Not a URL and not a path:
29
+ // both are things that command will happily install from.
30
+ const ADDON_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
31
+
32
+ // The registry is a GitHub topic; DDEV's own list is that search.
33
+ const REGISTRY_TOPIC = 'ddev-get';
34
+ const REGISTRY_TTL = 30 * 60 * 1000;
35
+ let _registry = { at: 0, list: null, error: '' };
36
+
37
+ /**
38
+ * A few that earn their place in THIS workspace, said with the reason.
39
+ *
40
+ * Not a ranking of the 294 and not a copy of the registry's stars: these are the ones a Drupal or
41
+ * Webship project here actually reaches for, and each line says what it is for so the list is a
42
+ * recommendation rather than a shelf.
43
+ */
44
+ const RECOMMENDED = [
45
+ { repo: 'ddev/ddev-drupal-contrib', why: 'The setup for working on a contrib module or theme inside a core checkout — what the modules and themes workspaces are for.' },
46
+ { repo: 'ddev/ddev-drupal-solr', why: 'Apache Solr, configured for Drupal Search API: the search backend Webship sites use in production.' },
47
+ { repo: 'ddev/ddev-redis', why: 'Redis for Drupal caching — the difference a page-heavy site feels immediately.' },
48
+ { repo: 'ddev/ddev-adminer', why: 'A database UI on its own URL, for when drush sql-cli is not the tool you want.' },
49
+ { repo: 'ddev/ddev-selenium-standalone-chrome', why: 'A real Chrome for functional tests, with noVNC to watch them run.' },
50
+ { repo: 'Lullabot/ddev-playwright', why: 'Playwright in the project, which is what the webship-js suites drive.' },
51
+ { repo: 'ddev/ddev-browsersync', why: 'Live reload while theming: a saved SCSS file refreshes the page.' },
52
+ { repo: 'ddev/ddev-memcached', why: 'Memcached, where a site is configured for it rather than for Redis.' },
53
+ ];
54
+
55
+ /** Everything DDEV has recorded as installed in this project, newest first. */
56
+ function installedAddons(projectDir) {
57
+ const dir = path.join(projectDir, '.ddev', 'addon-metadata');
58
+ let names = [];
59
+ try {
60
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
61
+ } catch (_) {
62
+ return [];
63
+ }
64
+ const out = [];
65
+ for (const name of names) {
66
+ const file = path.join(dir, name, 'manifest.yaml');
67
+ let raw = '';
68
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { continue; }
69
+ // One flat block of scalars — read the four that matter rather than taking a YAML parser to it.
70
+ const field = (k) => (raw.match(new RegExp(`^${k}:\\s*"?([^"\\n]+)"?\\s*$`, 'm')) || [, ''])[1].trim();
71
+ const repository = field('repository');
72
+ out.push({
73
+ name: field('name') || name,
74
+ repository,
75
+ version: field('version'),
76
+ installed: field('install_date'),
77
+ // What it put in the project, which is what removing it takes away again.
78
+ files: (raw.match(/^project_files:\s*$([\s\S]*?)(?=^\S|\Z)/m) || [, ''])[1]
79
+ .split('\n').map((l) => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean),
80
+ });
81
+ }
82
+ return out.sort((a, b) => String(b.installed).localeCompare(String(a.installed)));
83
+ }
84
+
85
+ /**
86
+ * The registry, cached.
87
+ *
88
+ * `gh` is in this container and already authenticated for the shared-agents listing, so the same
89
+ * search DDEV itself runs is one call away. It is cached for half an hour because it is a network
90
+ * round trip and the answer is a list of repositories, which does not change by the minute.
91
+ */
92
+ async function availableAddons({ force = false } = {}) {
93
+ if (!force && _registry.list && Date.now() - _registry.at < REGISTRY_TTL) return _registry;
94
+ const out = await run('gh', ['search', 'repos', '--topic', REGISTRY_TOPIC, '--limit', '300',
95
+ '--json', 'fullName,description,stargazersCount,updatedAt'], process.env.HOME, { timeoutMs: 60 * 1000 });
96
+ let list = null;
97
+ try {
98
+ list = JSON.parse(String(out.stdout || '[]')).map((r) => ({
99
+ repo: String(r.fullName || ''),
100
+ description: String(r.description || '').trim(),
101
+ stars: Number(r.stargazersCount || 0),
102
+ updated: String(r.updatedAt || ''),
103
+ })).filter((r) => ADDON_RE.test(r.repo));
104
+ } catch (_) { list = null; }
105
+ if (!list || !list.length) {
106
+ // The fallback is DDEV's own table. It truncates long names with `…`, and a truncated name
107
+ // cannot be installed — so those are listed as unavailable WITH the reason rather than as a
108
+ // button that would fail.
109
+ const table = await run('ddev', ['add-on', 'list'], process.env.HOME, { timeoutMs: 60 * 1000 });
110
+ const rows = String(table.stdout || '').split('\n')
111
+ .map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').match(/^│\s*([^│]+?)\s*│\s*([^│]*?)\s*│/))
112
+ .filter(Boolean)
113
+ .map((m) => ({ repo: m[1], description: m[2], stars: 0, updated: '', truncated: m[1].includes('…') }))
114
+ .filter((r) => r.repo !== 'ADD-ON');
115
+ _registry = { at: Date.now(), list: rows, error: rows.length ? '' : String(out.stderr || table.stderr || '').slice(0, 200) };
116
+ return _registry;
117
+ }
118
+ _registry = { at: Date.now(), list: list.sort((a, b) => b.stars - a.stars), error: '' };
119
+ return _registry;
120
+ }
121
+
122
+ /** Forget the cache: after installing or removing one, the next page should say what is true now. */
123
+ function invalidateRegistry() { _registry = { at: 0, list: null, error: '' }; }
124
+
125
+ /**
126
+ * Is this a name the registry actually lists?
127
+ *
128
+ * The check the whole feature rests on. `ddev add-on get` takes a URL or a path as happily as a
129
+ * repository, so a name that came from a browser is only allowed through when the registry listing
130
+ * contains it — and when the registry could not be read, nothing is installed rather than anything
131
+ * being trusted.
132
+ */
133
+ async function isKnownAddon(repo) {
134
+ if (!ADDON_RE.test(String(repo || ''))) return { ok: false, why: 'An add-on is named owner/repo.' };
135
+ const reg = await availableAddons();
136
+ if (!reg.list || !reg.list.length) {
137
+ return { ok: false, why: `The add-on registry could not be read${reg.error ? `: ${reg.error}` : ''}.` };
138
+ }
139
+ const found = reg.list.find((r) => r.repo.toLowerCase() === String(repo).toLowerCase());
140
+ return found ? { ok: true, addon: found } : { ok: false, why: `${repo} is not in the DDEV add-on registry.` };
141
+ }
142
+
143
+ /** The registry, narrowed by what somebody typed — name and description, like `ddev add-on search`. */
144
+ function searchAddons(list, q) {
145
+ const needle = String(q || '').trim().toLowerCase();
146
+ if (!needle) return list;
147
+ return list.filter((r) => r.repo.toLowerCase().includes(needle)
148
+ || String(r.description || '').toLowerCase().includes(needle));
149
+ }
150
+
151
+ module.exports = {
152
+ ADDON_RE, RECOMMENDED, REGISTRY_TOPIC,
153
+ installedAddons, availableAddons, invalidateRegistry, isKnownAddon, searchAddons,
154
+ };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a DDEV folder can run by name, for the DDEV menu: its own `ddev <command>`s from .ddev/commands,
5
+ * the scripts in its composer.json (once composer.lock is there) and the scripts in its package.json.
6
+ * Each group folds under its heading like the rest of the menu.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('./html');
13
+
14
+ const COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,60}$/;
15
+ // The folders DDEV runs a command in: the host, or the service of that name.
16
+ const SERVICE_RE = /^[a-z0-9][a-z0-9_-]{0,40}$/;
17
+
18
+ function annotation(text, key) {
19
+ const m = text.match(new RegExp(`^##\\s*${key}:\\s*(.+)$`, 'mi'));
20
+ return m ? m[1].trim() : '';
21
+ }
22
+
23
+ function projectType(projectPath) {
24
+ try {
25
+ const m = fs.readFileSync(path.join(projectPath, '.ddev', 'config.yaml'), 'utf8').match(/^type:\s*["']?([\w-]+)/m);
26
+ return m ? m[1] : '';
27
+ } catch (_) { return ''; }
28
+ }
29
+
30
+ /** The project's custom commands, as DDEV lists them: one per file under .ddev/commands/<service>/. */
31
+ function customCommands(projectPath) {
32
+ const root = path.join(projectPath, '.ddev', 'commands');
33
+ let services = [];
34
+ try { services = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory() && SERVICE_RE.test(d.name)); } catch (_) { return []; }
35
+ const type = projectType(projectPath);
36
+ const out = [];
37
+ for (const s of services) {
38
+ let files = [];
39
+ try { files = fs.readdirSync(path.join(root, s.name), { withFileTypes: true }); } catch (_) { continue; }
40
+ for (const f of files) {
41
+ if (!f.isFile() || !COMMAND_RE.test(f.name) || /^readme/i.test(f.name) || /\.example$/.test(f.name)) continue;
42
+ const file = path.join(root, s.name, f.name);
43
+ let text = '';
44
+ let exec = false;
45
+ try { text = fs.readFileSync(file, 'utf8').slice(0, 4096); exec = (fs.statSync(file).mode & 0o111) !== 0; } catch (_) { continue; }
46
+ // A helper the commands source is neither described nor executable.
47
+ if (!/^## Description:/mi.test(text) && !(exec && text.startsWith('#!'))) continue;
48
+ const types = annotation(text, 'ProjectTypes');
49
+ if (types && type && !types.split(',').map((t) => t.trim()).includes(type)) continue;
50
+ out.push({ name: f.name, service: s.name, description: annotation(text, 'Description'),
51
+ usage: annotation(text, 'Usage'), aliases: annotation(text, 'Aliases').split(',').map((a) => a.trim()).filter((a) => COMMAND_RE.test(a)) });
52
+ }
53
+ }
54
+ return out.sort((a, b) => a.name.localeCompare(b.name));
55
+ }
56
+
57
+ function hasComposerLock(projectPath) {
58
+ try { return fs.statSync(path.join(projectPath, 'composer.lock')).isFile(); } catch (_) { return false; }
59
+ }
60
+
61
+ /** The three groups, ready to drop into the DDEV menu. Empty when the folder has none of them. */
62
+ function menuGroupsHtml({ key, project, projectPath, running }) {
63
+ const { SCRIPT_ROUTE, hasComposerJson, composerScripts } = require('./actions/composer');
64
+ const { NODE_SCRIPT_ROUTE, hasPackageJson, nodeInfo, nodeScripts } = require('./actions/node');
65
+ const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(project)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
66
+ const play = '<span uk-icon="icon: play; ratio: .7"></span>';
67
+ const meta = (s) => (s ? ` <span class="uk-text-meta">· ${esc(s)}</span>` : '');
68
+ const group = (title, items) => (items.length ? `
69
+ <li class="uk-nav-divider"></li>
70
+ <li class="uk-nav-header" data-group="${esc(title)}">${esc(title)}</li>
71
+ ${items.join('\n ')}` : '');
72
+
73
+ const commands = customCommands(projectPath).map((c) => {
74
+ const needsRun = c.service !== 'host' && !running;
75
+ const tip = [c.description, c.usage && `ddev ${c.usage}`, c.aliases.length && `aliases: ${c.aliases.join(', ')}`,
76
+ needsRun && 'Runs in a container: start the project first'].filter(Boolean).join(' — ');
77
+ return needsRun
78
+ ? `<li data-entry="ddev ${esc(c.name)}"><span class="uk-text-meta" title="${esc(tip)}">ddev ${esc(c.name)} · start first</span></li>`
79
+ : `<li data-entry="ddev ${esc(c.name)}"><a href hx-post="/actions/ddev-project-command" ${vals(`,"command":"${esc(c.name)}"`)} title="${esc(tip)}">${play} ddev ${esc(c.name)}</a></li>`;
80
+ });
81
+
82
+ const composer = hasComposerJson(projectPath) && hasComposerLock(projectPath) ? composerScripts(projectPath).map((s) =>
83
+ `<li><a href hx-post="${SCRIPT_ROUTE}" ${vals(`,"script":"${esc(s.name)}"`)} title="${esc(s.description || `ddev composer run-script ${s.name}`)}">${play} ${esc(s.name)}${meta(s.aliases.join(', '))}</a></li>`) : [];
84
+
85
+ let node = [];
86
+ if (hasPackageJson(projectPath)) {
87
+ const info = nodeInfo(projectPath);
88
+ const m = info.preferred || 'npm';
89
+ node = (info.valid ? nodeScripts(info.pkg) : []).map((s) =>
90
+ `<li><a href hx-post="${NODE_SCRIPT_ROUTE}" ${vals(`,"manager":"${esc(m)}","script":"${esc(s.name)}"`)} title="${esc(s.command || `${m} run ${s.name}`)}">${play} ${esc(s.name)}</a></li>`);
91
+ }
92
+
93
+ return group('custom commands', commands) + group('composer.json scripts', composer) + group('package.json scripts', node);
94
+ }
95
+
96
+ module.exports = { customCommands, hasComposerLock, menuGroupsHtml };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The addresses a DDEV project answers at, beside the one its NAME gives it.
3
+ *
4
+ * DDEV has two keys for this and they are not interchangeable:
5
+ *
6
+ * additional_hostnames: LABELS — `project_tld` is appended to each
7
+ * additional_fqdns: HOSTNAMES — nothing is appended
8
+ *
9
+ * The dashboard read only the first for a long time, which is why a project with a Storybook showed
10
+ * no extra domains at all: Webship writes `storybook.<project>.ddev.site` into `additional_fqdns`,
11
+ * because it is a whole hostname and putting it in the label list would give
12
+ * `storybook.<project>.ddev.site.ddev.site`.
13
+ *
14
+ * Kept in its own module so it can be tested. The parsing is the part that was wrong, and it is the
15
+ * part nothing could reach while it lived inside the request handler.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ /** Strip one layer of quotes from a YAML scalar. */
24
+ const unquote = (s) => String(s).trim().replace(/^["']|["']$/g, '');
25
+
26
+ /**
27
+ * The raw keys, kept apart.
28
+ *
29
+ * `list` is what the hostname-collision check compares, and it compares labels — so a full hostname
30
+ * must never be folded into it, or it would compare `blog2` against `storybook.x.ddev.site`.
31
+ */
32
+ function ddevHostnames(dir) {
33
+ const out = { list: [], fqdns: [], tld: 'ddev.site' };
34
+ let cfg;
35
+ try {
36
+ cfg = fs.readFileSync(path.join(dir, '.ddev', 'config.yaml'), 'utf8').split('\n');
37
+ } catch (_) {
38
+ return out; // not a DDEV project
39
+ }
40
+ let into = null;
41
+ for (const ln of cfg) {
42
+ // A comment is not configuration. A stock config.yaml ships `# additional_hostnames:` and
43
+ // `# - somename`, and a parser that reads those hands every project hostnames it never had.
44
+ if (/^\s*#/.test(ln)) continue;
45
+
46
+ const head = ln.match(/^(additional_hostnames|additional_fqdns):(.*)$/);
47
+ if (head) {
48
+ const target = head[1] === 'additional_fqdns' ? out.fqdns : out.list;
49
+ const rest = head[2].trim();
50
+ if (rest.startsWith('[')) {
51
+ target.push(...rest.replace(/^\[|\]$/g, '').split(',').map(unquote).filter(Boolean));
52
+ } else {
53
+ into = target; // a block list follows
54
+ }
55
+ continue;
56
+ }
57
+ if (into) {
58
+ const item = ln.match(/^\s+-\s*(.+?)\s*$/);
59
+ if (item) { into.push(unquote(item[1])); continue; }
60
+ if (ln.trim() === '' || /^\s/.test(ln)) continue;
61
+ into = null;
62
+ }
63
+ const tld = ln.match(/^project_tld:\s*"?([^"#\s]+)"?\s*$/);
64
+ if (tld) out.tld = tld[1];
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Every extra address, as whole hostnames, with the Storybook one marked.
71
+ *
72
+ * Storybook is marked because it is not another copy of the site: it is the component library, a
73
+ * different thing to open, and the address people go looking for and cannot find.
74
+ */
75
+ function ddevExtraDomains(dir) {
76
+ const { list, fqdns, tld } = ddevHostnames(dir);
77
+ const hosts = [...list.map((label) => `${label}.${tld}`), ...fqdns];
78
+ const seen = new Set();
79
+ return hosts
80
+ .filter((h) => h && !seen.has(h) && seen.add(h))
81
+ .map((host) => ({ host, storybook: /^storybook\./i.test(host) }));
82
+ }
83
+
84
+ module.exports = { ddevHostnames, ddevExtraDomains };
package/ddev-import.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Putting a database back: the other half of Export the database.
3
+ *
4
+ * The row could dump a database into the workspace's backups folder and never put one back. So the
5
+ * dumps this dashboard writes were a one-way trip — restoring one meant finding the file, working out
6
+ * the flag, and typing `ddev import-db` in a terminal.
7
+ *
8
+ * Three decisions, each for a reason:
9
+ *
10
+ * - **Only files this workspace already holds.** The list is the workspace's own backups folder,
11
+ * so nothing is typed in and no path from a browser reaches a command line. A dump somewhere else
12
+ * is a `ddev import-db` in a terminal, and that is a fair place for it.
13
+ * - **Only things that ARE dumps.** That folder also holds whole-project tarballs from
14
+ * `cmd-tools-backup-*.sh`, and `ddev import-db` accepts a `.tar.gz` — so an archive of a whole
15
+ * project would be handed over as if it were SQL. They are excluded by name.
16
+ * - **A snapshot first, always.** Importing replaces every table, and the database it replaces is
17
+ * often the only copy of an afternoon's work. The snapshot is taken in the same line with `&&`,
18
+ * so nothing is imported if it fails — the same shape `drush sql:drop` already uses.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const { esc } = require('./html');
25
+ const { humanSize, humanTime } = require('./format');
26
+
27
+ // What `ddev import-db` reads AND what is unambiguously a database dump. `.zip`, `.tgz` and `.tar.gz`
28
+ // are left out on purpose: DDEV accepts them, and in this folder they are whole-project archives.
29
+ const DUMP_RE = /\.(sql|mysql)(\.(gz|bz2|xz))?$/i;
30
+
31
+ /** The dumps a workspace holds, newest first. */
32
+ function listDumps(backupsDir) {
33
+ let names = [];
34
+ try {
35
+ names = fs.readdirSync(backupsDir, { withFileTypes: true })
36
+ .filter((e) => e.isFile() && !e.name.startsWith('.') && DUMP_RE.test(e.name))
37
+ .map((e) => e.name);
38
+ } catch (_) {
39
+ return [];
40
+ }
41
+ return names.map((name) => {
42
+ let st;
43
+ try { st = fs.statSync(path.join(backupsDir, name)); } catch (_) { return null; }
44
+ return { name, size: st.size, mtime: st.mtime };
45
+ }).filter(Boolean).sort((a, b) => b.mtime - a.mtime);
46
+ }
47
+
48
+ /**
49
+ * The dump a request named, or null.
50
+ *
51
+ * A basename, matched against what the folder actually holds — never joined from a browser value and
52
+ * hoped about. `path.basename` first, so a name carrying a directory cannot even be compared.
53
+ */
54
+ function findDump(backupsDir, name) {
55
+ const wanted = path.basename(String(name || ''));
56
+ if (!wanted || wanted !== String(name || '')) return null;
57
+ return listDumps(backupsDir).find((d) => d.name === wanted) || null;
58
+ }
59
+
60
+ /** The panel: what there is to import, and what importing one will do. */
61
+ function importPanelHtml({ workspace, project, backupsDir, backupsLabel }) {
62
+ const dumps = listDumps(backupsDir);
63
+ const vals = (extra) => esc(JSON.stringify({ workspace, projectName: project, ...extra }));
64
+ const rows = dumps.map((d) => `
65
+ <li>
66
+ <div class="import-line">
67
+ <span class="import-name">${esc(d.name)}</span>
68
+ <span class="uk-text-meta import-meta">${esc(humanTime(d.mtime))} · ${esc(humanSize(d.size))}</span>
69
+ <span class="import-actions">
70
+ <button class="uk-button uk-button-primary uk-button-small arm-step" hx-post="/actions/ddev-import-db"
71
+ hx-vals='${vals({ dump: d.name, confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML"
72
+ hx-trigger="confirmed-remove" title="Replace this project's database with ${esc(d.name)}">Import</button>
73
+ </span>
74
+ </div>
75
+ </li>`).join('');
76
+
77
+ return `
78
+ <div class="editor-card">
79
+ <h3 class="uk-margin-small-bottom">Import a database into ${esc(project)}</h3>
80
+ <p class="uk-text-meta">Importing <strong>replaces</strong> every table in this project's database.
81
+ A snapshot is taken first, in the same command — so if the snapshot fails nothing is imported, and
82
+ if the import is wrong the snapshot is one click away under <em>Snapshots</em>.</p>
83
+ ${dumps.length
84
+ ? `<ul class="uk-list uk-list-divider import-list">${rows}</ul>`
85
+ : '<p class="uk-text-meta">No database dumps in this workspace yet. <em>Export the database</em> writes one here, and so does a project backup.</p>'}
86
+ <p class="uk-text-meta">These are the dumps in <code>${esc(backupsLabel)}</code>. Whole-project archives
87
+ from a backup are not listed: <code>ddev import-db</code> would accept one and hand a tarball of a
88
+ project to the database. A dump from anywhere else is
89
+ <code>ddev import-db --file &lt;path&gt;</code> in a terminal.</p>
90
+ </div>`;
91
+ }
92
+
93
+ module.exports = { DUMP_RE, listDumps, findDump, importPanelHtml };