@webship.co/workspace 1.0.0-rc1 → 1.0.1

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.
@@ -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
  /**
@@ -999,13 +1018,36 @@ function composerMenuHtml(key, p, projectPath, isDdev, mode = 'manifest') {
999
1018
  * left, a package and the package.json scripts on the right. What runs lives in actions/node.js.
1000
1019
  */
1001
1020
  function nodeMenuHtml(key, p, projectPath, isDdev) {
1002
- const { MANAGERS, NODE_GROUPS, NODE_RUN_ACTIONS, NODE_PACKAGE_ACTIONS, NODE_SCRIPT_ROUTE,
1003
- hasPackageJson, nodeInfo, nodeArgsFor, nodeScripts } = require('./actions/node');
1004
- if (!hasPackageJson(projectPath)) return '';
1021
+ const state = nodeMenuState(projectPath);
1022
+ if (!state) return '';
1023
+ // The row carries the button and the caption; the body (nodeMenuBody) arrives from
1024
+ // /fragments/row-menu/node/<ws>/<project> when the menu opens — fifty rows, fifty buttons, no menu (#111).
1025
+ return menuShellHtml('node', {
1026
+ key, project: p, caption: state.caption, dropdownClasses: 'node-dropdown',
1027
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="npm and yarn for this folder"><span uk-icon="icon: nut; ratio: .7"></span> NODE <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
1028
+ });
1029
+ }
1030
+
1031
+ /** What the NODE caption says: package.json parsed (or not), and which lock files sit beside it. Null without a package.json. */
1032
+ function nodeMenuState(projectPath) {
1033
+ const { hasPackageJson, nodeInfo } = require('./actions/node');
1034
+ if (!hasPackageJson(projectPath)) return null;
1005
1035
  const info = nodeInfo(projectPath);
1006
- const lockFile = { yarn: 'yarn.lock', npm: 'package-lock.json' };
1007
1036
  const caption = !info.valid ? 'bad json'
1008
1037
  : (info.locks.yarn && info.locks.npm ? '2 locks' : (info.locks.yarn ? 'yarn.lock' : (info.locks.npm ? 'npm lock' : 'no lock')));
1038
+ return { info, caption };
1039
+ }
1040
+
1041
+ /**
1042
+ * The NODE menu's body — what used to sit inside the dropdown on every row — for one project, served by
1043
+ * /fragments/row-menu/node/<ws>/<project> when the menu opens. '' for a folder without package.json.
1044
+ */
1045
+ function nodeMenuBody(key, p, projectPath, isDdev) {
1046
+ const { MANAGERS, NODE_GROUPS, NODE_RUN_ACTIONS, NODE_PACKAGE_ACTIONS, NODE_SCRIPT_ROUTE, nodeArgsFor, nodeScripts } = require('./actions/node');
1047
+ const state = nodeMenuState(projectPath);
1048
+ if (!state) return '';
1049
+ const { info } = state;
1050
+ const lockFile = { yarn: 'yarn.lock', npm: 'package-lock.json' };
1009
1051
  const vals = (m, extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}","manager":"${m}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
1010
1052
  const scripts = info.valid ? nodeScripts(info.pkg) : [];
1011
1053
  const panel = (m) => {
@@ -1044,17 +1086,12 @@ function nodeMenuHtml(key, p, projectPath, isDdev) {
1044
1086
  </div></li>`;
1045
1087
  };
1046
1088
  const tabs = MANAGERS.map((m) => `<li><a href="#">${m} <span class="uk-text-meta">· ${info.locks[m] ? 'lock ✓' : 'no lock'}</span></a></li>`).join('');
1047
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-node">
1048
- <button class="uk-button uk-button-default uk-button-small" type="button" title="npm and yarn for this folder"><span uk-icon="icon: nut; ratio: .7"></span> NODE <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
1049
- <div uk-dropdown="mode: click; pos: bottom-right" class="node-dropdown">
1050
- <ul class="uk-nav uk-dropdown-nav">
1089
+ return `<ul class="uk-nav uk-dropdown-nav">
1051
1090
  <li class="uk-nav-header">Node · ${esc((info.pkg && typeof info.pkg.name === 'string' && info.pkg.name) || p)}</li>
1052
1091
  <li data-entry="how it runs"><span class="uk-text-meta">${isDdev ? 'Runs as ddev npm / ddev yarn in this project.' : "Not a DDEV project — runs in the dashboard's DDEV container."}${info.pinned ? ` packageManager: ${esc(info.pinned.spec)}.` : ''}${info.nodeModules ? ' node_modules is here.' : ' No node_modules yet.'}${info.valid ? '' : ' package.json is not valid JSON.'}</span></li>
1053
1092
  </ul>
1054
1093
  <ul class="uk-tab node-tabs" data-keep-open uk-tab="active: ${Math.max(0, MANAGERS.indexOf(info.preferred))}">${tabs}</ul>
1055
- <ul class="uk-switcher">${MANAGERS.map(panel).join('')}</ul>
1056
- </div>
1057
- </div><span class="act-status uk-text-meta">${esc(caption)}</span></span>`;
1094
+ <ul class="uk-switcher">${MANAGERS.map(panel).join('')}</ul>`;
1058
1095
  }
1059
1096
 
1060
1097
 
@@ -2708,7 +2745,7 @@ async function settingsPage(section, state, options = {}) {
2708
2745
  : `${options.fragment ? '' : back}${isPlugins ? require('./plugins-page').pluginsPageHtml() : isStyles ? stylesPageHtml()
2709
2746
  : isNavigation ? navPage.navEditorHtml(options.scope || 'global')
2710
2747
  : isBuildOrder ? buildOrderPage.editorHtml(options.scope || 'global')
2711
- : isButtons ? rowLayout.rowActionsEditorHtml()
2748
+ : isButtons ? (await learnCoreMenus(), rowLayout.rowActionsEditorHtml())
2712
2749
  : options.raw ? settingsRawHtml(current)
2713
2750
  : await settingsFormHtml(current)}`;
2714
2751
 
@@ -3149,26 +3186,429 @@ function ddevStateOf(dir, project, statuses) {
3149
3186
  }
3150
3187
 
3151
3188
 
3189
+ /**
3190
+ * The DDEV menu's items for one project — every <li> inside the dropdown, before the Buttons settings
3191
+ * arrange them. The row builds them to count what the caption says ("12 commands") and the fragment
3192
+ * /fragments/row-menu/ddev/<ws>/<project> builds them again, for one project, when the menu opens;
3193
+ * the page itself carries only the button and the caption. `gates` is rowGates(): which verbs the
3194
+ * workspace's type allows and the folder ships; `ddev` is ddevStateOf(): the project's name and state.
3195
+ */
3196
+ function ddevMenuItemsHtml({ key, p, meta, projectPath, gates, ddev }) {
3197
+ const { canDrupal, canBackup, canClone, canRemove } = gates;
3198
+ const { ddevName, running, mailpit } = ddev;
3199
+ const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
3200
+ return `
3201
+ <li class="uk-nav-header" data-group="project">${esc(ddevName)}</li>
3202
+ ${running
3203
+ ? `<li data-entry="start-stop"><a href hx-post="/actions/ddev-stop" ${vals()}><span uk-icon="icon: ban; ratio: .7"></span> Stop</a></li>`
3204
+ : `<li data-entry="start-stop"><a href hx-post="/actions/ddev-start" ${vals()}><span uk-icon="icon: play; ratio: .7"></span> Start</a></li>`}
3205
+ <li><a href hx-post="/actions/ddev-restart" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Restart</a></li>
3206
+ <li><a href hx-post="/actions/ddev-describe" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Describe (URLs, services)</a></li>
3207
+ <li><a href hx-post="/actions/ddev-logs" ${vals()}><span uk-icon="icon: file-text; ratio: .7"></span> Web logs (last 100)</a></li>
3208
+ <li><a href="https://${esc(ddevName)}.ddev.site" target="_blank"><span uk-icon="icon: link-external; ratio: .7"></span> Canonical URL</a></li>
3209
+ ${require('./ddev-custom-commands').menuGroupsHtml({ key, project: p, projectPath, running })}
3210
+
3211
+ <li class="uk-nav-divider"></li>
3212
+ <li class="uk-nav-header">build</li>
3213
+ ${canDrupal ? `<li><a href hx-post="/actions/ddev-composer-install" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Composer install</a></li>
3214
+ <li><a href hx-post="/actions/ddev-composer-update" ${vals()}><span uk-icon="icon: cloud-download; ratio: .7"></span> Composer update</a></li>` : ''}
3215
+ <li><a href hx-post="/actions/ddev-mutagen-sync" ${vals()}><span uk-icon="icon: refresh; ratio: .7"></span> Mutagen sync</a></li>
3216
+ <!-- Beyond sync: is it behind or is it broken, and the reset for when it cannot be
3217
+ repaired. Reset stops the project and removes the volume, so it asks first — the
3218
+ files on disk are the source, and the volume is rebuilt on the next start. -->
3219
+ ${ddevMail.menuItemHtml({ workspace: key, project: p, running, mailpit })}
3220
+ <li><a href hx-post="/actions/ddev-mutagen-status" ${vals()}
3221
+ 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>
3222
+ <li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-mutagen-reset" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3223
+ 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>
3224
+
3225
+ ${canDrupal && running ? `<li class="uk-nav-divider"></li>
3226
+ <li class="uk-nav-header">drupal</li>
3227
+ <li><a href hx-post="/actions/ddev-drush-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Drush status</a></li>
3228
+ <li><a href hx-post="/actions/ddev-drush-cr" ${vals()}><span uk-icon="icon: bolt; ratio: .7"></span> Rebuild the cache (cr)</a></li>
3229
+ <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>
3230
+
3231
+ <li class="uk-nav-divider"></li>
3232
+ <li class="uk-nav-header">configuration</li>
3233
+ <li><a href hx-post="/actions/ddev-drush-cim" ${vals()}><span uk-icon="icon: download; ratio: .7"></span> Import configuration (cim)</a></li>
3234
+ <li><a href hx-post="/actions/ddev-drush-cex" ${vals()}><span uk-icon="icon: upload; ratio: .7"></span> Export configuration (cex)</a></li>` : ''}
3235
+
3236
+ <li class="uk-nav-divider"></li>
3237
+ <!-- Step debugging. Two items rather than a toggle, because ddev describe does not report
3238
+ which state it is in (checked) and a row must not spend a subprocess to find out — so
3239
+ the menu offers both, and asks when you want to know. Xdebug makes PHP about three
3240
+ times slower while it is on, which is why "off" is worth being one click away.
3241
+ No backticks in this comment: the menu is one template literal. -->
3242
+ <li class="uk-nav-header">debugging</li>
3243
+ ${running ? `<li><a href hx-post="/actions/ddev-xdebug-on" ${vals()}><span uk-icon="icon: code; ratio: .7"></span> Xdebug on</a></li>
3244
+ <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>
3245
+ <li><a href hx-post="/actions/ddev-xdebug-status" ${vals()}><span uk-icon="icon: info; ratio: .7"></span> Which is it now?</a></li>` : ''}
3246
+ <!-- When a project is broken, the four questions worth asking before guessing. Each reads
3247
+ and reports; none of them changes anything. They are here rather than in a document
3248
+ because the moment you need them is the moment you do not want to go looking. -->
3249
+ <li><a href hx-post="/actions/ddev-diagnose" ${vals()}
3250
+ 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>
3251
+ <li><a href hx-post="/actions/ddev-tls-diagnose" ${vals()}
3252
+ title="Why the browser does not trust this project's certificate"><span uk-icon="icon: lock; ratio: .7"></span> Diagnose the certificate</a></li>
3253
+ <li><a href hx-post="/actions/ddev-port-diagnose" ${vals()}
3254
+ 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>
3255
+ <li><a href hx-post="/actions/ddev-db-match" ${vals()}
3256
+ 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>
3257
+ <!-- The one thing on this menu that needs a person at a keyboard: ssh-add asks for the
3258
+ passphrase, so it runs in a screen session and the item says to go and answer it.
3259
+ Without it a composer install against a private repository fails at the first
3260
+ package nobody can read. -->
3261
+ <li><a href hx-post="/actions/ddev-auth-ssh" ${vals()}
3262
+ 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>
3263
+ <!-- The only verb here whose effect reaches people who are not on this machine: a tunnel
3264
+ provider publishes this project on the open internet, for as long as the session
3265
+ runs. Hence the confirm dialog, and hence a screen session — the URL is printed in
3266
+ there, and closing a tab must not take somebody's demo down. -->
3267
+ ${running ? `<li><a href class="uk-text-danger arm-step" hx-post="/actions/ddev-share" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"
3268
+ 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>` : ''}
3269
+
3270
+ <li class="uk-nav-header">database</li>
3271
+ ${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>` : ''}
3272
+ ${running ? `<li><a href hx-post="/actions/ddev-export-db" ${vals()}><span uk-icon="icon: database; ratio: .7"></span> Export the database</a></li>
3273
+ <li><a href hx-get="/fragments/projects/import-db/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3274
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3275
+ title="Put a database back from a dump this workspace holds"><span uk-icon="icon: push; ratio: .7"></span> Import a database…</a></li>
3276
+ <li><a href hx-post="/actions/ddev-snapshot" ${vals()}><span uk-icon="icon: camera; ratio: .7"></span> Take a snapshot</a></li>` : ''}
3277
+ <li><a href hx-get="/fragments/projects/addons/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3278
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3279
+ 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>
3280
+ <li><a href hx-get="/fragments/projects/snapshots/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3281
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3282
+ 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>
3283
+ ${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>
3284
+ <li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>` : ''}
3285
+ ${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"
3286
+ title="Drops every table in this project's database."><span uk-icon="icon: trash; ratio: .7"></span> Drop the database (sql:drop)</a></li>` : ''}
3287
+
3288
+ <li class="uk-nav-divider"></li>
3289
+ <li class="uk-nav-header">this project</li>
3290
+ <li><a href hx-get="/fragments/projects/ddev-config/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3291
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: cog; ratio: .7"></span> Configure (ddev config)…</a></li>
3292
+ <li><a href hx-get="/fragments/projects/hostnames/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3293
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: world; ratio: .7"></span> Domains this answers at…</a></li>
3294
+ ${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>` : ''}
3295
+ ${canRemove ? `<li class="uk-nav-divider"></li>
3296
+ <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"
3297
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3298
+ title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>` : ''}`;
3299
+ }
3300
+
3301
+ /**
3302
+ * Two gates per control on a project row: the folder must SHIP the script (capability), and the
3303
+ * workspace's type must WANT the action (policy — `type:` in its settings file). Read once per page by
3304
+ * the rows, and once per request by a menu body served on its own.
3305
+ */
3306
+ /**
3307
+ * The More menu's items for one project — the changelog, the recordings, the backups and the remove —
3308
+ * before the Buttons settings arrange them. The row builds them to count what the caption says
3309
+ * ("3 actions") and /fragments/row-menu/more/<ws>/<project> builds them again when the menu opens (#111).
3310
+ * Both verbs of the teardown also live in the DDEV menu, where a DDEV project's teardown belongs. They
3311
+ * stay here too: More is where someone looks for them, and a folder DDEV does not manage has no other home.
3312
+ */
3313
+ function moreMenuItemsHtml({ key, p, gates }) {
3314
+ const { canBackup, canRemove } = gates;
3315
+ const items = [];
3316
+ // What this product shipped, read from the CHANGELOG it already keeps. It belongs to the
3317
+ // row rather than to the rail: a changelog is a fact about one product, and the question
3318
+ // people ask is "what went into 1.0.3 of THIS", standing in front of it in its folder.
3319
+ // The latest version is in the label, so the menu answers the common case unopened.
3320
+ const cl = changelog.changelogFor(key, p);
3321
+ if (cl) {
3322
+ const latest = (changelog.changelogIndex().find((r) => r.workspace === key && r.item === p) || {}).latest || '';
3323
+ items.push(`<li><a href hx-get="/fragments/changelog/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3324
+ hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"
3325
+ title="Releases, newest first, from ${esc(cl.file)}"><span uk-icon="icon: history; ratio: .7"></span> Changelog${latest ? ` <span class="uk-text-meta">${esc(latest)}</span>` : ''}</a></li>`);
3326
+ }
3327
+ // Recordings about this project: a video, or audio, started from a set of pre-prompts.
3328
+ if (projectMake.WORKSPACES.includes(key)) {
3329
+ const makeLink = (kind, icon, label, hint) => `<li data-entry="${esc(label.toLowerCase())}"><a href hx-get="/fragments/projects/make-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}?kind=${kind}"
3330
+ hx-target="#editor-modal-body" hx-swap="innerHTML" hx-on::after:request="UIkit.modal('#editor-modal').show()"
3331
+ title="${esc(hint)}"><span uk-icon="icon: ${icon}; ratio: .7"></span> ${esc(label)}…</a></li>`;
3332
+ items.push(makeLink('video', 'play-circle', 'Create a video', 'A narrated video about this project, from a set of pre-prompts'));
3333
+ items.push(makeLink('audio', 'microphone', 'Create audio', 'A spoken recording about this project, from a set of pre-prompts'));
3334
+ items.push('<li class="uk-nav-divider"></li>');
3335
+ }
3336
+ if (canBackup) items.push(`<li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>`);
3337
+ if (canRemove) {
3338
+ items.push('<li class="uk-nav-divider"></li>');
3339
+ items.push(`<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"
3340
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3341
+ title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>`);
3342
+ }
3343
+ return items.join('');
3344
+ }
3345
+
3346
+ function rowGates(dir, meta) {
3347
+ const allowed = meta.typeActions;
3348
+ return {
3349
+ allowed,
3350
+ canBackup: allowed.has('backup') && !!findBackupScript(dir),
3351
+ canClone: allowed.has('clone') && fs.existsSync(path.join(dir, 'cmd-tools-clone.sh')),
3352
+ canRemove: allowed.has('remove') && !!findRemoveScript(dir),
3353
+ canRagify: allowed.has('ragify') && !!findRagifyScript(dir),
3354
+ canTest: allowed.has('tests') && fs.existsSync(path.join(dir, 'cmd-tools-testing.sh')),
3355
+ canChecks: allowed.has('checks') && fs.existsSync(path.join(dir, 'cmd-tools-checks.sh')),
3356
+ canUsers: allowed.has('users') && fs.existsSync(path.join(dir, 'cmd-tools-add-users.sh')),
3357
+ canDdev: allowed.has('ddev'),
3358
+ // A DDEV project is not necessarily a Drupal one. Milvus and Penpot run in DDEV with
3359
+ // no composer.json and no Drupal inside, so Composer install/update and every drush
3360
+ // item would offer to operate on something that is not there.
3361
+ canDrupal: allowed.has('drupal'),
3362
+ };
3363
+ }
3364
+
3365
+ /**
3366
+ * What the Tests button says before it opens, for one project: the caption under it and its title.
3367
+ * One quality menu, not two. Setting the environment up, running the suite, reading a report and
3368
+ * running the checks a package declares are all "is this project sound?" — and on a packages
3369
+ * workspace (products, modules, themes) there is no suite at all, so a separate Checks button would
3370
+ * be the only one there and a separate Tests button the only one on a site. The verbs live together
3371
+ * and the menu says which of them apply.
3372
+ */
3373
+ function testsMenuState({ key, dir, p, gates }) {
3374
+ const { canTest, canChecks } = gates;
3375
+ // Running means a live screen session with the name this row's own
3376
+ // buttons give it — the suite runs there, not in the dashboard.
3377
+ const testsSession = screenSessionName(p, 'tests', key);
3378
+ const busy = sessionLive(key, p, 'tests') || sessionLive(key, p, 'tests-setup');
3379
+ const stack = canTest ? testingStackOf(dir, p) : 'none';
3380
+ const busyChecks = sessionLive(key, p, 'checks');
3381
+ const declared = canChecks ? checksDeclaredOf(dir, p) : { declared: 0, ready: 0, blocked: 0, hasPackageJson: false };
3382
+ // The caption answers whichever question this workspace can ask: a site says
3383
+ // whether it could start a suite right now, a package says what it declares.
3384
+ // "ready" is the useful word — a project can carry a whole stack and still fail
3385
+ // every scenario because the web image has no chromium libraries.
3386
+ const ready = canTest ? testingReadiness(dir, p) : null;
3387
+ const caption = canTest
3388
+ ? (busy ? 'running now' : stack === 'none' ? 'not set up' : (ready.ready ? 'ready to test' : 'not ready'))
3389
+ : checksCaption(key, dir, p);
3390
+ const title = canTest ? (stack === 'none' ? 'No testing stack in this project yet' : `Automated testing (${stack})`) : `${declared.declared} check(s) declared in this package`;
3391
+ return { testsSession, busy, stack, busyChecks, declared, ready, caption, title };
3392
+ }
3393
+
3394
+ /**
3395
+ * The Tests menu's body for one project — the list inside the dropdown, before the Buttons settings
3396
+ * arrange it — served by /fragments/row-menu/tests/<ws>/<project> when the menu opens. '' where the
3397
+ * workspace offers none of its verbs.
3398
+ */
3399
+ function testsMenuBody({ key, dir, p, gates }) {
3400
+ const { canTest, canChecks, canUsers } = gates;
3401
+ if (!(canTest || canChecks || canUsers)) return '';
3402
+ const vals = (extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
3403
+ const { testsSession, busy, stack, busyChecks, declared, ready } = testsMenuState({ key, dir, p, gates });
3404
+ const reports = canTest ? findTestReports(dir, p) : [];
3405
+ const rows = canChecks ? checksRowsOf(dir, p) : [];
3406
+ // Every alias, with the script the package actually declared for it. Seeing the pair is
3407
+ // how you know the mapping is right — and each one runs on its own, because a spelling
3408
+ // failure is not a reason to re-run the linters.
3409
+ const symbol = { ready: '✅', masked: '⛔', refused: '🚫', skipped: '⏭', 'deps-missing': '⏭', 'unresolved-runner': '⏭' };
3410
+ const aliasItems = rows.map((r) => (r.status === 'ready'
3411
+ ? `<li><a href hx-post="/actions/checks" ${vals(`,"only":"${esc(r.id)}"`)} class="${busyChecks ? 'uk-disabled' : ''}" title="${esc(`${r.label} — runs ${r.script}`)}">
3412
+ <span class="checks-alias">${esc(r.id)}</span> <code class="checks-script">${esc(r.script)}</code></a></li>`
3413
+ : `<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('');
3414
+ return `<ul class="uk-nav uk-dropdown-nav" uk-nav="multiple: true">
3415
+ ${canUsers ? `<li class="uk-nav-header">test users</li>
3416
+ <li><a href hx-post="/actions/add-users" ${vals()}><span uk-icon="icon: users; ratio: .7"></span> Add the test users</a></li>
3417
+ <li><a href hx-post="/actions/cancel-users" ${vals()}><span uk-icon="icon: minus-circle; ratio: .7"></span> Cancel the test users</a></li>
3418
+ ${canTest || canChecks ? '<li class="uk-nav-divider"></li>' : ''}` : ''}
3419
+ ${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>
3420
+ ${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>` : ''}
3421
+ <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>
3422
+ ${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>` : ''}
3423
+ ${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>`}
3424
+ ${reports.length ? `<li class="uk-nav-divider"></li>
3425
+ <li class="uk-nav-header" data-group="reports">${reports.length} report${reports.length > 1 ? 's' : ''}</li>
3426
+ ${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('')}` : ''}` : ''}
3427
+
3428
+ ${canChecks ? `${canTest ? '<li class="uk-nav-divider"></li>' : ''}
3429
+ <!-- Nested rather than flat: the aliases are a list of their own, and on a Drupal
3430
+ tree with several reports a flat menu would run to twenty rows. -->
3431
+ <li class="uk-parent act-menu-checks" data-entry="checks">
3432
+ <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>
3433
+ <ul class="uk-nav-sub checks-sub">
3434
+ <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>
3435
+ ${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>` : ''}
3436
+ ${aliasItems ? `<li class="uk-nav-divider"></li>${aliasItems}` : '<li><span class="uk-text-meta">This project declares none.</span></li>'}
3437
+ </ul>
3438
+ </li>` : ''}
3439
+ </ul>`;
3440
+ }
3441
+
3442
+ /**
3443
+ * The SCREENS menu's body for one project, and what rides on it. `nav` is the list inside the dropdown
3444
+ * (the sessions, the Claude items, new screen, all screens), before the Buttons settings arrange it;
3445
+ * `oob` is swapped out of band into the row — the hand a waiting session raises on the button, the
3446
+ * agent badge in the title and the plugins' badges. Served by /fragments/row-menu/screens/<ws>/<project>
3447
+ * on open and on the row's own poll (the legacy /fragments/projects/screens/… answers the same).
3448
+ */
3449
+ async function screensMenuBody(ws, project) {
3450
+ const sDir = path.join(workspaceDir(ws), project);
3451
+ const sessions = fs.existsSync(sDir) ? await screensForDir(sDir) : [];
3452
+ const { sessionWaitingWhy } = require('./screens');
3453
+ const waiting = sessions.map((s) => ({ s, why: sessionWaitingWhy(s) })).filter((x) => x.why);
3454
+ const { agentSessions } = require('./screens');
3455
+ const agentsHere = agentSessions(sessions);
3456
+ const items = sessions.map((s) => {
3457
+ const why = sessionWaitingWhy(s);
3458
+ const isAgent = agentsHere.has(s.session);
3459
+ // A session is named after where it runs — `test--ruverify1--jira-webship-9999` — so the
3460
+ // project's own name is on every line of a menu that is already inside that project. The
3461
+ // line leads with the part that differs; the whole name is the title, and the row is the
3462
+ // one place it could ever be ambiguous.
3463
+ const short = s.session.startsWith(`${ws}--${project}--`)
3464
+ ? s.session.slice(`${ws}--${project}--`.length) : s.session;
3465
+ // Dead is not runnable: `screen -ls` lists a session whose process is gone, and opening one
3466
+ // attaches to nothing. Saying so beats a terminal that comes up empty.
3467
+ const dead = /dead/i.test(s.state || '');
3468
+ return `
3469
+ <li data-entry="open a screen"><a href data-open-terminal="${esc(s.session)}" title="${esc(s.session)}${
3470
+ s.started ? ` · started ${esc(s.started)}` : ''}">
3471
+ ${isAgent ? iconHtml('tabler:robot-face', 0.7) : '<span uk-icon="icon: laptop; ratio: .7"></span>'} ${esc(short)}
3472
+ <span class="screen-item-state">${
3473
+ why ? `<span class="uk-label uk-label-warning" title="${esc(why)}">✋ waiting</span>`
3474
+ : dead ? '<span class="uk-label">dead</span>'
3475
+ : '<span class="uk-label uk-label-success">running</span>'}${
3476
+ isAgent ? ' <span class="uk-label">agent</span>' : ''}</span></a></li>`;
3477
+ }).join('');
3478
+ // Out of band, into the row's own SCREENS button: this poll already runs every 15 seconds for
3479
+ // the menu, so the hand on the row costs no request of its own and cannot be staler than the
3480
+ // menu behind it. Always sent, empty when nothing is waiting — an OOB swap that is only sent
3481
+ // sometimes leaves the last hand up after the question has been answered.
3482
+ const hand = `<span class="screens-hand${waiting.length ? ' is-waiting' : ''}" id="screens-hand-${esc(ws)}-${esc(project)}" hx-swap-oob="true"${
3483
+ waiting.length ? ` title="${esc(waiting.map((x) => `${x.s.session}: ${x.why}`).join(' · '))}"` : ''}>${
3484
+ waiting.length ? `✋${waiting.length > 1 ? ` ${waiting.length}` : ''}` : ''}</span>`;
3485
+ // And whether an AGENT is at work in this project — asked of the process table rather than of
3486
+ // the session's name, so a session started from the CLI or by hand counts the same. Sent
3487
+ // always, empty when there is none: an OOB swap that is only sent sometimes leaves the last
3488
+ // answer standing after the agent has finished.
3489
+ const agents = agentsHere;
3490
+ const agentWaiting = waiting.some((x) => agents.has(x.s.session));
3491
+ const agentBadge = `<span class="project-title-agent" id="agent-badge-${esc(ws)}-${esc(project)}" hx-swap-oob="true">${
3492
+ agents.size
3493
+ ? `<span class="uk-label ${agentWaiting ? 'uk-label-warning' : 'uk-label-success'} agent-live" title="${esc([...agents].join(' · '))}${
3494
+ agentWaiting ? ' — and it has stopped to ask you something' : ''}">${
3495
+ // Tabler's robot FACE rather than the 🤖 emoji, and rather than Tabler's whole-robot
3496
+ // icon. An emoji is drawn by the platform's own font, so the same badge is a grey
3497
+ // outline on one machine and a yellow cartoon on another, at a size and weight nothing
3498
+ // here controls. And `robot` draws a head, antenna, arms and legs — at the 14px a label
3499
+ // gives it, that is a smudge. `robot-face` spends the same pixels on four features
3500
+ // instead of a dozen, which is the one that can still be read. Compared side by side at
3501
+ // badge size rather than picked by name.
3502
+ iconHtml('tabler:robot-face', 0.8)} ${agentWaiting ? 'waiting for you' : 'agent working'}${
3503
+ agents.size > 1 ? ` (${agents.size})` : ''}</span>`
3504
+ : ''}</span>`;
3505
+ // The plugins' badges in the card's bottom-right cluster beside the pen (which work item an agent
3506
+ // here is working, say). Sent on every poll, empty when there is nothing, so each clears itself.
3507
+ const keysBadge = `<span class="project-plugin-badges" id="plugin-badges-${esc(ws)}-${esc(project)}" hx-swap-oob="true">${
3508
+ pluginProjectBadgesHtml({ key: ws, project: project, dir: sDir, sessions })}</span>`;
3509
+ const claudeItems = require('./actions/claude-session').menuItems({ ws: ws, project: project, sessions, agents: agentsHere, dir: sDir });
3510
+ const screensNav = `${items || '<li class="uk-nav-header">No screens in this project</li>'}${claudeItems ? `
3511
+ <li class="uk-nav-divider"></li>${claudeItems}` : ''}
3512
+ <li class="uk-nav-divider"></li>
3513
+ <li><a href hx-get="/fragments/screen-new-form?workspace=${encodeURIComponent(ws)}&amp;project=${encodeURIComponent(project)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3514
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"><span uk-icon="icon: plus-circle; ratio: .7"></span> New screen here…</a></li>
3515
+ <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"
3516
+ hx-on::after:request="UIkit.modal('#editor-modal').show()">${iconHtml('tabler:sparkles', 0.7)} Claude Code here…</a></li>
3517
+ <li><a href="/screens"><span uk-icon="icon: list; ratio: .7"></span> All screens</a></li>`;
3518
+ return { nav: screensNav, oob: hand + agentBadge + keysBadge, dir: sDir };
3519
+ }
3520
+
3521
+ // The row menus the core draws itself whose bodies load when they open — the slots
3522
+ // /fragments/row-menu/<slot>/<ws>/<project> answers with no `body` parameter (coreRowMenuBody).
3523
+ // NODE and More joined in #111: a row carries their buttons and captions, not their dropdowns.
3524
+ const CORE_ROW_MENUS = new Set(['ddev', 'composer', 'tests', 'screens', 'node', 'more']);
3525
+
3526
+ /**
3527
+ * The body of one of the core's row menus for one project, as `{ status, html }`: the dropdown's inner
3528
+ * HTML — one `<ul class="uk-nav uk-dropdown-nav">` (SCREENS adds what it swaps out of band) — before the
3529
+ * Buttons settings arrange it. Everything the row knew when it drew the button is read again here, for
3530
+ * this project alone: the workspace's gates, the DDEV registry, the sessions. 404 for a folder that is
3531
+ * not there or a menu the row would not have drawn on it, so a stale page cannot ask for verbs the
3532
+ * project does not carry.
3533
+ */
3534
+ async function coreRowMenuBody(slot, key, project) {
3535
+ const none = (why) => ({ status: 404, html: `<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">${esc(why)}</li></ul>` });
3536
+ const meta = loadWorkspaces()[key];
3537
+ const dir = workspaceDir(key);
3538
+ const projectPath = path.join(dir, project);
3539
+ if (!meta || !fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) return none('No such project.');
3540
+ const gates = rowGates(dir, meta);
3541
+ const isDdev = ddevProjectName(projectPath) !== null;
3542
+ if (slot === 'ddev') {
3543
+ if (!gates.canDdev || !isDdev) return none('Not a DDEV project.');
3544
+ const ddev = ddevStateOf(dir, project, await ddevStatusMap());
3545
+ const items = ddevMenuItemsHtml({ key, p: project, meta, projectPath, gates, ddev });
3546
+ return { status: 200, html: `<ul class="uk-nav uk-dropdown-nav ddev-nav-columns">${items}\n </ul>` };
3547
+ }
3548
+ if (slot === 'composer') {
3549
+ const body = composerMenuBody(key, project, projectPath, isDdev, meta.composerMode);
3550
+ return body ? { status: 200, html: body } : none('No composer.json here.');
3551
+ }
3552
+ if (slot === 'tests') {
3553
+ const body = testsMenuBody({ key, dir, p: project, gates });
3554
+ return body ? { status: 200, html: body } : none('No tests to offer here.');
3555
+ }
3556
+ if (slot === 'screens') {
3557
+ const { nav, oob } = await screensMenuBody(key, project);
3558
+ return { status: 200, html: `<ul class="uk-nav uk-dropdown-nav">${nav}</ul>${oob}` };
3559
+ }
3560
+ if (slot === 'node') {
3561
+ const body = nodeMenuBody(key, project, projectPath, isDdev);
3562
+ return body ? { status: 200, html: body } : none('No package.json here.');
3563
+ }
3564
+ if (slot === 'more') {
3565
+ const items = moreMenuItemsHtml({ key, p: project, gates });
3566
+ return items ? { status: 200, html: `<ul class="uk-nav uk-dropdown-nav">${items}</ul>` } : none('Nothing more to offer here.');
3567
+ }
3568
+ return none('No such menu.');
3569
+ }
3570
+
3571
+ /**
3572
+ * The Buttons settings page lists what each menu has been seen to hold (row-layout's seen record). A
3573
+ * menu whose body loads when it opens (coreRowMenuBody) is recorded when one opens, so on a dashboard
3574
+ * where nobody has opened COMPOSER, Tests or NODE yet the page would list nothing for them: before it is drawn,
3575
+ * each such menu with nothing on record is built once, for the first project that carries it, and
3576
+ * arranged — which is what records it. DDEV is counted on every row, so it is on record already, and
3577
+ * SCREENS lists whatever sessions happen to run, which is nothing to learn from.
3578
+ */
3579
+ async function learnCoreMenus() {
3580
+ for (const slot of ['composer', 'tests', 'node']) {
3581
+ if (Object.keys(rowLayout.seenMenu(slot).groups).length) continue;
3582
+ let learned = false;
3583
+ for (const [key, meta] of Object.entries(loadWorkspaces())) {
3584
+ if (learned || meta.kind !== 'projects') continue;
3585
+ let projects = [];
3586
+ try { projects = listProjects(workspaceDir(key)).slice(0, 25); } catch (_) { continue; }
3587
+ for (const p of projects) {
3588
+ let answer;
3589
+ try { answer = await coreRowMenuBody(slot, key, p); } catch (_) { continue; }
3590
+ if (answer.status !== 200 || !/<a\b/.test(answer.html)) continue;
3591
+ const projectPath = path.join(workspaceDir(key), p);
3592
+ try {
3593
+ rowLayout.arrangeButtonHtml(slot, answer.html, { layout: rowLayout.loadRowLayout(), key, project: p, isDdev: ddevProjectName(projectPath) !== null,
3594
+ hasComposer: require('./actions/composer').hasComposerJson(projectPath),
3595
+ hasNode: require('./actions/node').hasPackageJson(projectPath), hasGit: hasOwnGit(projectPath) });
3596
+ } catch (_) { /* the page lists less, nothing breaks */ }
3597
+ learned = true;
3598
+ break;
3599
+ }
3600
+ }
3601
+ }
3602
+ }
3603
+
3152
3604
  async function projectRowsHtml(key, dir, state) {
3153
3605
  const meta = loadWorkspaces()[key];
3154
3606
  // Read once per page: every row on it is arranged the same way.
3155
3607
  const rowLayoutNow = rowLayout.loadRowLayout();
3156
3608
  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');
3609
+ // Two gates per control (rowGates): the folder ships the script, and the workspace's type wants the action.
3610
+ const gates = rowGates(dir, meta);
3611
+ const { allowed, canBackup, canClone, canRemove, canRagify, canTest, canChecks, canUsers, canDdev } = gates;
3172
3612
  const statuses = await ddevStatusMap();
3173
3613
  // One question per SERVER for the whole table, not one per row — a workspace can mix projects
3174
3614
  // bound to different servers, and a stopped one answers `up: false` rather than making every row
@@ -3212,7 +3652,8 @@ async function projectRowsHtml(key, dir, state) {
3212
3652
  const pg = paginate(sorted, st);
3213
3653
  const rows = pg.slice.map((it) => {
3214
3654
  const p = it.name;
3215
- const { projectPath, ddevName, isDdev, status, running, mailpit } = ddevStateOf(dir, p, statuses);
3655
+ const ddev = ddevStateOf(dir, p, statuses);
3656
+ const { projectPath, ddevName, isDdev, status, running } = ddev;
3216
3657
  // The IDE menu is built at row scope even though it renders in the state strip, right after
3217
3658
  // JIRA. Defining it inside the strip's IIFE made it a ReferenceError at the point of use — and because a
3218
3659
  // row's markup is built defensively, that silently dropped the whole action group
@@ -3269,106 +3710,8 @@ async function projectRowsHtml(key, dir, state) {
3269
3710
  // Counted from the rendered menu below rather than hand-maintained: the
3270
3711
  // number a row shows is the number of things it can actually do.
3271
3712
  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>` : ''}`;
3713
+ // The menu's own body, built first so the caption can count what is in it (ddevMenuItemsHtml).
3714
+ const ddevMenuItems = !(canDdev && isDdev) ? '' : ddevMenuItemsHtml({ key, p, meta, projectPath, gates, ddev });
3372
3715
  // Arranged before it is counted, so the caption counts what the menu really shows.
3373
3716
  const ddevItems = rowLayout.arrangeNavInner('ddev', ddevMenuItems, rowCtx);
3374
3717
  const ddevCount = (ddevItems.match(/<a /g) || []).length;
@@ -3376,79 +3719,25 @@ async function projectRowsHtml(key, dir, state) {
3376
3719
  title="This folder has no .ddev/config.yaml — set one up so it can start"
3377
3720
  hx-get="/fragments/projects/ddev-config/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3378
3721
  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>` : ''}`;
3722
+ 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', {
3723
+ // The items arrive from /fragments/row-menu/ddev/<ws>/<project> when the menu opens (coreRowMenuBody);
3724
+ // the row carries the button and the count.
3725
+ key, project: p, caption: `${ddevCount} command${ddevCount === 1 ? '' : 's'}`, dropdownClasses: 'ddev-dropdown-wide',
3726
+ 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>',
3727
+ }) : ''}`;
3384
3728
  // Order: what it is doing, the runtime menus that change that, then the
3385
3729
  // Tests menu and the result it produced.
3386
3730
  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>`;
3731
+ // One quality menu, not two (testsMenuState says why). The row carries the button and the
3732
+ // caption; the items arrive from /fragments/row-menu/tests/<ws>/<project> when the menu opens.
3733
+ const t = testsMenuState({ key, dir, p, gates });
3734
+ return menuShellHtml('tests', {
3735
+ key, project: p, caption: t.caption,
3736
+ // Never the danger variant: a failing suite is already stated in red on the state
3737
+ // strip above the name, and a permanently red menu button reads as "this control
3738
+ // is dangerous" rather than "those tests failed".
3739
+ 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>`,
3740
+ });
3452
3741
  })() : ''}`;
3453
3742
  const screensMenu = (() => {
3454
3743
  const n = projectScreenCount(projectPath);
@@ -3458,15 +3747,14 @@ async function projectRowsHtml(key, dir, state) {
3458
3747
  // the dropdown's own 15s poll, swapped out of band (see /fragments/projects/screens/…), so
3459
3748
  // it costs no request of its own and is never staler than the menu behind it.
3460
3749
  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>`;
3750
+ // The same shell as the other row menus (its body is /fragments/row-menu/screens/<ws>/<project>,
3751
+ // screensMenuBody) — and besides opening, it still asks on load, every 30 seconds and when a
3752
+ // screen changes, because the hand and the agent badge on the row ride on that answer.
3753
+ return menuShellHtml('screens', {
3754
+ key, project: p, caption: n ? `${n} running` : 'none', triggers: 'load, every 30s, screens-changed from:body',
3755
+ html: `<button class="uk-button ${n ? 'uk-button-primary' : 'uk-button-default'} uk-button-small" type="button" aria-label="SCREENS${n ? `, ${n} running` : ''}"
3756
+ 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>`,
3757
+ });
3470
3758
  })();
3471
3759
  // A project with no DDEV config has no menu to hold it, so Backup stays a button there.
3472
3760
  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>` : '';
@@ -3506,47 +3794,17 @@ async function projectRowsHtml(key, dir, state) {
3506
3794
  </div><span class="act-status uk-text-meta">${!rag.up ? 'offline' : built ? 'indexed' : 'none'}</span></span>`;
3507
3795
  })() : '';
3508
3796
  const moreMenu = (() => {
3509
- // Both verbs also live in the DDEV menu, where a DDEV project's
3510
- // teardown belongs. They stay here too: More is where someone looks
3511
- // for them, and a folder DDEV does not manage has no other home.
3512
- const items = [];
3513
- // What this product shipped, read from the CHANGELOG it already keeps. It belongs to the
3514
- // row rather than to the rail: a changelog is a fact about one product, and the question
3515
- // people ask is "what went into 1.0.3 of THIS", standing in front of it in its folder.
3516
- // The latest version is in the label, so the menu answers the common case unopened.
3517
- const cl = changelog.changelogFor(key, p);
3518
- if (cl) {
3519
- const latest = (changelog.changelogIndex().find((r) => r.workspace === key && r.item === p) || {}).latest || '';
3520
- items.push(`<li><a href hx-get="/fragments/changelog/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3521
- hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"
3522
- title="Releases, newest first, from ${esc(cl.file)}"><span uk-icon="icon: history; ratio: .7"></span> Changelog${latest ? ` <span class="uk-text-meta">${esc(latest)}</span>` : ''}</a></li>`);
3523
- }
3524
- // Recordings about this project: a video, or audio, started from a set of pre-prompts.
3525
- if (projectMake.WORKSPACES.includes(key)) {
3526
- const makeLink = (kind, icon, label, hint) => `<li data-entry="${esc(label.toLowerCase())}"><a href hx-get="/fragments/projects/make-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}?kind=${kind}"
3527
- hx-target="#editor-modal-body" hx-swap="innerHTML" hx-on::after:request="UIkit.modal('#editor-modal').show()"
3528
- title="${esc(hint)}"><span uk-icon="icon: ${icon}; ratio: .7"></span> ${esc(label)}…</a></li>`;
3529
- items.push(makeLink('video', 'play-circle', 'Create a video', 'A narrated video about this project, from a set of pre-prompts'));
3530
- items.push(makeLink('audio', 'microphone', 'Create audio', 'A spoken recording about this project, from a set of pre-prompts'));
3531
- items.push('<li class="uk-nav-divider"></li>');
3532
- }
3533
- if (canBackup) items.push(`<li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>`);
3534
- if (canRemove) {
3535
- items.push('<li class="uk-nav-divider"></li>');
3536
- items.push(`<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"
3537
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3538
- title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>`);
3539
- }
3540
3797
  // Arranged before it is counted, like DDEV — and whether there is a More at all is decided after,
3541
- // since hiding can empty it and a custom action can fill it.
3542
- const arranged = rowLayout.arrangeNavInner('more', items.join(''), rowCtx);
3798
+ // since hiding can empty it and a custom action can fill it. The row carries the button and the
3799
+ // count; the items arrive from /fragments/row-menu/more/<ws>/<project> when the menu opens.
3800
+ const arranged = rowLayout.arrangeNavInner('more', moreMenuItemsHtml({ key, p, gates }), rowCtx);
3543
3801
  if (!/<li\b/.test(arranged)) return '';
3544
3802
  // Dividers are not actions — count only what can be clicked.
3545
3803
  const n = (arranged.match(/<a /g) || []).length;
3546
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-more">
3547
- <button class="uk-button uk-button-default uk-button-small" type="button" title="More actions"><span uk-icon="icon: more; ratio: .7"></span> More <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
3548
- <div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav">${arranged}</ul></div>
3549
- </div><span class="act-status uk-text-meta">${n} action${n === 1 ? '' : 's'}</span></span>`;
3804
+ return menuShellHtml('more', {
3805
+ key, project: p, caption: `${n} action${n === 1 ? '' : 's'}`,
3806
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="More actions"><span uk-icon="icon: more; ratio: .7"></span> More <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
3807
+ });
3550
3808
  })();
3551
3809
  // SCREENS is drawn beside the running state in the card's head, so the tool strip leaves it out.
3552
3810
  const screensOnTools = '';
@@ -5559,6 +5817,36 @@ const server = http.createServer(async (req, res) => {
5559
5817
  }
5560
5818
  if (pathname === '/actions/status') return handleAction(pathname, {}, res);
5561
5819
  if (pathname.startsWith('/fragments/p/') && pluginFragment(pathname, req, res)) return;
5820
+ // The body of a plugin's row menu that loads when it opens (plugin-loader's rowMenuHtml): the
5821
+ // plugin's own fragment, arranged like an inline menu's items — the Buttons settings hide, order
5822
+ // and add to it the same way, and the settings page sees its entries once one has been opened.
5823
+ // The core's own menus (DDEV, COMPOSER, Tests, SCREENS, NODE, More) take the same route with no `body`: their
5824
+ // bodies come from coreRowMenuBody, computed for this one project when the menu opens.
5825
+ if (pathname.startsWith('/fragments/row-menu/')) {
5826
+ const m = pathname.match(/^\/fragments\/row-menu\/([a-z0-9-]+)\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
5827
+ const body = new URL(req.url, 'http://localhost').searchParams.get('body') || '';
5828
+ const core = !!m && CORE_ROW_MENUS.has(m[1]) && !body;
5829
+ if (!m || !isValidWorkspace(m[2]) || !NAME_RE.test(m[3])
5830
+ || !(core || (pluginRowMenuSlot(m[1]) && /^\/fragments\/p\/[a-z0-9-]+\//.test(body)))) {
5831
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
5832
+ return res.end('<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">No such menu.</li></ul>');
5833
+ }
5834
+ const [slot, key, project] = [m[1], m[2], m[3]];
5835
+ const answer = core ? await coreRowMenuBody(slot, key, project) : await pluginRowMenuBody(body, req);
5836
+ let html = answer.html;
5837
+ if (answer.status === 200) {
5838
+ const projectPath = path.join(workspaceDir(key), project);
5839
+ const layout = rowLayout.loadRowLayout();
5840
+ const ctx = { layout, key, project, isDdev: ddevProjectName(projectPath) !== null,
5841
+ hasComposer: require('./actions/composer').hasComposerJson(projectPath),
5842
+ hasNode: require('./actions/node').hasPackageJson(projectPath), hasGit: hasOwnGit(projectPath) };
5843
+ // Hidden entries, order and your own actions, then the columns the menu was set to — the two
5844
+ // passes a menu drawn inline went through (rowLayout.rowLinesHtml).
5845
+ try { html = rowLayout.columnizeButtonHtml(slot, rowLayout.arrangeButtonHtml(slot, html, ctx), layout); } catch (_) { /* shown as it was drawn */ }
5846
+ }
5847
+ res.writeHead(answer.status, { 'Content-Type': 'text/html; charset=utf-8' });
5848
+ return res.end(html);
5849
+ }
5562
5850
  // The knowledge graph a project's graphify run produced. Those files live
5563
5851
  // inside the project, not under a docroot, so the dashboard serves them.
5564
5852
  // graph.html is not self-contained: it pulls vis-network from unpkg.com.
@@ -6691,79 +6979,13 @@ const server = http.createServer(async (req, res) => {
6691
6979
  } }
6692
6980
  { const sm = pathname.match(/^\/fragments\/projects\/screens\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
6693
6981
  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>`;
6982
+ // The list's inner HTML, for a page drawn before the menus loaded their bodies through
6983
+ // /fragments/row-menu/screens/…: arranged like the row's other menus (Commands → Buttons).
6984
+ const { nav, oob, dir: sDir } = await screensMenuBody(sm[1], sm[2]);
6753
6985
  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],
6986
+ return res.end(rowLayout.arrangeNavInner('screens', nav, { key: sm[1], project: sm[2],
6765
6987
  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);
6988
+ hasNode: require('./actions/node').hasPackageJson(sDir), hasGit: hasOwnGit(sDir) }) + oob);
6767
6989
  } }
6768
6990
  { const cm = pathname.match(/^\/fragments\/projects\/ddev-config\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
6769
6991
  if (cm && isValidWorkspace(cm[1])) {