@webship.co/workspace 1.0.0-beta2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/core/workspace-app/PLUGINS.md +55 -2
  2. package/core/workspace-app/package.json +2 -2
  3. package/core/workspace-app/plugin-loader.js +85 -4
  4. package/core/workspace-app/plugins-page.js +1 -1
  5. package/core/workspace-app/public/ui.js +19 -3
  6. package/core/workspace-app/server.js +461 -276
  7. package/core/workspace-app/tests/features/assistant-speech.feature +2 -2
  8. package/core/workspace-app/tests/features/checks.feature +23 -6
  9. package/core/workspace-app/tests/features/clone-project.feature +5 -4
  10. package/core/workspace-app/tests/features/project-actions.feature +15 -8
  11. package/core/workspace-app/tests/step-definitions/assistant-speech.steps.js +10 -5
  12. package/core/workspace-app/tests/step-definitions/clone-project.steps.js +47 -0
  13. package/core/workspace-app/tests/step-definitions/preconditions.steps.js +69 -1
  14. package/core/workspace-app/tests/unit/changelog.test.js +49 -3
  15. package/core/workspace-app/tests/unit/claude-code-here.test.js +1 -1
  16. package/core/workspace-app/tests/unit/ddev-domains.test.js +31 -5
  17. package/core/workspace-app/tests/unit/plugin-system.test.js +38 -1
  18. package/core/workspace-app/tests/unit/preconditions.test.js +21 -0
  19. package/core/workspace-app/tests/unit/row-layout.test.js +4 -1
  20. package/core/workspace-app/tests/unit/row-menu-fragments.test.js +229 -0
  21. package/core/workspace-app/tests/unit/screens-menu.test.js +7 -2
  22. package/core/workspace-app/tests/unit/screens.test.js +35 -16
  23. package/core/workspace-app/tests/unit/video-borrow.test.js +27 -5
  24. package/core/workspace-app/tests/unit/video-edit.test.js +24 -5
  25. package/package.json +1 -1
