@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.
@@ -150,7 +150,8 @@ module.exports.register = (api) => {
150
150
  | `api.addFragment(handler)` | GET handler for `/fragments/p/<plugin>/<rest>`. Context: `{ req, res, send, rest, searchParams }`. |
151
151
  | `api.addPageActions(fn)` | `fn({ view, key, meta })` returns a rail group `{ label, items: [html] }` or null. Runs on every page render. |
152
152
  | `api.addProjectActions(fn)` | `fn({ key, project, dir, meta })` returns HTML appended to that project row's actions (or null). Runs per row on `projects`-kind pages. |
153
- | `api.addRowMenu(slot, { label, row }, fn)` | A named menu on every project row, in a slot the Buttons settings arrange like a built-in one. `fn({ key, project, dir, meta, allowed, vals })` returns the menu's HTML, or null for a row it has nothing for. |
153
+ | `api.addRowMenu(slot, { label, row }, fn)` | A named menu on every project row, in a slot the Buttons settings arrange like a built-in one. `fn({ key, project, dir, meta, allowed, vals })` returns the menu's HTML, or null for a row it has nothing for — or `{ html, body }`, a menu whose body loads when it opens (below). |
154
+ | `api.capabilities` | What this dashboard can do that an older one could not, as a `Set` of names; a package that serves several dashboards checks it before using a newer form of an answer. So far: `row-menu-body`. |
154
155
  | `api.addPage(re, handler)` | A whole page: GET requests whose path matches `re`. `handler({ req, res, match, send })` writes the response itself. |
155
156
  | `api.addProjectChip(fn)` | A chip in the project card's meta line. `fn({ key, project, dir, meta })` returns HTML or null. |
156
157
  | `api.addFolderSetup({ html, steps, prompt, problem })` | A section of the New-folder form (`html(ws, { allowed })`), the shell steps it adds (`steps(ws, name, form, ctx)` → array), the lines it adds to the Claude Code prompt (`prompt(…)` → array) and its validation (`problem(ws, form, ctx)` → '' or why). |
@@ -165,6 +166,50 @@ module.exports.register = (api) => {
165
166
  | `api.addSessionLog(fn)` | Where a session's log lives when the dashboard does not know: `fn(session, dirs)` returns a file in one of `dirs`, or ''. |
166
167
  | `api.addAssistantContext(fn)` | A paragraph of the assistant's system context: what this plugin lets it reach, and the rules. |
167
168
 
169
+ ### A row menu whose body loads when it opens
170
+
171
+ A page of fifty rows must not carry fifty copies of every menu. Instead of the whole menu, a row-menu
172
+ function may return an object:
173
+
174
+ ```js
175
+ api.addRowMenu('cloud', { label: 'CLOUD', row: 0 }, ({ key, project, dir, allowed }) => {
176
+ if (!allowed.has('cloud')) return null;
177
+ return {
178
+ html: `<button class="uk-button uk-button-default uk-button-small" type="button">CLOUD <span uk-icon="icon: chevron-down; ratio: .6"></span></button>`,
179
+ body: `/fragments/p/${api.name}/menu/${encodeURIComponent(key)}/${encodeURIComponent(project)}`,
180
+ caption: 'linked', // optional: the small text beside the button
181
+ classes: 'act-menu-hosting', // optional: extra classes on the menu's wrapper
182
+ };
183
+ });
184
+ // The body: what used to be inside the dropdown, as a fragment.
185
+ api.addFragment(({ send, rest }) => {
186
+ const m = rest.match(/^menu\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
187
+ if (m) return send(`<ul class="uk-nav uk-dropdown-nav"><li><a href …>…</a></li></ul>`);
188
+ });
189
+ ```
190
+
191
+ | Key | What it is |
192
+ | --- | --- |
193
+ | `html` | The button that opens the menu. Required. |
194
+ | `body` | The URL of a fragment this plugin serves (under `/fragments/p/<plugin>/`) whose response is the dropdown's inner HTML, normally one `<ul class="uk-nav uk-dropdown-nav">`. Required. |
195
+ | `caption` | The text beside the button (`not linked`, `3 running`), or none. |
196
+ | `classes` | Extra classes on the `.uk-inline.act-menu` wrapper, after the `act-menu-<slot>` the core adds. |
197
+
198
+ The core draws `<span class="act-with-status"><div class="uk-inline act-menu act-menu-<slot> …">` + the
199
+ button + an empty `uk-dropdown` shell + the caption. The shell carries `hx-get` to
200
+ `/fragments/row-menu/<slot>/<ws>/<project>?body=<url>` with `hx-trigger="menu-opened"`: UIkit's
201
+ `beforeshow` on the dropdown is relayed as `menu-opened` by `public/ui.js`, so the body is fetched each
202
+ time the menu opens and never before. The core asks the plugin's `body` URL itself and arranges the
203
+ items the way it arranges an inline menu's (the Buttons settings hide, order and add your own actions to
204
+ it alike), so a lazy menu behaves like an inline one once open. A string return is unchanged: the whole
205
+ menu, inline. A plugin that must also run on a dashboard older than this form checks
206
+ `api.capabilities && api.capabilities.has('row-menu-body')` and falls back to the string.
207
+
208
+ The core's own menus take the same road: DDEV, COMPOSER, Tests, SCREENS, NODE and More are drawn as this shell and
209
+ answer at `/fragments/row-menu/<slot>/<ws>/<project>` with no `body` parameter, their items computed for
210
+ that one project when the menu opens (a page of fifty rows is a third of what it was). A plugin's
211
+ `body` must still name one of its own fragments; the core slots are not open to one.
212
+
168
213
  `api.lib` also lends `tabModalHtml`, `hubDomain()`, `listScreens()`, `agentSessions()`, `screenLogFor()`, `claudeSessionsFor()` and
169
214
  `screensCached()` (the last `screen -ls`, for a renderer that cannot wait). The types of all of it are
170
215
  `@webship.co/workspace-plugin-api`; a plugin written in TypeScript is checked against them.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workspace-dashboard",
3
- "version": "1.0.0-rc1",
3
+ "version": "1.0.1",
4
4
  "description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin — required with npm, then activated, installed and configured.",
5
5
  "license": "ISC",
6
6
  "main": "server.js",
@@ -84,7 +84,7 @@ const actions = {}; // '/actions/p/<plugin>/<action>' -> handler(ctx)
84
84
  const fragments = {}; // '<plugin>' -> handler({ req, res, send, rest, searchParams })
85
85
  const pageActionFns = []; // ({ view, key, meta }) -> { label, items: [html] } | null
86
86
  const projectActionFns = []; // ({ key, project, dir, meta }) -> html | null
87
- const rowMenus = {}; // '<slot>' -> ({ key, project, dir, meta, allowed, vals }) -> html | null
87
+ const rowMenus = {}; // '<slot>' -> ({ key, project, dir, meta, allowed, vals }) -> html | { html, body, caption, classes } | null
88
88
  const pages = []; // { re, handler({ req, res, match, send }) }
89
89
  const chipFns = []; // ({ key, project, dir, meta }) -> html | null
90
90
  const folderSetups = []; // { html(ws, ctx), steps(ws, name, form, ctx), prompt(ws, name, form, ctx), problem(ws, form, ctx) }
@@ -133,11 +133,17 @@ function appliesTo(config, workspaceKey) {
133
133
  return list.length === 0 || list.includes(workspaceKey);
134
134
  }
135
135
 
136
+ // What this dashboard can do that an older one could not, for a package that serves several
137
+ // dashboards: a plugin checks `api.capabilities.has(…)` before it uses a newer form of an answer.
138
+ // row-menu-body a row-menu function may return `{ html, body }`: the body loads when the menu opens.
139
+ const CAPABILITIES = Object.freeze(new Set(['row-menu-body']));
140
+
136
141
  function makeApi(name, config) {
137
142
  return {
138
143
  name,
139
144
  config,
140
145
  lib,
146
+ capabilities: CAPABILITIES,
141
147
  // This plugin's own settings file (core/config/plugin.<name>.settings.yml): read it, or change one
142
148
  // key or one list in place with comments kept. Secret-named keys stay out of the browser like
143
149
  // any setting.
@@ -163,7 +169,9 @@ function makeApi(name, config) {
163
169
  },
164
170
  // A named menu on every project row, in the slot the layout settings arrange: `spec` is
165
171
  // `{ label, row }` like a built-in button's. The function draws it, or returns null for a row
166
- // the plugin has nothing to say about.
172
+ // the plugin has nothing to say about. It may also return `{ html, body, caption, classes }`:
173
+ // the button, and the URL of a fragment that answers the dropdown's inner HTML when the menu
174
+ // opens — so a page of fifty rows carries fifty buttons, not fifty menus (rowMenuHtml).
167
175
  addRowMenu(slot, spec, fn) {
168
176
  if (!/^[a-z0-9-]+$/.test(slot)) throw new Error(`${name}: invalid row menu slot '${slot}'`);
169
177
  rowLayout.registerButton({ id: slot, label: spec.label || slot, row: spec.row || 0, columns: spec.columns, menu: spec.menu });
@@ -376,11 +384,84 @@ function pluginProjectActionsHtml(ctx) {
376
384
  function pluginRowMenus(ctx) {
377
385
  const out = {};
378
386
  for (const [slot, fn] of Object.entries(rowMenus)) {
379
- try { out[slot] = fn(ctx) || ''; } catch (_) { out[slot] = ''; }
387
+ try { out[slot] = rowMenuHtml(slot, fn(ctx), ctx); } catch (_) { out[slot] = ''; }
380
388
  }
381
389
  return out;
382
390
  }
383
391
 
392
+ /**
393
+ * What a row-menu function answered, as the row's HTML.
394
+ *
395
+ * A string is the whole menu, drawn inline as it always was. An object `{ html, body, caption, classes }`
396
+ * is a menu whose body loads when it opens: `html` is the button, `body` the URL of a fragment the
397
+ * plugin serves (under /fragments/p/<plugin>/) whose response is the dropdown's inner HTML, `caption`
398
+ * the small text beside the button and `classes` extra classes on the menu's wrapper. The page then
399
+ * carries the button, the caption and an empty dropdown shell; UIkit's `beforeshow` on the shell is
400
+ * relayed as `menu-opened` (public/ui.js), and htmx fetches the body into it. The fetch goes through
401
+ * /fragments/row-menu/<slot>/<ws>/<project>, which asks the plugin's URL and arranges the items the
402
+ * way the Buttons settings arrange an inline menu's (hidden entries, order, your own actions).
403
+ */
404
+ function rowMenuHtml(slot, result, ctx = {}) {
405
+ if (!result) return '';
406
+ if (typeof result === 'string') return result;
407
+ if (typeof result !== 'object' || typeof result.html !== 'string' || !result.html
408
+ || typeof result.body !== 'string' || !/^\/fragments\/p\/[a-z0-9-]+\//.test(result.body)) return '';
409
+ return menuShellHtml(slot, {
410
+ key: ctx.key, project: ctx.project, html: result.html, caption: result.caption, classes: result.classes,
411
+ query: `?body=${encodeURIComponent(result.body)}`,
412
+ });
413
+ }
414
+
415
+ /**
416
+ * The shell of a row menu whose body loads when it opens — the one markup for a plugin's lazy menu and for
417
+ * the core's own (DDEV, COMPOSER, Tests, SCREENS, NODE, More): `<span class="act-with-status">` around the
418
+ * `.uk-inline.act-menu.act-menu-<slot>` wrapper, the button (`html`), an empty `uk-dropdown` shell and the
419
+ * caption. The shell fetches `/fragments/row-menu/<slot>/<ws>/<project><query>` on `menu-opened` (the relay
420
+ * of UIkit's `beforeshow` in public/ui.js) and swaps the answer in as its inner HTML; `triggers` adds more
421
+ * (SCREENS also polls, for the badges it swaps out of band). `classes` go on the wrapper after
422
+ * `act-menu-<slot>`, `dropdownClasses` on the shell after `act-menu-lazy` (a menu's own width class).
423
+ */
424
+ function menuShellHtml(slot, { key, project, html, caption, classes, dropdownClasses, triggers, query = '' } = {}) {
425
+ const url = `/fragments/row-menu/${encodeURIComponent(slot)}/${encodeURIComponent(key || '')}/${encodeURIComponent(project || '')}${query}`;
426
+ const wrap = ['uk-inline', 'act-menu', `act-menu-${slot}`, ...String(classes || '').split(/\s+/).filter(Boolean)];
427
+ const drop = ['act-menu-lazy', ...String(dropdownClasses || '').split(/\s+/).filter(Boolean)];
428
+ const trigger = ['menu-opened', ...String(triggers || '').split(/\s*,\s*/).filter(Boolean)].join(', ');
429
+ const cap = caption === undefined || caption === null || caption === '' ? ''
430
+ : `<span class="act-status uk-text-meta">${esc(String(caption))}</span>`;
431
+ return `<span class="act-with-status"><div class="${esc([...new Set(wrap)].join(' '))}">${html}`
432
+ + `<div uk-dropdown="mode: click; pos: bottom-right" class="${esc([...new Set(drop)].join(' '))}" hx-get="${esc(url)}" hx-trigger="${esc(trigger)}" hx-target="this" hx-swap="innerHTML">`
433
+ + '<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">…</li></ul></div></div>'
434
+ + `${cap}</span>`;
435
+ }
436
+
437
+ /** Whether a plugin registered a row menu in this slot. */
438
+ const pluginRowMenuSlot = (slot) => Object.prototype.hasOwnProperty.call(rowMenus, slot);
439
+
440
+ /**
441
+ * The body of a lazy row menu: the plugin's fragment at `body` (a /fragments/p/<plugin>/… URL), asked
442
+ * the way a browser would and answered as `{ status, html }` instead of written to a response — so the
443
+ * caller can arrange the items before sending them. A plugin that never answers is given 30 seconds.
444
+ */
445
+ function pluginRowMenuBody(body, req = {}) {
446
+ return new Promise((resolve) => {
447
+ let done = false;
448
+ const finish = (status, html) => { if (done) return; done = true; clearTimeout(timer); resolve({ status, html }); };
449
+ const timer = setTimeout(() => finish(504, '<li class="uk-nav-header">The menu did not answer.</li>'), 30000);
450
+ if (typeof timer.unref === 'function') timer.unref();
451
+ let status = 200;
452
+ let out = '';
453
+ const res = {
454
+ writeHead(code) { status = code || 200; },
455
+ setHeader() {},
456
+ write(chunk) { out += String(chunk || ''); },
457
+ end(chunk) { out += String(chunk || ''); finish(status, out); },
458
+ };
459
+ const pathname = String(body).split('?')[0];
460
+ const hit = pluginFragment(pathname, { url: body, method: 'GET', headers: (req && req.headers) || {} }, res);
461
+ if (!hit) finish(404, '<li class="uk-nav-header">No such menu.</li>');
462
+ });
463
+ }
464
+
384
465
  /** The chips plugins put in a project card's meta line. */
385
466
  function pluginProjectChipsHtml(ctx) {
386
467
  let html = '';
@@ -460,7 +541,7 @@ loadPlugins();
460
541
 
461
542
  module.exports = {
462
543
  pluginActions: actions, pluginFragment, pluginPage, pluginPageActionGroups, pluginProjectActionsHtml,
463
- pluginRowMenus, pluginProjectChipsHtml, pluginFolderSetups, pluginCssFiles, pluginLabels,
544
+ pluginRowMenus, rowMenuHtml, menuShellHtml, pluginRowMenuSlot, pluginRowMenuBody, pluginProjectChipsHtml, pluginFolderSetups, pluginCssFiles, pluginLabels,
464
545
  pluginProjectBadgesHtml, pluginSettingsOptions, pluginScreenStarted, pluginWorkKeyLink, pluginWorkPrompt, pluginSessionLog, pluginAssistantContext, pluginForge, pluginForges, pluginOpenWith, pluginFolderLinks, pluginWorkspaceView,
465
546
  loadedPlugins: loaded, pluginConfigs,
466
547
  };
@@ -93,7 +93,7 @@ function pluginsPageHtml() {
93
93
  <strong>configure</strong> its own settings file. The same as <code>bash core/scripts/plugins.sh</code>.</p>
94
94
  <form class="uk-flex uk-flex-middle uk-margin-small-bottom plugin-require" hx-post="/actions/plugins" hx-target="#webship-workspace-output" hx-swap="innerHTML">
95
95
  <input type="hidden" name="verb" value="require">
96
- <input class="uk-input uk-form-small uk-form-width-large" name="spec" placeholder="github:webship/workspace-jira" aria-label="Package to require" required pattern="[A-Za-z0-9@:/._#+~-]+">
96
+ <input class="uk-input uk-form-small uk-form-width-large" name="spec" placeholder="@webship.co/workspace-jira" aria-label="Package to require" required pattern="[A-Za-z0-9@:/._#+~-]+">
97
97
  <button class="uk-button uk-button-default uk-button-small uk-margin-small-left" type="submit">Require</button>
98
98
  </form>
99
99
  <div class="plugin-cards">${cards || '<p class="uk-text-meta">No plugin packages yet. Pick one from the catalogue below, or require one above.</p>'}</div>
@@ -315,9 +315,14 @@ document.body.addEventListener('click', (e) => {
315
315
  // sessions is the one thing you open the menu to read, and reading a thirty-second-old one is how you
316
316
  // end up clicking a session that has already finished.
317
317
  //
318
- // UIkit emits `beforeshow` on the dropdown, and htmx listens for `menu-opened` on the list inside it.
318
+ // UIkit emits `beforeshow` on the dropdown, and htmx listens for `menu-opened` on the list inside it —
319
+ // or on the dropdown itself, when the whole body loads as it opens (a plugin's lazy row menu). The
320
+ // relay, rather than `hx-trigger="beforeshow"` on the dropdown: a nav inside a menu emits `beforeshow`
321
+ // too when it unfolds, and that bubbling up must not fetch the menu again under the pointer.
319
322
  document.addEventListener('beforeshow', (e) => {
320
- const list = e.target.querySelector && e.target.querySelector('[hx-trigger*="menu-opened"]');
323
+ const t = e.target;
324
+ if (!t || !t.querySelector) return;
325
+ const list = (t.matches && t.matches('[hx-trigger*="menu-opened"]')) ? t : t.querySelector('[hx-trigger*="menu-opened"]');
321
326
  if (list) list.dispatchEvent(new CustomEvent('menu-opened'));
322
327
  }, true);
323
328
 
@@ -798,7 +803,18 @@ function foldDdevMenus(root) {
798
803
  });
799
804
  }
800
805
  document.addEventListener('DOMContentLoaded', () => foldDdevMenus());
801
- document.body.addEventListener('htmx:after:swap', (e) => foldDdevMenus(swappedEl(e)));
806
+ document.body.addEventListener('htmx:after:swap', (e) => {
807
+ const el = swappedEl(e);
808
+ // A menu whose body just arrived — a row menu loading as it opens, SCREENS on its poll — is the dropdown
809
+ // itself, which the swap left in place with its fold mark while the folded rows went with the old body.
810
+ // Marked unfolded again; folded now if it is open, else by `shown` when it opens.
811
+ if (el && el.matches && el.matches('.act-menu .uk-dropdown')) {
812
+ delete el.dataset.folded;
813
+ if (el.classList.contains('uk-open')) foldDdevMenus(el.parentElement);
814
+ return;
815
+ }
816
+ foldDdevMenus(el);
817
+ });
802
818
  // A menu that opens upwards near the top would sit under the top bar; the page moves down to show it.
803
819
  document.addEventListener('shown', (e) => {
804
820
  const d = e.target;