@@ -84,7 +84,7 @@ const {
84
84
  // Optional features workspaces turn on from settings.yml `plugins:` — see PLUGINS.md.
85
85
  const {
86
86
  pluginActions, pluginFragment, pluginPage, pluginPageActionGroups, pluginProjectActionsHtml,
87
- pluginRowMenus, pluginWorkspaceView, pluginOpenWith, pluginFolderLinks, pluginProjectChipsHtml, pluginLabels,
87
+ pluginRowMenus, menuShellHtml, pluginRowMenuSlot, pluginRowMenuBody, pluginWorkspaceView, pluginOpenWith, pluginFolderLinks, pluginProjectChipsHtml, pluginLabels,
88
88
  pluginProjectBadgesHtml, pluginSettingsOptions, pluginWorkKeyLink, pluginWorkPrompt,
89
89
  } = require('./plugin-loader');
90
90
  // How a project row is arranged — the order of its buttons and of what is inside their menus (Commands → Buttons).
@@ -937,15 +937,37 @@ function promptFile(text) {
937
937
  * anything else runs in the dashboard's DDEV container, and the menu says which.
938
938
  */
939
939
  function composerMenuHtml(key, p, projectPath, isDdev, mode = 'manifest') {
940
- const { SCRIPT_ROUTE, COMPOSER_GROUPS, composerActionsFor, hasComposerJson, composerScripts } = require('./actions/composer');
940
+ const { hasComposerJson } = require('./actions/composer');
941
941
  if (!hasComposerJson(projectPath)) return '';
942
- // Only a site workspace (`composer: full`) installs; everywhere else only composer.json is edited. See actions/composer.js.
943
942
  const manifest = mode !== 'full';
944
- const { run, packages, scripts: withScripts } = composerActionsFor(mode);
943
+ const { caption } = composerMenuState(projectPath);
944
+ // The row carries the button and the caption; the body (composerMenuBody) arrives from
945
+ // /fragments/row-menu/composer/<ws>/<project> when the menu opens — fifty rows, fifty buttons, no menu.
946
+ return menuShellHtml('composer', {
947
+ key, project: p, caption, dropdownClasses: `composer-dropdown${manifest ? '' : ' composer-dropdown-wide'}`,
948
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="Composer commands for this folder"><span uk-icon="icon: album; ratio: .7"></span> COMPOSER <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
949
+ });
950
+ }
951
+
952
+ /** What the COMPOSER caption says: composer.json parsed (or not), and whether a lock file sits beside it. */
953
+ function composerMenuState(projectPath) {
945
954
  let pkg = null;
946
955
  try { pkg = JSON.parse(fs.readFileSync(path.join(projectPath, 'composer.json'), 'utf8')); } catch (_) { /* said in the caption */ }
947
956
  const locked = fs.existsSync(path.join(projectPath, 'composer.lock'));
948
- const caption = !pkg ? 'bad json' : (locked ? 'locked' : 'no lock');
957
+ return { pkg, locked, caption: !pkg ? 'bad json' : (locked ? 'locked' : 'no lock') };
958
+ }
959
+
960
+ /**
961
+ * The COMPOSER menu's body — what used to sit inside the dropdown on every row — for one project, served
962
+ * by /fragments/row-menu/composer/<ws>/<project> when the menu opens. '' for a folder without composer.json.
963
+ */
964
+ function composerMenuBody(key, p, projectPath, isDdev, mode = 'manifest') {
965
+ const { SCRIPT_ROUTE, COMPOSER_GROUPS, composerActionsFor, hasComposerJson, composerScripts } = require('./actions/composer');
966
+ if (!hasComposerJson(projectPath)) return '';
967
+ // Only a site workspace (`composer: full`) installs; everywhere else only composer.json is edited. See actions/composer.js.
968
+ const manifest = mode !== 'full';
969
+ const { run, packages, scripts: withScripts } = composerActionsFor(mode);
970
+ const { pkg } = composerMenuState(projectPath);
949
971
  const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
950
972
  // Daily first: install, update, outdated and validate are what a working day is made of.
951
973
  const groups = COMPOSER_GROUPS.filter((group) => Object.values(run).some((a) => a.group === group)).map((group) => `
@@ -986,10 +1008,7 @@ function composerMenuHtml(key, p, projectPath, isDdev, mode = 'manifest') {
986
1008
  <ul class="uk-nav uk-dropdown-nav composer-col-commands">${head}${groups}</ul>
987
1009
  <ul class="uk-nav uk-dropdown-nav composer-col-inputs">${packageBlock}${scriptsBlock}</ul>
988
1010
  </div>`;
989
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-composer">
990
- <button class="uk-button uk-button-default uk-button-small" type="button" title="Composer commands for this folder"><span uk-icon="icon: album; ratio: .7"></span> COMPOSER <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
991
- <div uk-dropdown="mode: click; pos: bottom-right" class="composer-dropdown${manifest ? '' : ' composer-dropdown-wide'}">${body}</div>
992
- </div><span class="act-status uk-text-meta">${esc(caption)}</span></span>`;
1011
+ return body;
993
1012
  }
994
1013
 
995
1014
  /**
@@ -2708,7 +2727,7 @@ async function settingsPage(section, state, options = {}) {
2708
2727
  : `${options.fragment ? '' : back}${isPlugins ? require('./plugins-page').pluginsPageHtml() : isStyles ? stylesPageHtml()
2709
2728
  : isNavigation ? navPage.navEditorHtml(options.scope || 'global')
2710
2729
  : isBuildOrder ? buildOrderPage.editorHtml(options.scope || 'global')
2711
- : isButtons ? rowLayout.rowActionsEditorHtml()
2730
+ : isButtons ? (await learnCoreMenus(), rowLayout.rowActionsEditorHtml())
2712
2731
  : options.raw ? settingsRawHtml(current)
2713
2732
  : await settingsFormHtml(current)}`;
2714
2733
 
@@ -3149,26 +3168,380 @@ function ddevStateOf(dir, project, statuses) {
3149
3168
  }
3150
3169
 
3151
3170
 
3171
+ /**
3172
+ * The DDEV menu's items for one project — every <li> inside the dropdown, before the Buttons settings
3173
+ * arrange them. The row builds them to count what the caption says ("12 commands") and the fragment
3174
+ * /fragments/row-menu/ddev/<ws>/<project> builds them again, for one project, when the menu opens;
3175
+ * the page itself carries only the button and the caption. `gates` is rowGates(): which verbs the
3176
+ * workspace's type allows and the folder ships; `ddev` is ddevStateOf(): the project's name and state.
3177
+ */
3178
+ function ddevMenuItemsHtml({ key, p, meta, projectPath, gates, ddev }) {
3179
+ const { canDrupal, canBackup, canClone, canRemove } = gates;
3180
+ const { ddevName, running, mailpit } = ddev;
3181
+ const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
3182
+ return `
3183
+ <li class="uk-nav-header" data-group="project">${esc(ddevName)}</li>
3184
+ ${running
3185
+ ? `<li data-entry="start-stop"><a href hx-post="/actions/ddev-stop" ${vals()}><span uk-icon="icon: ban; ratio: .7"></span> Stop</a></li>`
3186
+ : `<li data-entry="start-stop"><a href hx-post="/actions/ddev-start" ${vals()}><span uk-icon="icon: play; ratio: .7"></span> Start</a></li>`}
3187
+ <li><a href hx-post="/actions/ddev-restart" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Restart</a></li>
3188
+ <li><a href hx-post="/actions/ddev-describe" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Describe (URLs, services)</a></li>
3189
+ <li><a href hx-post="/actions/ddev-logs" ${vals()}><span uk-icon="icon: file-text; ratio: .7"></span> Web logs (last 100)</a></li>
3190
+ <li><a href="https://${esc(ddevName)}.ddev.site" target="_blank"><span uk-icon="icon: link-external; ratio: .7"></span> Canonical URL</a></li>
3191
+ ${require('./ddev-custom-commands').menuGroupsHtml({ key, project: p, projectPath, running })}
3192
+
3193
+ <li class="uk-nav-divider"></li>
3194
+ <li class="uk-nav-header">build</li>
3195
+ ${canDrupal ? `<li><a href hx-post="/actions/ddev-composer-install" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Composer install</a></li>
3196
+ <li><a href hx-post="/actions/ddev-composer-update" ${vals()}><span uk-icon="icon: cloud-download; ratio: .7"></span> Composer update</a></li>` : ''}
3197
+ <li><a href hx-post="/actions/ddev-mutagen-sync" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Mutagen sync</a></li>
3198
+ <!-- Beyond sync: is it behind or is it broken, and the reset for when it cannot be
3199
+ repaired. Reset stops the project and removes the volume, so it asks first — the
3200
+ files on disk are the source, and the volume is rebuilt on the next start. -->
3201
+ ${ddevMail.menuItemHtml({ workspace: key, project: p, running, mailpit })}
3202
+ <li><a href hx-post="/actions/ddev-mutagen-status" ${vals()}
3203
+ title="Is the file sync up to date, behind, or not enabled at all"><span uk-icon="icon: info; ratio: .7"></span> Mutagen status</a></li>
3204
+ <li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-mutagen-reset" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3205
+ title="Stop the project and remove its Mutagen volume, for a sync that cannot be repaired. The files on disk are the source; the volume is rebuilt on the next start."><span uk-icon="icon: refresh; ratio: .7"></span> Reset the sync…</a></li>
3206
+
3207
+ ${canDrupal && running ? `<li class="uk-nav-divider"></li>
3208
+ <li class="uk-nav-header">drupal</li>
3209
+ <li><a href hx-post="/actions/ddev-drush-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Drush status</a></li>
3210
+ <li><a href hx-post="/actions/ddev-drush-cr" ${vals()}><span uk-icon="icon: bolt; ratio: .7"></span> Rebuild the cache (cr)</a></li>
3211
+ <li><a href hx-post="/actions/ddev-drush-uli" ${vals()}><span uk-icon="icon: unlock; ratio: .7"></span> One-time login link (uli)</a></li>
3212
+
3213
+ <li class="uk-nav-divider"></li>
3214
+ <li class="uk-nav-header">configuration</li>
3215
+ <li><a href hx-post="/actions/ddev-drush-cim" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Import configuration (cim)</a></li>
3216
+ <li><a href hx-post="/actions/ddev-drush-cex" ${vals()}><span uk-icon="icon: upload; ratio: .7"></span> Export configuration (cex)</a></li>` : ''}
3217
+
3218
+ <li class="uk-nav-divider"></li>
3219
+ <!-- Step debugging. Two items rather than a toggle, because ddev describe does not report
3220
+ which state it is in (checked) and a row must not spend a subprocess to find out — so
3221
+ the menu offers both, and asks when you want to know. Xdebug makes PHP about three
3222
+ times slower while it is on, which is why "off" is worth being one click away.
3223
+ No backticks in this comment: the menu is one template literal. -->
3224
+ <li class="uk-nav-header">debugging</li>
3225
+ ${running ? `<li><a href hx-post="/actions/ddev-xdebug-on" ${vals()}><span uk-icon="icon: code; ratio: .7"></span> Xdebug on</a></li>
3226
+ <li><a href hx-post="/actions/ddev-xdebug-off" ${vals()}><span uk-icon="icon: code; ratio: .7"></span> Xdebug off — PHP is ~3&times; slower while it is on</a></li>
3227
+ <li><a href hx-post="/actions/ddev-xdebug-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Which is it now?</a></li>` : ''}
3228
+ <!-- When a project is broken, the four questions worth asking before guessing. Each reads
3229
+ and reports; none of them changes anything. They are here rather than in a document
3230
+ because the moment you need them is the moment you do not want to go looking. -->
3231
+ <li><a href hx-post="/actions/ddev-diagnose" ${vals()}
3232
+ title="Docker, the network, HTTPS and this project's health, in one report"><span uk-icon="icon: lifesaver; ratio: .7"></span> Diagnose — Docker, network, HTTPS</a></li>
3233
+ <li><a href hx-post="/actions/ddev-tls-diagnose" ${vals()}
3234
+ title="Why the browser does not trust this project's certificate"><span uk-icon="icon: lock; ratio: .7"></span> Diagnose the certificate</a></li>
3235
+ <li><a href hx-post="/actions/ddev-port-diagnose" ${vals()}
3236
+ title="What else on this machine is holding the ports DDEV needs"><span uk-icon="icon: server; ratio: .7"></span> Diagnose the ports</a></li>
3237
+ <li><a href hx-post="/actions/ddev-db-match" ${vals()}
3238
+ title="Is the database volume on disk the engine and version this project's config asks for"><span uk-icon="icon: database; ratio: .7"></span> Does the database match the config?</a></li>
3239
+ <!-- The one thing on this menu that needs a person at a keyboard: ssh-add asks for the
3240
+ passphrase, so it runs in a screen session and the item says to go and answer it.
3241
+ Without it a composer install against a private repository fails at the first
3242
+ package nobody can read. -->
3243
+ <li><a href hx-post="/actions/ddev-auth-ssh" ${vals()}
3244
+ title="Add your SSH keys to the agent container every project borrows them from — it asks for the passphrase in a screen session"><span uk-icon="icon: unlock; ratio: .7"></span> Add my SSH keys — then answer the passphrase in Screens</a></li>
3245
+ <!-- The only verb here whose effect reaches people who are not on this machine: a tunnel
3246
+ provider publishes this project on the open internet, for as long as the session
3247
+ runs. Hence the confirm dialog, and hence a screen session — the URL is printed in
3248
+ there, and closing a tab must not take somebody's demo down. -->
3249
+ ${running ? `<li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-share" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3250
+ title="Publish this project on the internet through a tunnel (ngrok), until the session is stopped. Anyone with the address can reach the site, including its admin."><span uk-icon="icon: world; ratio: .7"></span> Share on the internet…</a></li>` : ''}
3251
+
3252
+ <li class="uk-nav-header">database</li>
3253
+ ${canDrupal && running ? `<li><a href hx-post="/actions/ddev-drush-updb" ${vals()}><span uk-icon="icon: database; ratio: .7"></span> Run database updates (updb)</a></li>` : ''}
3254
+ ${running ? `<li><a href hx-post="/actions/ddev-export-db" ${vals()}><span uk-icon="icon: database; ratio: .7"></span> Export the database</a></li>
3255
+ <li><a href hx-get="/fragments/projects/import-db/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3256
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3257
+ title="Put a database back from a dump this workspace holds"><span uk-icon="icon: push; ratio: .7"></span> Import a database…</a></li>
3258
+ <li><a href hx-post="/actions/ddev-snapshot" ${vals()}><span uk-icon="icon: camera; ratio: .7"></span> Take a snapshot</a></li>` : ''}
3259
+ <li><a href hx-get="/fragments/projects/addons/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3260
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3261
+ title="Services and tools DDEV can add to this project — redis, solr, adminer, playwright…"><span uk-icon="icon: plus-circle; ratio: .7"></span> Add-ons — installed and available…</a></li>
3262
+ <li><a href hx-get="/fragments/projects/snapshots/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3263
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3264
+ title="List the database snapshots of this project, restore one, or remove one"><span uk-icon="icon: history; ratio: .7"></span> Snapshots — list and restore…</a></li>
3265
+ ${canBackup ? `<li data-entry="back up"><a href hx-post="/actions/backup" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Back up this ${esc(meta.noun)}</a></li>
3266
+ <li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>` : ''}
3267
+ ${canDrupal && running ? `<li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-drush-sql-drop" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3268
+ title="Drops every table in this project's database."><span uk-icon="icon: trash; ratio: .7"></span> Drop the database (sql:drop)</a></li>` : ''}
3269
+
3270
+ <li class="uk-nav-divider"></li>
3271
+ <li class="uk-nav-header">this project</li>
3272
+ <li><a href hx-get="/fragments/projects/ddev-config/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3273
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: cog; ratio: .7"></span> Configure (ddev config)…</a></li>
3274
+ <li><a href hx-get="/fragments/projects/hostnames/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3275
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: world; ratio: .7"></span> Domains this answers at…</a></li>
3276
+ ${canClone ? `<li data-entry="clone"><a href hx-get="/fragments/projects/clone-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: copy; ratio: .7"></span> Clone this ${esc(meta.noun)}…</a></li>` : ''}
3277
+ ${canRemove ? `<li class="uk-nav-divider"></li>
3278
+ <li><a href class="uk-text-danger" hx-get="/fragments/projects/remove-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3279
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3280
+ title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>` : ''}`;
3281
+ }
3282
+
3283
+ /**
3284
+ * Two gates per control on a project row: the folder must SHIP the script (capability), and the
3285
+ * workspace's type must WANT the action (policy — `type:` in its settings file). Read once per page by
3286
+ * the rows, and once per request by a menu body served on its own.
3287
+ */
3288
+ function rowGates(dir, meta) {
3289
+ const allowed = meta.typeActions;
3290
+ return {
3291
+ allowed,
3292
+ canBackup: allowed.has('backup') && !!findBackupScript(dir),
3293
+ canClone: allowed.has('clone') && fs.existsSync(path.join(dir, 'cmd-tools-clone.sh')),
3294
+ canRemove: allowed.has('remove') && !!findRemoveScript(dir),
3295
+ canRagify: allowed.has('ragify') && !!findRagifyScript(dir),
3296
+ canTest: allowed.has('tests') && fs.existsSync(path.join(dir, 'cmd-tools-testing.sh')),
3297
+ canChecks: allowed.has('checks') && fs.existsSync(path.join(dir, 'cmd-tools-checks.sh')),
3298
+ canUsers: allowed.has('users') && fs.existsSync(path.join(dir, 'cmd-tools-add-users.sh')),
3299
+ canDdev: allowed.has('ddev'),
3300
+ // A DDEV project is not necessarily a Drupal one. Milvus and Penpot run in DDEV with
3301
+ // no composer.json and no Drupal inside, so Composer install/update and every drush
3302
+ // item would offer to operate on something that is not there.
3303
+ canDrupal: allowed.has('drupal'),
3304
+ };
3305
+ }
3306
+
3307
+ /**
3308
+ * What the Tests button says before it opens, for one project: the caption under it and its title.
3309
+ * One quality menu, not two. Setting the environment up, running the suite, reading a report and
3310
+ * running the checks a package declares are all "is this project sound?" — and on a packages
3311
+ * workspace (products, modules, themes) there is no suite at all, so a separate Checks button would
3312
+ * be the only one there and a separate Tests button the only one on a site. The verbs live together
3313
+ * and the menu says which of them apply.
3314
+ */
3315
+ function testsMenuState({ key, dir, p, gates }) {
3316
+ const { canTest, canChecks } = gates;
3317
+ // Running means a live screen session with the name this row's own
3318
+ // buttons give it — the suite runs there, not in the dashboard.
3319
+ const testsSession = screenSessionName(p, 'tests', key);
3320
+ const busy = sessionLive(key, p, 'tests') || sessionLive(key, p, 'tests-setup');
3321
+ const stack = canTest ? testingStackOf(dir, p) : 'none';
3322
+ const busyChecks = sessionLive(key, p, 'checks');
3323
+ const declared = canChecks ? checksDeclaredOf(dir, p) : { declared: 0, ready: 0, blocked: 0, hasPackageJson: false };
3324
+ // The caption answers whichever question this workspace can ask: a site says
3325
+ // whether it could start a suite right now, a package says what it declares.
3326
+ // "ready" is the useful word — a project can carry a whole stack and still fail
3327
+ // every scenario because the web image has no chromium libraries.
3328
+ const ready = canTest ? testingReadiness(dir, p) : null;
3329
+ const caption = canTest
3330
+ ? (busy ? 'running now' : stack === 'none' ? 'not set up' : (ready.ready ? 'ready to test' : 'not ready'))
3331
+ : checksCaption(key, dir, p);
3332
+ const title = canTest ? (stack === 'none' ? 'No testing stack in this project yet' : `Automated testing (${stack})`) : `${declared.declared} check(s) declared in this package`;
3333
+ return { testsSession, busy, stack, busyChecks, declared, ready, caption, title };
3334
+ }
3335
+
3336
+ /**
3337
+ * The Tests menu's body for one project — the list inside the dropdown, before the Buttons settings
3338
+ * arrange it — served by /fragments/row-menu/tests/<ws>/<project> when the menu opens. '' where the
3339
+ * workspace offers none of its verbs.
3340
+ */
3341
+ function testsMenuBody({ key, dir, p, gates }) {
3342
+ const { canTest, canChecks, canUsers } = gates;
3343
+ if (!(canTest || canChecks || canUsers)) return '';
3344
+ const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
3345
+ const { testsSession, busy, stack, busyChecks, declared, ready } = testsMenuState({ key, dir, p, gates });
3346
+ const reports = canTest ? findTestReports(dir, p) : [];
3347
+ const rows = canChecks ? checksRowsOf(dir, p) : [];
3348
+ // Every alias, with the script the package actually declared for it. Seeing the pair is
3349
+ // how you know the mapping is right — and each one runs on its own, because a spelling
3350
+ // failure is not a reason to re-run the linters.
3351
+ const symbol = { ready: '✅', masked: '⛔', refused: '🚫', skipped: '⏭', 'deps-missing': '⏭', 'unresolved-runner': '⏭' };
3352
+ const aliasItems = rows.map((r) => (r.status === 'ready'
3353
+ ? `<li><a href hx-post="/actions/checks" ${vals(`,"only":"${esc(r.id)}"`)} class="${busyChecks ? 'uk-disabled' : ''}" title="${esc(`${r.label} — runs ${r.script}`)}">
3354
+ <span class="checks-alias">${esc(r.id)}</span> <code class="checks-script">${esc(r.script)}</code></a></li>`
3355
+ : `<li><span class="uk-text-meta checks-alias-off" title="${esc(r.reason)}">${symbol[r.status] || '➖'} <span class="checks-alias">${esc(r.id)}</span> <code class="checks-script">${esc(r.script || '—')}</code></span></li>`)).join('');
3356
+ return `<ul class="uk-nav uk-dropdown-nav" uk-nav="multiple: true">
3357
+ ${canUsers ? `<li class="uk-nav-header">test users</li>
3358
+ <li><a href hx-post="/actions/add-users" ${vals()}><span uk-icon="icon: users; ratio: .7"></span> Add the test users</a></li>
3359
+ <li><a href hx-post="/actions/cancel-users" ${vals()}><span uk-icon="icon: minus-circle; ratio: .7"></span> Cancel the test users</a></li>
3360
+ ${canTest || canChecks ? '<li class="uk-nav-divider"></li>' : ''}` : ''}
3361
+ ${canTest ? `<li class="uk-nav-header" data-group="suite">${esc(stack === 'none' ? 'not set up' : stack)}${ready && stack !== 'none' ? esc(ready.ready ? ' · ready' : ` · missing ${ready.missing.join(' and ')}`) : ''}</li>
3362
+ ${ready && !ready.ready && stack !== 'none' ? `<li data-entry="not ready note"><span class="uk-text-meta tests-not-ready">Set up the testing environment first — it installs ${esc(ready.missing.join(' and '))}.</span></li>` : ''}
3363
+ <li data-entry="testing environment"><a href hx-post="/actions/testing-configure" ${vals()} class="${busy ? 'uk-disabled' : ''}"><span uk-icon="icon: cog; ratio: .7"></span> ${stack === 'none' ? 'Set up the testing environment' : 'Re-apply the testing environment'}${busy ? ' (running…)' : ''}</a></li>
3364
+ ${busy ? `<li><a href="/terminal/${encodeURIComponent(testsSession)}" target="_blank" title="Open the session the suite is running in"><span uk-icon="icon: laptop; ratio: .7"></span> Watch it run in <code>${esc(testsSession)}</code></a></li>` : ''}
3365
+ ${stack === 'none' ? '' : `<li><a href hx-post="/actions/testing-run" ${vals()} class="${busy ? 'uk-disabled' : ''}" title="${esc(ready && !ready.ready ? `It will install ${ready.missing.join(' and ')} first, which rebuilds the web image` : 'Run the suite')}"><span uk-icon="icon: play; ratio: .7"></span> Run the tests${busy ? ' (running…)' : ''}</a></li>`}
3366
+ ${reports.length ? `<li class="uk-nav-divider"></li>
3367
+ <li class="uk-nav-header" data-group="reports">${reports.length} report${reports.length > 1 ? 's' : ''}</li>
3368
+ ${reports.map((r, i) => `<li><a href hx-get="/fragments/${esc(key)}/test-report/${encodeURIComponent(p)}?report=${i}" hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal" title="${esc(`${r.rel} · ${humanTime(r.mtime)}`)}"><span uk-icon="icon: file-text; ratio: .7"></span> ${esc(reportLabel(r))}</a></li>`).join('')}` : ''}` : ''}
3369
+
3370
+ ${canChecks ? `${canTest ? '<li class="uk-nav-divider"></li>' : ''}
3371
+ <!-- Nested rather than flat: the aliases are a list of their own, and on a Drupal
3372
+ tree with several reports a flat menu would run to twenty rows. -->
3373
+ <li class="uk-parent act-menu-checks" data-entry="checks">
3374
+ <a href><span uk-icon="icon: check; ratio: .7"></span> Checks <span class="uk-text-meta">${esc(declared.hasPackageJson ? `${declared.declared} declared · ${declared.ready} runnable` : 'no package.json')}</span></a>
3375
+ <ul class="uk-nav-sub checks-sub">
3376
+ <li><a href hx-post="/actions/checks-plan" ${vals()} hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"><span uk-icon="icon: list; ratio: .7"></span> What this project checks</a></li>
3377
+ ${declared.ready ? `<li><a href hx-post="/actions/checks" ${vals()} class="${busyChecks ? 'uk-disabled' : ''}"><span uk-icon="icon: play; ratio: .7"></span> Run every check${busyChecks ? ' (running…)' : ''}</a></li>` : ''}
3378
+ ${aliasItems ? `<li class="uk-nav-divider"></li>${aliasItems}` : '<li><span class="uk-text-meta">This project declares none.</span></li>'}
3379
+ </ul>
3380
+ </li>` : ''}
3381
+ </ul>`;
3382
+ }
3383
+
3384
+ /**
3385
+ * The SCREENS menu's body for one project, and what rides on it. `nav` is the list inside the dropdown
3386
+ * (the sessions, the Claude items, new screen, all screens), before the Buttons settings arrange it;
3387
+ * `oob` is swapped out of band into the row — the hand a waiting session raises on the button, the
3388
+ * agent badge in the title and the plugins' badges. Served by /fragments/row-menu/screens/<ws>/<project>
3389
+ * on open and on the row's own poll (the legacy /fragments/projects/screens/… answers the same).
3390
+ */
3391
+ async function screensMenuBody(ws, project) {
3392
+ const sDir = path.join(workspaceDir(ws), project);
3393
+ const sessions = fs.existsSync(sDir) ? await screensForDir(sDir) : [];
3394
+ const { sessionWaitingWhy } = require('./screens');
3395
+ const waiting = sessions.map((s) => ({ s, why: sessionWaitingWhy(s) })).filter((x) => x.why);
3396
+ const { agentSessions } = require('./screens');
3397
+ const agentsHere = agentSessions(sessions);
3398
+ const items = sessions.map((s) => {
3399
+ const why = sessionWaitingWhy(s);
3400
+ const isAgent = agentsHere.has(s.session);
3401
+ // A session is named after where it runs — `test--ruverify1--jira-webship-9999` — so the
3402
+ // project's own name is on every line of a menu that is already inside that project. The
3403
+ // line leads with the part that differs; the whole name is the title, and the row is the
3404
+ // one place it could ever be ambiguous.
3405
+ const short = s.session.startsWith(`${ws}--${project}--`)
3406
+ ? s.session.slice(`${ws}--${project}--`.length) : s.session;
3407
+ // Dead is not runnable: `screen -ls` lists a session whose process is gone, and opening one
3408
+ // attaches to nothing. Saying so beats a terminal that comes up empty.
3409
+ const dead = /dead/i.test(s.state || '');
3410
+ return `
3411
+ <li data-entry="open a screen"><a href data-open-terminal="${esc(s.session)}" title="${esc(s.session)}${
3412
+ s.started ? ` · started ${esc(s.started)}` : ''}">
3413
+ ${isAgent ? iconHtml('tabler:robot-face', 0.7) : '<span uk-icon="icon: laptop; ratio: .7"></span>'} ${esc(short)}
3414
+ <span class="screen-item-state">${
3415
+ why ? `<span class="uk-label uk-label-warning" title="${esc(why)}">✋ waiting</span>`
3416
+ : dead ? '<span class="uk-label">dead</span>'
3417
+ : '<span class="uk-label uk-label-success">running</span>'}${
3418
+ isAgent ? ' <span class="uk-label">agent</span>' : ''}</span></a></li>`;
3419
+ }).join('');
3420
+ // Out of band, into the row's own SCREENS button: this poll already runs every 15 seconds for
3421
+ // the menu, so the hand on the row costs no request of its own and cannot be staler than the
3422
+ // menu behind it. Always sent, empty when nothing is waiting — an OOB swap that is only sent
3423
+ // sometimes leaves the last hand up after the question has been answered.
3424
+ const hand = `<span class="screens-hand${waiting.length ? ' is-waiting' : ''}" id="screens-hand-${esc(ws)}-${esc(project)}" hx-swap-oob="true"${
3425
+ waiting.length ? ` title="${esc(waiting.map((x) => `${x.s.session}: ${x.why}`).join(' · '))}"` : ''}>${
3426
+ waiting.length ? `✋${waiting.length > 1 ? ` ${waiting.length}` : ''}` : ''}</span>`;
3427
+ // And whether an AGENT is at work in this project — asked of the process table rather than of
3428
+ // the session's name, so a session started from the CLI or by hand counts the same. Sent
3429
+ // always, empty when there is none: an OOB swap that is only sent sometimes leaves the last
3430
+ // answer standing after the agent has finished.
3431
+ const agents = agentsHere;
3432
+ const agentWaiting = waiting.some((x) => agents.has(x.s.session));
3433
+ const agentBadge = `<span class="project-title-agent" id="agent-badge-${esc(ws)}-${esc(project)}" hx-swap-oob="true">${
3434
+ agents.size
3435
+ ? `<span class="uk-label ${agentWaiting ? 'uk-label-warning' : 'uk-label-success'} agent-live" title="${esc([...agents].join(' · '))}${
3436
+ agentWaiting ? ' — and it has stopped to ask you something' : ''}">${
3437
+ // Tabler's robot FACE rather than the 🤖 emoji, and rather than Tabler's whole-robot
3438
+ // icon. An emoji is drawn by the platform's own font, so the same badge is a grey
3439
+ // outline on one machine and a yellow cartoon on another, at a size and weight nothing
3440
+ // here controls. And `robot` draws a head, antenna, arms and legs — at the 14px a label
3441
+ // gives it, that is a smudge. `robot-face` spends the same pixels on four features
3442
+ // instead of a dozen, which is the one that can still be read. Compared side by side at
3443
+ // badge size rather than picked by name.
3444
+ iconHtml('tabler:robot-face', 0.8)} ${agentWaiting ? 'waiting for you' : 'agent working'}${
3445
+ agents.size > 1 ? ` (${agents.size})` : ''}</span>`
3446
+ : ''}</span>`;
3447
+ // The plugins' badges in the card's bottom-right cluster beside the pen (which work item an agent
3448
+ // here is working, say). Sent on every poll, empty when there is nothing, so each clears itself.
3449
+ const keysBadge = `<span class="project-plugin-badges" id="plugin-badges-${esc(ws)}-${esc(project)}" hx-swap-oob="true">${
3450
+ pluginProjectBadgesHtml({ key: ws, project: project, dir: sDir, sessions })}</span>`;
3451
+ const claudeItems = require('./actions/claude-session').menuItems({ ws: ws, project: project, sessions, agents: agentsHere, dir: sDir });
3452
+ const screensNav = `${items || '<li class="uk-nav-header">No screens in this project</li>'}${claudeItems ? `
3453
+ <li class="uk-nav-divider"></li>${claudeItems}` : ''}
3454
+ <li class="uk-nav-divider"></li>
3455
+ <li><a href hx-get="/fragments/screen-new-form?workspace=${encodeURIComponent(ws)}&amp;project=${encodeURIComponent(project)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3456
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: plus-circle; ratio: .7"></span> New screen here…</a></li>
3457
+ <li><a href hx-get="/fragments/screen-new-form?workspace=${encodeURIComponent(ws)}&amp;project=${encodeURIComponent(project)}&amp;kind=claude" hx-target="#editor-modal-body" hx-swap="innerHTML"
3458
+ hx-on::after:request="UIkit.modal('#editor-modal').show()">${iconHtml('tabler:sparkles', 0.7)} Claude Code here…</a></li>
3459
+ <li><a href="/screens"><span uk-icon="icon: list; ratio: .7"></span> All screens</a></li>`;
3460
+ return { nav: screensNav, oob: hand + agentBadge + keysBadge, dir: sDir };
3461
+ }
3462
+
3463
+ // The row menus the core draws itself whose bodies load when they open — the slots
3464
+ // /fragments/row-menu/<slot>/<ws>/<project> answers with no `body` parameter (coreRowMenuBody).
3465
+ const CORE_ROW_MENUS = new Set(['ddev', 'composer', 'tests', 'screens']);
3466
+
3467
+ /**
3468
+ * The body of one of the core's row menus for one project, as `{ status, html }`: the dropdown's inner
3469
+ * HTML — one `<ul class="uk-nav uk-dropdown-nav">` (SCREENS adds what it swaps out of band) — before the
3470
+ * Buttons settings arrange it. Everything the row knew when it drew the button is read again here, for
3471
+ * this project alone: the workspace's gates, the DDEV registry, the sessions. 404 for a folder that is
3472
+ * not there or a menu the row would not have drawn on it, so a stale page cannot ask for verbs the
3473
+ * project does not carry.
3474
+ */
3475
+ async function coreRowMenuBody(slot, key, project) {
3476
+ const none = (why) => ({ status: 404, html: `<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">${esc(why)}</li></ul>` });
3477
+ const meta = loadWorkspaces()[key];
3478
+ const dir = workspaceDir(key);
3479
+ const projectPath = path.join(dir, project);
3480
+ if (!meta || !fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) return none('No such project.');
3481
+ const gates = rowGates(dir, meta);
3482
+ const isDdev = ddevProjectName(projectPath) !== null;
3483
+ if (slot === 'ddev') {
3484
+ if (!gates.canDdev || !isDdev) return none('Not a DDEV project.');
3485
+ const ddev = ddevStateOf(dir, project, await ddevStatusMap());
3486
+ const items = ddevMenuItemsHtml({ key, p: project, meta, projectPath, gates, ddev });
3487
+ return { status: 200, html: `<ul class="uk-nav uk-dropdown-nav ddev-nav-columns">${items}\n </ul>` };
3488
+ }
3489
+ if (slot === 'composer') {
3490
+ const body = composerMenuBody(key, project, projectPath, isDdev, meta.composerMode);
3491
+ return body ? { status: 200, html: body } : none('No composer.json here.');
3492
+ }
3493
+ if (slot === 'tests') {
3494
+ const body = testsMenuBody({ key, dir, p: project, gates });
3495
+ return body ? { status: 200, html: body } : none('No tests to offer here.');
3496
+ }
3497
+ if (slot === 'screens') {
3498
+ const { nav, oob } = await screensMenuBody(key, project);
3499
+ return { status: 200, html: `<ul class="uk-nav uk-dropdown-nav">${nav}</ul>${oob}` };
3500
+ }
3501
+ return none('No such menu.');
3502
+ }
3503
+
3504
+ /**
3505
+ * The Buttons settings page lists what each menu has been seen to hold (row-layout's seen record). A
3506
+ * menu whose body loads when it opens (coreRowMenuBody) is recorded when one opens, so on a dashboard
3507
+ * where nobody has opened COMPOSER or Tests yet the page would list nothing for them: before it is drawn,
3508
+ * each such menu with nothing on record is built once, for the first project that carries it, and
3509
+ * arranged — which is what records it. DDEV is counted on every row, so it is on record already, and
3510
+ * SCREENS lists whatever sessions happen to run, which is nothing to learn from.
3511
+ */
3512
+ async function learnCoreMenus() {
3513
+ for (const slot of ['composer', 'tests']) {
3514
+ if (Object.keys(rowLayout.seenMenu(slot).groups).length) continue;
3515
+ let learned = false;
3516
+ for (const [key, meta] of Object.entries(loadWorkspaces())) {
3517
+ if (learned || meta.kind !== 'projects') continue;
3518
+ let projects = [];
3519
+ try { projects = listProjects(workspaceDir(key)).slice(0, 25); } catch (_) { continue; }
3520
+ for (const p of projects) {
3521
+ let answer;
3522
+ try { answer = await coreRowMenuBody(slot, key, p); } catch (_) { continue; }
3523
+ if (answer.status !== 200 || !/<a\b/.test(answer.html)) continue;
3524
+ const projectPath = path.join(workspaceDir(key), p);
3525
+ try {
3526
+ rowLayout.arrangeButtonHtml(slot, answer.html, { layout: rowLayout.loadRowLayout(), key, project: p, isDdev: ddevProjectName(projectPath) !== null,
3527
+ hasComposer: require('./actions/composer').hasComposerJson(projectPath),
3528
+ hasNode: require('./actions/node').hasPackageJson(projectPath), hasGit: hasOwnGit(projectPath) });
3529
+ } catch (_) { /* the page lists less, nothing breaks */ }
3530
+ learned = true;
3531
+ break;
3532
+ }
3533
+ }
3534
+ }
3535
+ }
3536
+
3152
3537
  async function projectRowsHtml(key, dir, state) {
3153
3538
  const meta = loadWorkspaces()[key];
3154
3539
  // Read once per page: every row on it is arranged the same way.
3155
3540
  const rowLayoutNow = rowLayout.loadRowLayout();
3156
3541
  const projects = listProjects(dir);
3157
- // Two gates per control: the folder must SHIP the script (capability), and the
3158
- // workspace's type must WANT the action (policy — `type:` in its settings file).
3159
- const allowed = meta.typeActions;
3160
- const canBackup = allowed.has('backup') && !!findBackupScript(dir);
3161
- const canClone = allowed.has('clone') && fs.existsSync(path.join(dir, 'cmd-tools-clone.sh'));
3162
- const canRemove = allowed.has('remove') && !!findRemoveScript(dir);
3163
- const canRagify = allowed.has('ragify') && !!findRagifyScript(dir);
3164
- const canTest = allowed.has('tests') && fs.existsSync(path.join(dir, 'cmd-tools-testing.sh'));
3165
- const canChecks = allowed.has('checks') && fs.existsSync(path.join(dir, 'cmd-tools-checks.sh'));
3166
- const canUsers = allowed.has('users') && fs.existsSync(path.join(dir, 'cmd-tools-add-users.sh'));
3167
- const canDdev = allowed.has('ddev');
3168
- // A DDEV project is not necessarily a Drupal one. Milvus and Penpot run in DDEV with
3169
- // no composer.json and no Drupal inside, so Composer install/update and every drush
3170
- // item would offer to operate on something that is not there.
3171
- const canDrupal = allowed.has('drupal');
3542
+ // Two gates per control (rowGates): the folder ships the script, and the workspace's type wants the action.
3543
+ const gates = rowGates(dir, meta);
3544
+ const { allowed, canBackup, canClone, canRemove, canRagify, canTest, canChecks, canUsers, canDdev } = gates;
3172
3545
  const statuses = await ddevStatusMap();
3173
3546
  // One question per SERVER for the whole table, not one per row — a workspace can mix projects
3174
3547
  // bound to different servers, and a stopped one answers `up: false` rather than making every row
@@ -3212,7 +3585,8 @@ async function projectRowsHtml(key, dir, state) {
3212
3585
  const pg = paginate(sorted, st);
3213
3586
  const rows = pg.slice.map((it) => {
3214
3587
  const p = it.name;
3215
- const { projectPath, ddevName, isDdev, status, running, mailpit } = ddevStateOf(dir, p, statuses);
3588
+ const ddev = ddevStateOf(dir, p, statuses);
3589
+ const { projectPath, ddevName, isDdev, status, running } = ddev;
3216
3590
  // The IDE menu is built at row scope even though it renders in the state strip, right after
3217
3591
  // JIRA. Defining it inside the strip's IIFE made it a ReferenceError at the point of use — and because a
3218
3592
  // row's markup is built defensively, that silently dropped the whole action group
@@ -3269,106 +3643,8 @@ async function projectRowsHtml(key, dir, state) {
3269
3643
  // Counted from the rendered menu below rather than hand-maintained: the
3270
3644
  // number a row shows is the number of things it can actually do.
3271
3645
  const ddevCaption = !canDdev ? '' : (isDdev ? '' : 'not configured');
3272
- // The menu's own body, built first so the caption can count what is in it.
3273
- const ddevMenuItems = !(canDdev && isDdev) ? '' : `
3274
- <li class="uk-nav-header" data-group="project">${esc(ddevName)}</li>
3275
- ${running
3276
- ? `<li data-entry="start-stop"><a href hx-post="/actions/ddev-stop" ${vals()}><span uk-icon="icon: ban; ratio: .7"></span> Stop</a></li>`
3277
- : `<li data-entry="start-stop"><a href hx-post="/actions/ddev-start" ${vals()}><span uk-icon="icon: play; ratio: .7"></span> Start</a></li>`}
3278
- <li><a href hx-post="/actions/ddev-restart" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Restart</a></li>
3279
- <li><a href hx-post="/actions/ddev-describe" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Describe (URLs, services)</a></li>
3280
- <li><a href hx-post="/actions/ddev-logs" ${vals()}><span uk-icon="icon: file-text; ratio: .7"></span> Web logs (last 100)</a></li>
3281
- <li><a href="https://${esc(ddevName)}.ddev.site" target="_blank"><span uk-icon="icon: link-external; ratio: .7"></span> Canonical URL</a></li>
3282
- ${require('./ddev-custom-commands').menuGroupsHtml({ key, project: p, projectPath, running })}
3283
-
3284
- <li class="uk-nav-divider"></li>
3285
- <li class="uk-nav-header">build</li>
3286
- ${canDrupal ? `<li><a href hx-post="/actions/ddev-composer-install" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Composer install</a></li>
3287
- <li><a href hx-post="/actions/ddev-composer-update" ${vals()}><span uk-icon="icon: cloud-download; ratio: .7"></span> Composer update</a></li>` : ''}
3288
- <li><a href hx-post="/actions/ddev-mutagen-sync" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Mutagen sync</a></li>
3289
- <!-- Beyond sync: is it behind or is it broken, and the reset for when it cannot be
3290
- repaired. Reset stops the project and removes the volume, so it asks first — the
3291
- files on disk are the source, and the volume is rebuilt on the next start. -->
3292
- ${ddevMail.menuItemHtml({ workspace: key, project: p, running, mailpit })}
3293
- <li><a href hx-post="/actions/ddev-mutagen-status" ${vals()}
3294
- title="Is the file sync up to date, behind, or not enabled at all"><span uk-icon="icon: info; ratio: .7"></span> Mutagen status</a></li>
3295
- <li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-mutagen-reset" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3296
- title="Stop the project and remove its Mutagen volume, for a sync that cannot be repaired. The files on disk are the source; the volume is rebuilt on the next start."><span uk-icon="icon: refresh; ratio: .7"></span> Reset the sync…</a></li>
3297
-
3298
- ${canDrupal && running ? `<li class="uk-nav-divider"></li>
3299
- <li class="uk-nav-header">drupal</li>
3300
- <li><a href hx-post="/actions/ddev-drush-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Drush status</a></li>
3301
- <li><a href hx-post="/actions/ddev-drush-cr" ${vals()}><span uk-icon="icon: bolt; ratio: .7"></span> Rebuild the cache (cr)</a></li>
3302
- <li><a href hx-post="/actions/ddev-drush-uli" ${vals()}><span uk-icon="icon: unlock; ratio: .7"></span> One-time login link (uli)</a></li>
3303
-
3304
- <li class="uk-nav-divider"></li>
3305
- <li class="uk-nav-header">configuration</li>
3306
- <li><a href hx-post="/actions/ddev-drush-cim" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Import configuration (cim)</a></li>
3307
- <li><a href hx-post="/actions/ddev-drush-cex" ${vals()}><span uk-icon="icon: upload; ratio: .7"></span> Export configuration (cex)</a></li>` : ''}
3308
-
3309
- <li class="uk-nav-divider"></li>
3310
- <!-- Step debugging. Two items rather than a toggle, because ddev describe does not report
3311
- which state it is in (checked) and a row must not spend a subprocess to find out — so
3312
- the menu offers both, and asks when you want to know. Xdebug makes PHP about three
3313
- times slower while it is on, which is why "off" is worth being one click away.
3314
- No backticks in this comment: the menu is one template literal. -->
3315
- <li class="uk-nav-header">debugging</li>
3316
- ${running ? `<li><a href hx-post="/actions/ddev-xdebug-on" ${vals()}><span uk-icon="icon: code; ratio: .7"></span> Xdebug on</a></li>
3317
- <li><a href hx-post="/actions/ddev-xdebug-off" ${vals()}><span uk-icon="icon: code; ratio: .7"></span> Xdebug off — PHP is ~3&times; slower while it is on</a></li>
3318
- <li><a href hx-post="/actions/ddev-xdebug-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Which is it now?</a></li>` : ''}
3319
- <!-- When a project is broken, the four questions worth asking before guessing. Each reads
3320
- and reports; none of them changes anything. They are here rather than in a document
3321
- because the moment you need them is the moment you do not want to go looking. -->
3322
- <li><a href hx-post="/actions/ddev-diagnose" ${vals()}
3323
- title="Docker, the network, HTTPS and this project's health, in one report"><span uk-icon="icon: lifesaver; ratio: .7"></span> Diagnose — Docker, network, HTTPS</a></li>
3324
- <li><a href hx-post="/actions/ddev-tls-diagnose" ${vals()}
3325
- title="Why the browser does not trust this project's certificate"><span uk-icon="icon: lock; ratio: .7"></span> Diagnose the certificate</a></li>
3326
- <li><a href hx-post="/actions/ddev-port-diagnose" ${vals()}
3327
- title="What else on this machine is holding the ports DDEV needs"><span uk-icon="icon: server; ratio: .7"></span> Diagnose the ports</a></li>
3328
- <li><a href hx-post="/actions/ddev-db-match" ${vals()}
3329
- title="Is the database volume on disk the engine and version this project's config asks for"><span uk-icon="icon: database; ratio: .7"></span> Does the database match the config?</a></li>
3330
- <!-- The one thing on this menu that needs a person at a keyboard: ssh-add asks for the
3331
- passphrase, so it runs in a screen session and the item says to go and answer it.
3332
- Without it a composer install against a private repository fails at the first
3333
- package nobody can read. -->
3334
- <li><a href hx-post="/actions/ddev-auth-ssh" ${vals()}
3335
- title="Add your SSH keys to the agent container every project borrows them from — it asks for the passphrase in a screen session"><span uk-icon="icon: unlock; ratio: .7"></span> Add my SSH keys — then answer the passphrase in Screens</a></li>
3336
- <!-- The only verb here whose effect reaches people who are not on this machine: a tunnel
3337
- provider publishes this project on the open internet, for as long as the session
3338
- runs. Hence the confirm dialog, and hence a screen session — the URL is printed in
3339
- there, and closing a tab must not take somebody's demo down. -->
3340
- ${running ? `<li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-share" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3341
- title="Publish this project on the internet through a tunnel (ngrok), until the session is stopped. Anyone with the address can reach the site, including its admin."><span uk-icon="icon: world; ratio: .7"></span> Share on the internet…</a></li>` : ''}
3342
-
3343
- <li class="uk-nav-header">database</li>
3344
- ${canDrupal && running ? `<li><a href hx-post="/actions/ddev-drush-updb" ${vals()}><span uk-icon="icon: database; ratio: .7"></span> Run database updates (updb)</a></li>` : ''}
3345
- ${running ? `<li><a href hx-post="/actions/ddev-export-db" ${vals()}><span uk-icon="icon: database; ratio: .7"></span> Export the database</a></li>
3346
- <li><a href hx-get="/fragments/projects/import-db/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3347
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3348
- title="Put a database back from a dump this workspace holds"><span uk-icon="icon: push; ratio: .7"></span> Import a database…</a></li>
3349
- <li><a href hx-post="/actions/ddev-snapshot" ${vals()}><span uk-icon="icon: camera; ratio: .7"></span> Take a snapshot</a></li>` : ''}
3350
- <li><a href hx-get="/fragments/projects/addons/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3351
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3352
- title="Services and tools DDEV can add to this project — redis, solr, adminer, playwright…"><span uk-icon="icon: plus-circle; ratio: .7"></span> Add-ons — installed and available…</a></li>
3353
- <li><a href hx-get="/fragments/projects/snapshots/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3354
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3355
- title="List the database snapshots of this project, restore one, or remove one"><span uk-icon="icon: history; ratio: .7"></span> Snapshots — list and restore…</a></li>
3356
- ${canBackup ? `<li data-entry="back up"><a href hx-post="/actions/backup" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Back up this ${esc(meta.noun)}</a></li>
3357
- <li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>` : ''}
3358
- ${canDrupal && running ? `<li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-drush-sql-drop" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3359
- title="Drops every table in this project's database."><span uk-icon="icon: trash; ratio: .7"></span> Drop the database (sql:drop)</a></li>` : ''}
3360
-
3361
- <li class="uk-nav-divider"></li>
3362
- <li class="uk-nav-header">this project</li>
3363
- <li><a href hx-get="/fragments/projects/ddev-config/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3364
- hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: cog; ratio: .7"></span> Configure (ddev config)…</a></li>
3365
- <li><a href hx-get="/fragments/projects/hostnames/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3366
- hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: world; ratio: .7"></span> Domains this answers at…</a></li>
3367
- ${canClone ? `<li data-entry="clone"><a href hx-get="/fragments/projects/clone-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: copy; ratio: .7"></span> Clone this ${esc(meta.noun)}…</a></li>` : ''}
3368
- ${canRemove ? `<li class="uk-nav-divider"></li>
3369
- <li><a href class="uk-text-danger" hx-get="/fragments/projects/remove-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3370
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3371
- title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>` : ''}`;
3646
+ // The menu's own body, built first so the caption can count what is in it (ddevMenuItemsHtml).
3647
+ const ddevMenuItems = !(canDdev && isDdev) ? '' : ddevMenuItemsHtml({ key, p, meta, projectPath, gates, ddev });
3372
3648
  // Arranged before it is counted, so the caption counts what the menu really shows.
3373
3649
  const ddevItems = rowLayout.arrangeNavInner('ddev', ddevMenuItems, rowCtx);
3374
3650
  const ddevCount = (ddevItems.match(/<a /g) || []).length;
@@ -3376,79 +3652,25 @@ async function projectRowsHtml(key, dir, state) {
3376
3652
  title="This folder has no .ddev/config.yaml — set one up so it can start"
3377
3653
  hx-get="/fragments/projects/ddev-config/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3378
3654
  hx-target="#editor-modal-body" hx-swap="innerHTML"
3379
- hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: cog; ratio: .7"></span> Configure DDEV</button><span class="act-status uk-text-meta">${esc(ddevCaption)}</span></span>` : ''}${canDdev && isDdev ? `<span class="act-with-status"><div class="uk-inline act-menu act-menu-ddev">
3380
- <button class="uk-button uk-button-default uk-button-small" type="button" title="Start, stop, back up and inspect this DDEV project"><span uk-icon="icon: cog; ratio: .7"></span> DDEV <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
3381
- <div uk-dropdown="mode: click; pos: bottom-right" class="ddev-dropdown-wide"><ul class="uk-nav uk-dropdown-nav ddev-nav-columns">${ddevItems}
3382
- </ul></div>
3383
- </div><span class="act-status uk-text-meta">${ddevCount} command${ddevCount === 1 ? '' : 's'}</span></span>` : ''}`;
3655
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: cog; ratio: .7"></span> Configure DDEV</button><span class="act-status uk-text-meta">${esc(ddevCaption)}</span></span>` : ''}${canDdev && isDdev ? menuShellHtml('ddev', {
3656
+ // The items arrive from /fragments/row-menu/ddev/<ws>/<project> when the menu opens (coreRowMenuBody);
3657
+ // the row carries the button and the count.
3658
+ key, project: p, caption: `${ddevCount} command${ddevCount === 1 ? '' : 's'}`, dropdownClasses: 'ddev-dropdown-wide',
3659
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="Start, stop, back up and inspect this DDEV project"><span uk-icon="icon: cog; ratio: .7"></span> DDEV <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
3660
+ }) : ''}`;
3384
3661
  // Order: what it is doing, the runtime menus that change that, then the
3385
3662
  // Tests menu and the result it produced.
3386
3663
  const testsMenu = `${canTest || canChecks || canUsers ? (() => {
3387
- // One quality menu, not two. Setting the environment up, running the suite, reading a
3388
- // report and running the checks a package declares are all "is this project sound?" —
3389
- // and on a packages workspace (products, modules, themes) there is no suite at all, so a
3390
- // separate Checks button would be the only one there and a separate Tests button the
3391
- // only one on a site. The verbs live together and the menu says which of them apply.
3392
- const reports = canTest ? findTestReports(dir, p) : [];
3393
- // Running means a live screen session with the name this row's own
3394
- // buttons give it — the suite runs there, not in the dashboard.
3395
- const testsSession = screenSessionName(p, 'tests', key);
3396
- const busy = sessionLive(key, p, 'tests') || sessionLive(key, p, 'tests-setup');
3397
- const stack = canTest ? testingStackOf(dir, p) : 'none';
3398
- const busyChecks = sessionLive(key, p, 'checks');
3399
- const declared = canChecks ? checksDeclaredOf(dir, p) : { declared: 0, ready: 0, blocked: 0, hasPackageJson: false };
3400
- const rows = canChecks ? checksRowsOf(dir, p) : [];
3401
- // The caption answers whichever question this workspace can ask: a site says
3402
- // whether it could start a suite right now, a package says what it declares.
3403
- // "ready" is the useful word — a project can carry a whole stack and still fail
3404
- // every scenario because the web image has no chromium libraries.
3405
- const ready = canTest ? testingReadiness(dir, p) : null;
3406
- const caption = canTest
3407
- ? (busy ? 'running now' : stack === 'none' ? 'not set up' : (ready.ready ? 'ready to test' : 'not ready'))
3408
- : checksCaption(key, dir, p);
3409
-
3410
- // Every alias, with the script the package actually declared for it. Seeing the pair is
3411
- // how you know the mapping is right — and each one runs on its own, because a spelling
3412
- // failure is not a reason to re-run the linters.
3413
- const symbol = { ready: '✅', masked: '⛔', refused: '🚫', skipped: '⏭', 'deps-missing': '⏭', 'unresolved-runner': '⏭' };
3414
- const aliasItems = rows.map((r) => (r.status === 'ready'
3415
- ? `<li><a href hx-post="/actions/checks" ${vals(`,"only":"${esc(r.id)}"`)} class="${busyChecks ? 'uk-disabled' : ''}" title="${esc(`${r.label} — runs ${r.script}`)}">
3416
- <span class="checks-alias">${esc(r.id)}</span> <code class="checks-script">${esc(r.script)}</code></a></li>`
3417
- : `<li><span class="uk-text-meta checks-alias-off" title="${esc(r.reason)}">${symbol[r.status] || '➖'} <span class="checks-alias">${esc(r.id)}</span> <code class="checks-script">${esc(r.script || '—')}</code></span></li>`)).join('');
3418
-
3419
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-tests">
3420
- <!-- Never the danger variant: a failing suite is already stated in red on the state
3421
- strip above the name, and a permanently red menu button reads as "this control
3422
- is dangerous" rather than "those tests failed". -->
3423
- <button class="uk-button uk-button-secondary uk-button-small" type="button" title="${esc(canTest ? (stack === 'none' ? 'No testing stack in this project yet' : `Automated testing (${stack})`) : `${declared.declared} check(s) declared in this package`)}"><span uk-icon="icon: bolt; ratio: .7"></span> Tests <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
3424
-
3425
- <div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav" uk-nav="multiple: true">
3426
- ${canUsers ? `<li class="uk-nav-header">test users</li>
3427
- <li><a href hx-post="/actions/add-users" ${vals()}><span uk-icon="icon: users; ratio: .7"></span> Add the test users</a></li>
3428
- <li><a href hx-post="/actions/cancel-users" ${vals()}><span uk-icon="icon: minus-circle; ratio: .7"></span> Cancel the test users</a></li>
3429
- ${canTest || canChecks ? '<li class="uk-nav-divider"></li>' : ''}` : ''}
3430
- ${canTest ? `<li class="uk-nav-header" data-group="suite">${esc(stack === 'none' ? 'not set up' : stack)}${ready && stack !== 'none' ? esc(ready.ready ? ' · ready' : ` · missing ${ready.missing.join(' and ')}`) : ''}</li>
3431
- ${ready && !ready.ready && stack !== 'none' ? `<li data-entry="not ready note"><span class="uk-text-meta tests-not-ready">Set up the testing environment first — it installs ${esc(ready.missing.join(' and '))}.</span></li>` : ''}
3432
- <li data-entry="testing environment"><a href hx-post="/actions/testing-configure" ${vals()} class="${busy ? 'uk-disabled' : ''}"><span uk-icon="icon: cog; ratio: .7"></span> ${stack === 'none' ? 'Set up the testing environment' : 'Re-apply the testing environment'}${busy ? ' (running…)' : ''}</a></li>
3433
- ${busy ? `<li><a href="/terminal/${encodeURIComponent(testsSession)}" target="_blank" title="Open the session the suite is running in"><span uk-icon="icon: laptop; ratio: .7"></span> Watch it run in <code>${esc(testsSession)}</code></a></li>` : ''}
3434
- ${stack === 'none' ? '' : `<li><a href hx-post="/actions/testing-run" ${vals()} class="${busy ? 'uk-disabled' : ''}" title="${esc(ready && !ready.ready ? `It will install ${ready.missing.join(' and ')} first, which rebuilds the web image` : 'Run the suite')}"><span uk-icon="icon: play; ratio: .7"></span> Run the tests${busy ? ' (running…)' : ''}</a></li>`}
3435
- ${reports.length ? `<li class="uk-nav-divider"></li>
3436
- <li class="uk-nav-header" data-group="reports">${reports.length} report${reports.length > 1 ? 's' : ''}</li>
3437
- ${reports.map((r, i) => `<li><a href hx-get="/fragments/${esc(key)}/test-report/${encodeURIComponent(p)}?report=${i}" hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal" title="${esc(`${r.rel} · ${humanTime(r.mtime)}`)}"><span uk-icon="icon: file-text; ratio: .7"></span> ${esc(reportLabel(r))}</a></li>`).join('')}` : ''}` : ''}
3438
-
3439
- ${canChecks ? `${canTest ? '<li class="uk-nav-divider"></li>' : ''}
3440
- <!-- Nested rather than flat: the aliases are a list of their own, and on a Drupal
3441
- tree with several reports a flat menu would run to twenty rows. -->
3442
- <li class="uk-parent act-menu-checks" data-entry="checks">
3443
- <a href><span uk-icon="icon: check; ratio: .7"></span> Checks <span class="uk-text-meta">${esc(declared.hasPackageJson ? `${declared.declared} declared · ${declared.ready} runnable` : 'no package.json')}</span></a>
3444
- <ul class="uk-nav-sub checks-sub">
3445
- <li><a href hx-post="/actions/checks-plan" ${vals()} hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"><span uk-icon="icon: list; ratio: .7"></span> What this project checks</a></li>
3446
- ${declared.ready ? `<li><a href hx-post="/actions/checks" ${vals()} class="${busyChecks ? 'uk-disabled' : ''}"><span uk-icon="icon: play; ratio: .7"></span> Run every check${busyChecks ? ' (running…)' : ''}</a></li>` : ''}
3447
- ${aliasItems ? `<li class="uk-nav-divider"></li>${aliasItems}` : '<li><span class="uk-text-meta">This project declares none.</span></li>'}
3448
- </ul>
3449
- </li>` : ''}
3450
- </ul></div>
3451
- </div><span class="act-status uk-text-meta">${esc(caption)}</span></span>`;
3664
+ // One quality menu, not two (testsMenuState says why). The row carries the button and the
3665
+ // caption; the items arrive from /fragments/row-menu/tests/<ws>/<project> when the menu opens.
3666
+ const t = testsMenuState({ key, dir, p, gates });
3667
+ return menuShellHtml('tests', {
3668
+ key, project: p, caption: t.caption,
3669
+ // Never the danger variant: a failing suite is already stated in red on the state
3670
+ // strip above the name, and a permanently red menu button reads as "this control
3671
+ // is dangerous" rather than "those tests failed".
3672
+ html: `<button class="uk-button uk-button-secondary uk-button-small" type="button" title="${esc(t.title)}"><span uk-icon="icon: bolt; ratio: .7"></span> Tests <span uk-icon="icon: chevron-down; ratio: .6"></span></button>`,
3673
+ });
3452
3674
  })() : ''}`;
3453
3675
  const screensMenu = (() => {
3454
3676
  const n = projectScreenCount(projectPath);
@@ -3458,15 +3680,14 @@ async function projectRowsHtml(key, dir, state) {
3458
3680
  // the dropdown's own 15s poll, swapped out of band (see /fragments/projects/screens/…), so
3459
3681
  // it costs no request of its own and is never staler than the menu behind it.
3460
3682
  const handId = `screens-hand-${key}-${p}`;
3461
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-screens">
3462
- <button class="uk-button ${n ? 'uk-button-primary' : 'uk-button-default'} uk-button-small" type="button" aria-label="SCREENS${n ? `, ${n} running` : ''}"
3463
- title="${n ? `${n} screen session${n > 1 ? 's' : ''} running in this project` : 'Screen sessions running in this project'}"><span uk-icon="icon: laptop; ratio: .7"></span> SCREENS${n ? ` <span class="uk-badge">${n}</span>` : ''}<span class="screens-hand" id="${esc(handId)}"></span> <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
3464
- <div uk-dropdown="mode: click; pos: bottom-right">
3465
- <ul class="uk-nav uk-dropdown-nav" hx-get="/fragments/projects/screens/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-trigger="load, every 30s, screens-changed from:body, menu-opened" hx-target="this" hx-swap="innerHTML">
3466
- <li class="uk-nav-header">loading…</li>
3467
- </ul>
3468
- </div>
3469
- </div><span class="act-status uk-text-meta">${n ? `${n} running` : 'none'}</span></span>`;
3683
+ // The same shell as the other row menus (its body is /fragments/row-menu/screens/<ws>/<project>,
3684
+ // screensMenuBody) — and besides opening, it still asks on load, every 30 seconds and when a
3685
+ // screen changes, because the hand and the agent badge on the row ride on that answer.
3686
+ return menuShellHtml('screens', {
3687
+ key, project: p, caption: n ? `${n} running` : 'none', triggers: 'load, every 30s, screens-changed from:body',
3688
+ html: `<button class="uk-button ${n ? 'uk-button-primary' : 'uk-button-default'} uk-button-small" type="button" aria-label="SCREENS${n ? `, ${n} running` : ''}"
3689
+ title="${n ? `${n} screen session${n > 1 ? 's' : ''} running in this project` : 'Screen sessions running in this project'}"><span uk-icon="icon: laptop; ratio: .7"></span> SCREENS${n ? ` <span class="uk-badge">${n}</span>` : ''}<span class="screens-hand" id="${esc(handId)}"></span> <span uk-icon="icon: chevron-down; ratio: .6"></span></button>`,
3690
+ });
3470
3691
  })();
3471
3692
  // A project with no DDEV config has no menu to hold it, so Backup stays a button there.
3472
3693
  const backupButton = canBackup && !isDdev ? `<button class="uk-button uk-button-default uk-button-small" hx-post="/actions/backup" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Backup</button>` : '';
@@ -5559,6 +5780,36 @@ const server = http.createServer(async (req, res) => {
5559
5780
  }
5560
5781
  if (pathname === '/actions/status') return handleAction(pathname, {}, res);
5561
5782
  if (pathname.startsWith('/fragments/p/') && pluginFragment(pathname, req, res)) return;
5783
+ // The body of a plugin's row menu that loads when it opens (plugin-loader's rowMenuHtml): the
5784
+ // plugin's own fragment, arranged like an inline menu's items — the Buttons settings hide, order
5785
+ // and add to it the same way, and the settings page sees its entries once one has been opened.
5786
+ // The core's own menus (DDEV, COMPOSER, Tests, SCREENS) take the same route with no `body`: their
5787
+ // bodies come from coreRowMenuBody, computed for this one project when the menu opens.
5788
+ if (pathname.startsWith('/fragments/row-menu/')) {
5789
+ const m = pathname.match(/^\/fragments\/row-menu\/([a-z0-9-]+)\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
5790
+ const body = new URL(req.url, 'http://localhost').searchParams.get('body') || '';
5791
+ const core = !!m && CORE_ROW_MENUS.has(m[1]) && !body;
5792
+ if (!m || !isValidWorkspace(m[2]) || !NAME_RE.test(m[3])
5793
+ || !(core || (pluginRowMenuSlot(m[1]) && /^\/fragments\/p\/[a-z0-9-]+\//.test(body)))) {
5794
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
5795
+ return res.end('<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">No such menu.</li></ul>');
5796
+ }
5797
+ const [slot, key, project] = [m[1], m[2], m[3]];
5798
+ const answer = core ? await coreRowMenuBody(slot, key, project) : await pluginRowMenuBody(body, req);
5799
+ let html = answer.html;
5800
+ if (answer.status === 200) {
5801
+ const projectPath = path.join(workspaceDir(key), project);
5802
+ const layout = rowLayout.loadRowLayout();
5803
+ const ctx = { layout, key, project, isDdev: ddevProjectName(projectPath) !== null,
5804
+ hasComposer: require('./actions/composer').hasComposerJson(projectPath),
5805
+ hasNode: require('./actions/node').hasPackageJson(projectPath), hasGit: hasOwnGit(projectPath) };
5806
+ // Hidden entries, order and your own actions, then the columns the menu was set to — the two
5807
+ // passes a menu drawn inline went through (rowLayout.rowLinesHtml).
5808
+ try { html = rowLayout.columnizeButtonHtml(slot, rowLayout.arrangeButtonHtml(slot, html, ctx), layout); } catch (_) { /* shown as it was drawn */ }
5809
+ }
5810
+ res.writeHead(answer.status, { 'Content-Type': 'text/html; charset=utf-8' });
5811
+ return res.end(html);
5812
+ }
5562
5813
  // The knowledge graph a project's graphify run produced. Those files live
5563
5814
  // inside the project, not under a docroot, so the dashboard serves them.
5564
5815
  // graph.html is not self-contained: it pulls vis-network from unpkg.com.
@@ -6691,79 +6942,13 @@ const server = http.createServer(async (req, res) => {
6691
6942
  } }
6692
6943
  { const sm = pathname.match(/^\/fragments\/projects\/screens\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
6693
6944
  if (sm && isValidWorkspace(sm[1])) {
6694
- const sDir = path.join(workspaceDir(sm[1]), sm[2]);
6695
- const sessions = fs.existsSync(sDir) ? await screensForDir(sDir) : [];
6696
- const { sessionWaitingWhy } = require('./screens');
6697
- const waiting = sessions.map((s) => ({ s, why: sessionWaitingWhy(s) })).filter((x) => x.why);
6698
- const { agentSessions } = require('./screens');
6699
- const agentsHere = agentSessions(sessions);
6700
- const items = sessions.map((s) => {
6701
- const why = sessionWaitingWhy(s);
6702
- const isAgent = agentsHere.has(s.session);
6703
- // A session is named after where it runs — `test--ruverify1--jira-webship-9999` — so the
6704
- // project's own name is on every line of a menu that is already inside that project. The
6705
- // line leads with the part that differs; the whole name is the title, and the row is the
6706
- // one place it could ever be ambiguous.
6707
- const short = s.session.startsWith(`${sm[1]}--${sm[2]}--`)
6708
- ? s.session.slice(`${sm[1]}--${sm[2]}--`.length) : s.session;
6709
- // Dead is not runnable: `screen -ls` lists a session whose process is gone, and opening one
6710
- // attaches to nothing. Saying so beats a terminal that comes up empty.
6711
- const dead = /dead/i.test(s.state || '');
6712
- return `
6713
- <li data-entry="open a screen"><a href data-open-terminal="${esc(s.session)}" title="${esc(s.session)}${
6714
- s.started ? ` · started ${esc(s.started)}` : ''}">
6715
- ${isAgent ? iconHtml('tabler:robot-face', 0.7) : '<span uk-icon="icon: laptop; ratio: .7"></span>'} ${esc(short)}
6716
- <span class="screen-item-state">${
6717
- why ? `<span class="uk-label uk-label-warning" title="${esc(why)}">✋ waiting</span>`
6718
- : dead ? '<span class="uk-label">dead</span>'
6719
- : '<span class="uk-label uk-label-success">running</span>'}${
6720
- isAgent ? ' <span class="uk-label">agent</span>' : ''}</span></a></li>`;
6721
- }).join('');
6722
- // Out of band, into the row's own SCREENS button: this poll already runs every 15 seconds for
6723
- // the menu, so the hand on the row costs no request of its own and cannot be staler than the
6724
- // menu behind it. Always sent, empty when nothing is waiting — an OOB swap that is only sent
6725
- // sometimes leaves the last hand up after the question has been answered.
6726
- const hand = `<span class="screens-hand${waiting.length ? ' is-waiting' : ''}" id="screens-hand-${esc(sm[1])}-${esc(sm[2])}" hx-swap-oob="true"${
6727
- waiting.length ? ` title="${esc(waiting.map((x) => `${x.s.session}: ${x.why}`).join(' · '))}"` : ''}>${
6728
- waiting.length ? `✋${waiting.length > 1 ? ` ${waiting.length}` : ''}` : ''}</span>`;
6729
- // And whether an AGENT is at work in this project — asked of the process table rather than of
6730
- // the session's name, so a session started from the CLI or by hand counts the same. Sent
6731
- // always, empty when there is none: an OOB swap that is only sent sometimes leaves the last
6732
- // answer standing after the agent has finished.
6733
- const agents = agentsHere;
6734
- const agentWaiting = waiting.some((x) => agents.has(x.s.session));
6735
- const agentBadge = `<span class="project-title-agent" id="agent-badge-${esc(sm[1])}-${esc(sm[2])}" hx-swap-oob="true">${
6736
- agents.size
6737
- ? `<span class="uk-label ${agentWaiting ? 'uk-label-warning' : 'uk-label-success'} agent-live" title="${esc([...agents].join(' · '))}${
6738
- agentWaiting ? ' — and it has stopped to ask you something' : ''}">${
6739
- // Tabler's robot FACE rather than the 🤖 emoji, and rather than Tabler's whole-robot
6740
- // icon. An emoji is drawn by the platform's own font, so the same badge is a grey
6741
- // outline on one machine and a yellow cartoon on another, at a size and weight nothing
6742
- // here controls. And `robot` draws a head, antenna, arms and legs — at the 14px a label
6743
- // gives it, that is a smudge. `robot-face` spends the same pixels on four features
6744
- // instead of a dozen, which is the one that can still be read. Compared side by side at
6745
- // badge size rather than picked by name.
6746
- iconHtml('tabler:robot-face', 0.8)} ${agentWaiting ? 'waiting for you' : 'agent working'}${
6747
- agents.size > 1 ? ` (${agents.size})` : ''}</span>`
6748
- : ''}</span>`;
6749
- // The plugins' badges in the card's bottom-right cluster beside the pen (which work item an agent
6750
- // here is working, say). Sent on every poll, empty when there is nothing, so each clears itself.
6751
- const keysBadge = `<span class="project-plugin-badges" id="plugin-badges-${esc(sm[1])}-${esc(sm[2])}" hx-swap-oob="true">${
6752
- pluginProjectBadgesHtml({ key: sm[1], project: sm[2], dir: sDir, sessions })}</span>`;
6945
+ // The list's inner HTML, for a page drawn before the menus loaded their bodies through
6946
+ // /fragments/row-menu/screens/…: arranged like the row's other menus (Commands → Buttons).
6947
+ const { nav, oob, dir: sDir } = await screensMenuBody(sm[1], sm[2]);
6753
6948
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
6754
- const claudeItems = require('./actions/claude-session').menuItems({ ws: sm[1], project: sm[2], sessions, agents: agentsHere, dir: sDir });
6755
- const screensNav = `${items || '<li class="uk-nav-header">No screens in this project</li>'}${claudeItems ? `
6756
- <li class="uk-nav-divider"></li>${claudeItems}` : ''}
6757
- <li class="uk-nav-divider"></li>
6758
- <li><a href hx-get="/fragments/screen-new-form?workspace=${encodeURIComponent(sm[1])}&amp;project=${encodeURIComponent(sm[2])}" hx-target="#editor-modal-body" hx-swap="innerHTML"
6759
- hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: plus-circle; ratio: .7"></span> New screen here…</a></li>
6760
- <li><a href hx-get="/fragments/screen-new-form?workspace=${encodeURIComponent(sm[1])}&amp;project=${encodeURIComponent(sm[2])}&amp;kind=claude" hx-target="#editor-modal-body" hx-swap="innerHTML"
6761
- hx-on::after:request="UIkit.modal('#editor-modal').show()">${iconHtml('tabler:sparkles', 0.7)} Claude Code here…</a></li>
6762
- <li><a href="/screens"><span uk-icon="icon: list; ratio: .7"></span> All screens</a></li>`;
6763
- // Arranged like the row's other menus (Commands → Buttons); the row's copy is only a loading line.
6764
- return res.end(rowLayout.arrangeNavInner('screens', screensNav, { key: sm[1], project: sm[2],
6949
+ return res.end(rowLayout.arrangeNavInner('screens', nav, { key: sm[1], project: sm[2],
6765
6950
  isDdev: require('./actions/composer').isDdevProject(sDir), hasComposer: require('./actions/composer').hasComposerJson(sDir),
6766
- hasNode: require('./actions/node').hasPackageJson(sDir), hasGit: hasOwnGit(sDir) }) + hand + agentBadge + keysBadge);
6951
+ hasNode: require('./actions/node').hasPackageJson(sDir), hasGit: hasOwnGit(sDir) }) + oob);
6767
6952
  } }
6768
6953
  { const cm = pathname.match(/^\/fragments\/projects\/ddev-config\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
6769
6954
  if (cm && isValidWorkspace(cm[1])) {