@monster-orange/den 0.4.16 → 0.4.17

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.
@@ -1168,6 +1168,11 @@
1168
1168
  }
1169
1169
  .tablewrap {
1170
1170
  overflow-x: auto;
1171
+ /* 0.4.17: in a short window (under ~620 px tall) the column's other
1172
+ parts squeezed it to nothing; it keeps room for rows and scrolls. */
1173
+ min-height: 200px;
1174
+ flex-shrink: 0;
1175
+ overflow-y: auto;
1171
1176
  border: 1px solid var(--border);
1172
1177
  border-radius: var(--radius);
1173
1178
  background: var(--surface);
@@ -1285,6 +1290,9 @@
1285
1290
  .tbl-health.ok {
1286
1291
  color: var(--ok);
1287
1292
  }
1293
+ .tbl-up:not(:empty) {
1294
+ margin-left: 8px;
1295
+ }
1288
1296
  .tbl-muted {
1289
1297
  color: var(--muted);
1290
1298
  font-size: 12.5px;
@@ -1330,6 +1338,46 @@
1330
1338
  .tbl-acts .tbl-more-sp {
1331
1339
  display: inline-block;
1332
1340
  }
1341
+ /* Visit ↗ (0.4.17): before Start, so Start/Stop/Restart/Logs and ⋯
1342
+ still line up across rows whether a service has a URL or not. */
1343
+ .tbl-acts .tbl-visit {
1344
+ flex: none;
1345
+ padding-left: 6px;
1346
+ padding-right: 6px;
1347
+ text-decoration: none;
1348
+ }
1349
+ /* The table never scrolls sideways: in a window under 1600px wide
1350
+ (the workspace list beside it) Visit is its ↗; its name and title
1351
+ still say Visit. */
1352
+ @media (max-width: 1599px) {
1353
+ .tbl-visit .tbl-visit-w {
1354
+ display: none;
1355
+ }
1356
+ }
1357
+ table.svc a.repo-name {
1358
+ text-decoration: underline;
1359
+ text-decoration-color: var(--border-strong);
1360
+ text-underline-offset: 2px;
1361
+ }
1362
+ table.svc a.repo-name:hover {
1363
+ text-decoration-color: currentColor;
1364
+ }
1365
+ /* The favourite star (0.4.17): the shell's pins, as on Services. */
1366
+ .tbl-star {
1367
+ background: none;
1368
+ border: 1px solid transparent;
1369
+ border-radius: var(--radius-button);
1370
+ color: var(--faint);
1371
+ cursor: pointer;
1372
+ font-size: 13px;
1373
+ line-height: 1.2;
1374
+ padding: 0 2px;
1375
+ }
1376
+ .tbl-star:hover,
1377
+ .tbl-star[aria-pressed="true"] {
1378
+ color: var(--accent-text);
1379
+ background: var(--sunken);
1380
+ }
1333
1381
  .tbl-btn:disabled {
1334
1382
  opacity: 0.45;
1335
1383
  cursor: not-allowed;
@@ -1769,7 +1817,11 @@
1769
1817
  .querySelectorAll(".ws-menu-dropdown.open")
1770
1818
  .forEach((d) => d.classList.remove("open"));
1771
1819
  const menu = document.getElementById(menuBtn.dataset.menuId);
1772
- menu?.classList.toggle("open");
1820
+ if (menu?.classList.toggle("open"))
1821
+ window.denShell?.topLayer?.show(menu, menuBtn, {
1822
+ align: "right",
1823
+ gap: 2,
1824
+ });
1773
1825
  return;
1774
1826
  }
1775
1827
  // ↗ open in editor
@@ -1858,8 +1910,16 @@
1858
1910
  if (d.id !== btn.dataset.menuId) d.style.display = "none";
1859
1911
  });
1860
1912
  const menu = document.getElementById(btn.dataset.menuId);
1861
- if (menu)
1862
- menu.style.display = menu.style.display === "none" ? "" : "none";
1913
+ if (menu) {
1914
+ const show = menu.style.display === "none";
1915
+ menu.style.display = show ? "" : "none";
1916
+ // 0.4.17: in the top layer, so no row or card clips it.
1917
+ if (show)
1918
+ window.denShell?.topLayer?.show(menu, btn, {
1919
+ align: "right",
1920
+ gap: 3,
1921
+ });
1922
+ }
1863
1923
  e.stopPropagation();
1864
1924
  return;
1865
1925
  }
@@ -2102,7 +2162,7 @@
2102
2162
  <th scope="col">Health</th>
2103
2163
  <th scope="col">Branch</th>
2104
2164
  <th scope="col">Mode</th>
2105
- <th scope="col">Last run</th>
2165
+ <th scope="col">Last test run</th>
2106
2166
  <th scope="col"><span class="tbl-sr">Actions</span></th>
2107
2167
  </tr></thead>
2108
2168
  </table>
@@ -2272,22 +2332,60 @@
2272
2332
  return [{ key: "", title: null, rows }];
2273
2333
  }
2274
2334
 
2275
- /** The last run (the runs dock's) of a repo's tab: "✓ 12:03", "running…", "—". */
2335
+ // 0.4.17: each repo's last test run, from the server's run history
2336
+ // (every browser's runs, kept across restarts), and when its
2337
+ // service started (the daemons list), shown apart from it.
2338
+ let _lastRuns = {};
2339
+ let _upSince = new Map();
2340
+ async function loadLastRuns() {
2341
+ const [last, daemons] = await Promise.all([
2342
+ fetch("/api/runs/last")
2343
+ .then((r) => (r.ok ? r.json() : null))
2344
+ .catch(() => null),
2345
+ fetch("/api/service/daemons")
2346
+ .then((r) => (r.ok ? r.json() : []))
2347
+ .catch(() => []),
2348
+ ]);
2349
+ _lastRuns = last?.repos ?? {};
2350
+ _upSince = new Map();
2351
+ for (const d of Array.isArray(daemons) ? daemons : []) {
2352
+ const at = Date.parse(d.startedAt);
2353
+ if (!d.repoName || !Number.isFinite(at)) continue;
2354
+ const cur = _upSince.get(d.repoName);
2355
+ if (cur === undefined || at < cur) _upSince.set(d.repoName, at);
2356
+ }
2357
+ paintLastRuns();
2358
+ }
2359
+
2360
+ /** A repo's last test run: "✓ 12:03", "✗ failed 12:03", "running…", "—". */
2276
2361
  function lastRunText(repo) {
2277
- const list = runs()?.list?.() ?? [];
2278
- const mine = list.filter(
2279
- (r) => r.id === repo || r.id.split("/")[0] === repo,
2280
- );
2281
- const r = mine.sort((a, b) => (b.ranAt ?? 0) - (a.ranAt ?? 0))[0];
2282
- if (!r || (!r.ranAt && !r.running)) return { text: "—", cls: "" };
2283
- if (r.running) return { text: "running…", cls: "" };
2362
+ const r = _lastRuns[repo];
2363
+ if (!r) return { text: "—", cls: "", title: "" };
2364
+ const title = [r.pillar, r.env].filter(Boolean).join(" · ");
2365
+ if (r.running) return { text: "running…", cls: "", title };
2284
2366
  const t = new Intl.DateTimeFormat(undefined, {
2285
2367
  hour: "2-digit",
2286
2368
  minute: "2-digit",
2287
- }).format(new Date(r.endedAt ?? r.ranAt));
2288
- return r.error
2289
- ? { text: `✗ failed ${t}`, cls: "bad" }
2290
- : { text: `✓ ${t}`, cls: "ok" };
2369
+ }).format(new Date(r.endedAt ?? r.startedAt));
2370
+ return r.exitCode === 0
2371
+ ? { text: `✓ ${t}`, cls: "ok", title }
2372
+ : { text: `✗ failed ${t}`, cls: "bad", title };
2373
+ }
2374
+
2375
+ /** How long a repo's service has run: "up 2h", or "" when it doesn't. */
2376
+ function upText(repo) {
2377
+ const at = _upSince.get(repo);
2378
+ if (at === undefined) return "";
2379
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000));
2380
+ const d =
2381
+ s < 60
2382
+ ? `${s}s`
2383
+ : s < 3600
2384
+ ? `${Math.floor(s / 60)}m`
2385
+ : s < 86400
2386
+ ? `${Math.floor(s / 3600)}h`
2387
+ : `${Math.floor(s / 86400)}d`;
2388
+ return `up ${d}`;
2291
2389
  }
2292
2390
 
2293
2391
  /** A health cell: the dot and the rollup, or the failing check. */
@@ -2335,6 +2433,47 @@
2335
2433
  );
2336
2434
  }
2337
2435
 
2436
+ /** A service's URL to visit (its edge URL if routed, else its local one), or null. */
2437
+ function visitUrl(svc) {
2438
+ const u = svc?.open;
2439
+ return typeof u === "string" && /^https?:\/\//.test(u) ? u : null;
2440
+ }
2441
+ /** The shell's words (its language), else English. */
2442
+ function shT(key, vars, en) {
2443
+ const v = window.denShell?.t?.(key, vars);
2444
+ return v && v !== key ? v : en;
2445
+ }
2446
+ /** Visit ↗ (0.4.17): opens the service in a new tab; nothing without a URL. */
2447
+ function visitBtn(svc, name) {
2448
+ const url = visitUrl(svc);
2449
+ if (!url) return "";
2450
+ const label = shT(
2451
+ "act.visitX",
2452
+ { s: name },
2453
+ `Visit ${name} (opens a new tab)`,
2454
+ );
2455
+ return `<a class="den-btn sm tbl-btn tbl-visit" href="${escHtml(url)}" target="_blank" rel="noopener" aria-label="${escHtml(label)}" title="${escHtml(url)}" data-k="visit:${escHtml(name)}"><span class="tbl-visit-w">${escHtml(shT("act.visit", {}, "Visit"))} </span>↗</a>`;
2456
+ }
2457
+ /** A row's name: a link to visit when the service has a URL. */
2458
+ function nameLink(svc, text) {
2459
+ const url = visitUrl(svc);
2460
+ return url
2461
+ ? `<a class="repo-name" href="${escHtml(url)}" target="_blank" rel="noopener" title="${escHtml(shT("act.visitX", { s: url }, `Visit ${url} (opens a new tab)`))}">${escHtml(text)}</a>`
2462
+ : `<span class="repo-name">${escHtml(text)}</span>`;
2463
+ }
2464
+ /** The favourite star (0.4.17): the shell's pins, the same store as Services. */
2465
+ function starBtn(id) {
2466
+ const sh = window.denShell;
2467
+ if (!sh?.isPinned) return "";
2468
+ const on = sh.isPinned(id);
2469
+ const label = shT(
2470
+ on ? "act.unpinX" : "act.pinX",
2471
+ { s: id },
2472
+ `${on ? "Unpin" : "Pin"} ${id}`,
2473
+ );
2474
+ return `<button type="button" class="tbl-star" data-pin-toggle="${escHtml(id)}" aria-pressed="${on ? "true" : "false"}" aria-label="${escHtml(label)}" title="${escHtml(label)}" data-k="star:${escHtml(id)}">${on ? "★" : "☆"}</button>`;
2475
+ }
2476
+
2338
2477
  /** Action buttons; each one's name says what and on which repo or service. */
2339
2478
  function actBtns(id, o) {
2340
2479
  const b = (act, label, name, extra = "") =>
@@ -2407,12 +2546,12 @@
2407
2546
  ? `<span class="tbl-mode"><span class="badge ${escHtml(mode)}">${escHtml(mode)}</span><button type="button" class="tbl-switch" data-action="tbl-mode" data-repo="${escHtml(repo)}" data-mode="${switchTo}" aria-label="${escHtml(repo)}: use ${mode === "managed" ? "local" : "managed"}" data-k="mode:${escHtml(repo)}">use ${mode === "managed" ? "local" : "managed"}</button></span>`
2408
2547
  : "";
2409
2548
  const head = `<tr class="repo${r.problem ? " prob" : ""}" data-repo="${escHtml(repo)}">
2410
- <th scope="row">${exp}<span class="repo-name">${escHtml(repo)}</span>${badges}</th>
2411
- <td>${repoHealth(r)}</td>
2549
+ <th scope="row">${exp}${nameLink(primary, repo)} ${starBtn(repo)}${badges}</th>
2550
+ <td>${repoHealth(r)}<span class="tbl-muted tbl-up" data-up="${escHtml(repo)}">${escHtml(upText(repo))}</span></td>
2412
2551
  <td class="tbl-mono tbl-muted">${escHtml(primary?.branch ?? "")}</td>
2413
2552
  <td>${modeCell}</td>
2414
- <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}">${escHtml(run.text)}</td>
2415
- <td><div class="tbl-acts">${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2553
+ <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}" title="${escHtml(run.title)}">${escHtml(run.text)}</td>
2554
+ <td><div class="tbl-acts">${visitBtn(primary, repo)}${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2416
2555
  </tr>`;
2417
2556
  const fixes = r.fixes
2418
2557
  .map(
@@ -2434,10 +2573,10 @@
2434
2573
  s,
2435
2574
  i,
2436
2575
  ) => `<tr class="sub" id="${subIds[i]}" data-repo="${escHtml(repo)}" data-id="${escHtml(s.id)}"${shown(s) ? "" : " hidden"}>
2437
- <th scope="row"><span class="repo-name">${escHtml(s.name)}</span>${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2576
+ <th scope="row">${nameLink(s, s.name)} ${starBtn(s.id)}${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2438
2577
  <td>${!_lastHealthAt ? CHECKING() : healthCell(status(s), s.health.ok === false ? checkLineOf(s) : healthLine(s))}</td>
2439
2578
  <td></td><td></td><td></td>
2440
- <td><div class="tbl-acts">${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2579
+ <td><div class="tbl-acts">${visitBtn(s, s.id)}${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2441
2580
  </tr>`,
2442
2581
  )
2443
2582
  .join("")
@@ -2516,13 +2655,17 @@
2516
2655
  if (keep)
2517
2656
  table.querySelector(`[data-k="${CSS.escape(keep)}"]`)?.focus();
2518
2657
  }
2519
- /** Only the Last run cells (the dock changed). */
2658
+ /** Only the Last test run and "up" cells (the history changed). */
2520
2659
  function paintLastRuns() {
2521
2660
  document.querySelectorAll("#repo-table [data-last]").forEach((td) => {
2522
2661
  const run = lastRunText(td.dataset.last);
2523
2662
  td.textContent = run.text;
2663
+ td.title = run.title;
2524
2664
  td.className = "tbl-muted" + (run.cls ? " " + run.cls : "");
2525
2665
  });
2666
+ document.querySelectorAll("#repo-table [data-up]").forEach((el) => {
2667
+ el.textContent = upText(el.dataset.up);
2668
+ });
2526
2669
  }
2527
2670
 
2528
2671
  /**
@@ -2581,6 +2724,14 @@
2581
2724
  setTableGroup(g.dataset.group);
2582
2725
  return;
2583
2726
  }
2727
+ const star = e.target.closest("#repo-table [data-pin-toggle]");
2728
+ if (star) {
2729
+ const sh = window.denShell;
2730
+ const id = star.dataset.pinToggle;
2731
+ if (sh?.isPinned(id)) sh.unpin(id);
2732
+ else sh?.pin(id);
2733
+ return;
2734
+ }
2584
2735
  const b = e.target.closest("[data-action^='tbl-']");
2585
2736
  if (!b || b.disabled) return;
2586
2737
  const a = b.dataset.action;
@@ -2641,8 +2792,20 @@
2641
2792
  const v = localStorage.getItem(LS_GROUP);
2642
2793
  if (["none", "app", "org"].includes(v)) _tblGroup = v;
2643
2794
  } catch {}
2644
- // The Last run column follows the dock.
2645
- window.denShell?.runs?.onChange?.(paintLastRuns);
2795
+ // The Last test run column: the server's history, read now, when a
2796
+ // run here changes, and every 30 s for runs from elsewhere.
2797
+ void loadLastRuns();
2798
+ // The dock changes on every line of a run: read at most every 2 s.
2799
+ let _lastRunsSoon = null;
2800
+ window.denShell?.runs?.onChange?.(() => {
2801
+ _lastRunsSoon ??= setTimeout(() => {
2802
+ _lastRunsSoon = null;
2803
+ void loadLastRuns();
2804
+ }, 2_000);
2805
+ });
2806
+ setInterval(() => {
2807
+ if (!document.hidden) void loadLastRuns();
2808
+ }, 30_000);
2646
2809
 
2647
2810
  // ── Tab management (one persistent tab per service address) ───────────────
2648
2811
  //
@@ -79,7 +79,7 @@ export const ACTIONS = [
79
79
  },
80
80
  {
81
81
  id: "open",
82
- label: "Open ↗",
82
+ label: "Visit ↗",
83
83
  for: ["service"],
84
84
  tier: "common",
85
85
  when: (s) => !!(s.open || s.target?.url),
@@ -134,6 +134,10 @@
134
134
  font-size: 11px;
135
135
  color: var(--muted);
136
136
  }
137
+ .view-note {
138
+ color: var(--muted);
139
+ font-size: 12px;
140
+ }
137
141
  #status-text {
138
142
  font-size: 11px;
139
143
  color: var(--muted);
@@ -678,6 +682,7 @@
678
682
  ⟳ Refresh
679
683
  </button>
680
684
  <span id="status-text">idle</span>
685
+ <span id="view-note"></span>
681
686
  <div class="h-spacer" style="flex: 1"></div>
682
687
  <button class="btn" id="waves-btn" title="Edit wave assignment">
683
688
  Waves…
@@ -735,6 +740,7 @@
735
740
  refreshHealth,
736
741
  initWorkspacePill,
737
742
  } from "/header.js";
743
+ import { viewNoteHtml, viewRepos, viewScope } from "/view-scope.js";
738
744
 
739
745
  // The header's dots report LOCAL service health here: #den-shell pins
740
746
  // data-health-env="local" (shell.js). The env picker is the shell's
@@ -819,6 +825,15 @@
819
825
  .replace(/>/g, "&gt;")
820
826
  .replace(/"/g, "&quot;");
821
827
 
828
+ // 0.4.17: the repos follow the top bar's View (Org and Workspace),
829
+ // as on Environments: no picker of this page's own (view-scope.js).
830
+ let viewIn = null;
831
+ const viewReady = viewScope().then(async (v) => {
832
+ const note = document.getElementById("view-note");
833
+ if (note) note.innerHTML = viewNoteHtml(v);
834
+ viewIn = await viewRepos(v);
835
+ });
836
+
822
837
  // ── Init ──────────────────────────────────────────────────────────────────
823
838
  async function init() {
824
839
  try {
@@ -909,7 +924,10 @@
909
924
  const data = await fetch(
910
925
  `/api/release/status?env=${encodeURIComponent(state.env)}&refresh=${refresh ? 1 : 0}`,
911
926
  ).then((r) => r.json());
912
- state.rows = data.rows || [];
927
+ await viewReady;
928
+ state.rows = (data.rows || []).filter(
929
+ (r) => !viewIn || viewIn.has(r.repo),
930
+ );
913
931
  setStatus(
914
932
  data.error
915
933
  ? `error: ${data.error}`
@@ -62,8 +62,9 @@
62
62
  * setOrgs([{ id, title, workspaces? }], currentId, onSelect?)
63
63
  * two or more: the Org ▾ menu; a pick
64
64
  * calls onSelect(id), fires `den:org`
65
- * setWorkspaces({ label, title, items, focus, onSelect })
65
+ * setWorkspaces({ label, title, items, focus, names, onSelect })
66
66
  * the Workspace ▾ menu (header.js)
67
+ * workspaceNames() every workspace's name, from it
67
68
  * syncEnv() repaint Env ▾ from #env-sel
68
69
  * registerCommand({ id, label, keywords?, kind?, run })
69
70
  * a ⌘K palette row (R17, dev.20). label
@@ -77,6 +78,8 @@
77
78
  * unregisterCommand(id), commands()
78
79
  * openMenu(anchor, menu) a .pop menu below anchor (the scope
79
80
  * parts' and the user menu's)
81
+ * topLayer.show(el, anchor, opts) a page's own menu in the top layer
82
+ * (0.4.17), placed by anchor; hide(el)
80
83
  *
81
84
  * Loading before shell.js: push functions onto window.denShellQueue (an
82
85
  * array); each runs with denShell once it exists, and a later push runs at
@@ -160,6 +163,8 @@
160
163
  "b.down.one": "{n} down",
161
164
  "act.pin": "Pin",
162
165
  "act.unpin": "Unpin",
166
+ "act.visit": "Visit",
167
+ "act.visitX": "Visit {s} (opens a new tab)",
163
168
  "act.pinX": "Pin {s}",
164
169
  "act.unpinX": "Unpin {s}",
165
170
  "toast.pinned": "Pinned {s}",
@@ -220,6 +225,8 @@
220
225
  "kind.app": "App",
221
226
  "kind.action": "Action",
222
227
  "kind.setting": "Setting",
228
+ "kind.workspace": "Workspace",
229
+ "pal.goWs": "Go to workspace {ws}",
223
230
  "pal.repos": "{n} repos",
224
231
  "pal.repos.one": "{n} repo",
225
232
  "pal.restart": "Restart {s}",
@@ -625,6 +632,10 @@
625
632
  "ag.pc.busy": "It's working. Change after this turn, or stop it now?",
626
633
  "ag.pc.after": "After this turn",
627
634
  "ag.pc.now": "Stop it now",
635
+ "ag.pc.fresh":
636
+ "{id} is now {p}: nothing to resume yet; started fresh (same conversation id, folder and scope)",
637
+ "ag.freshLine":
638
+ "Nothing to resume yet; started fresh (same conversation id, folder and scope)",
628
639
  "ag.pc.done": "{id} is now {p}",
629
640
  "ag.pc.pending": "{id} becomes {p} when this turn ends",
630
641
  "ag.more": "More for {id}",
@@ -776,6 +787,9 @@
776
787
  "This session has ended. Its record stays until it is resumed or removed.",
777
788
  "ag.switched": "Switched to {p}",
778
789
  "ag.exitedLine": "[the session exited{code}]",
790
+ "ag.refusedUnknown": "the server refused the terminal",
791
+ "ag.refusedStatus": "the server refused the terminal ({status})",
792
+ "ag.goWs": "Open {ws} on Workspaces",
779
793
  "ag.lost": "[disconnected: {why}]",
780
794
  "ag.reconnect": "Reconnect",
781
795
  "ag.noBackend":
@@ -1411,6 +1425,116 @@
1411
1425
  return el;
1412
1426
  }
1413
1427
 
1428
+ // ── the top layer (0.4.17): one placement for every menu ─────────────
1429
+ // A menu shown with topShow is in the browser's top layer (a manual
1430
+ // popover), placed by its anchor: below it, above when there's more room
1431
+ // there, kept inside the window. No overflow or stacking ancestor can
1432
+ // clip or cover it, and it stays where it is in the page, so the page's
1433
+ // own click handlers still reach it. It closes when the page hides it
1434
+ // (display: none), its anchor goes, or topHide.
1435
+ var topOpen = [];
1436
+ var TOP_MARGIN = 8;
1437
+ function topSupported(el) {
1438
+ return typeof el.showPopover === "function";
1439
+ }
1440
+ function topPlace(el, anchor, opts) {
1441
+ var r = anchor.getBoundingClientRect();
1442
+ var gap = opts.gap != null ? opts.gap : 4;
1443
+ el.style.maxHeight = "";
1444
+ var w = el.offsetWidth;
1445
+ var h = el.offsetHeight;
1446
+ var vw = window.innerWidth;
1447
+ var vh = window.innerHeight;
1448
+ var below = vh - r.bottom - gap - TOP_MARGIN;
1449
+ var above = r.top - gap - TOP_MARGIN;
1450
+ var up = h > below && above > below;
1451
+ var room = Math.max(80, up ? above : below);
1452
+ if (h > room) {
1453
+ el.style.maxHeight = room + "px";
1454
+ h = room;
1455
+ }
1456
+ var top = up ? r.top - gap - h : r.bottom + gap;
1457
+ var left = opts.align === "right" ? r.right - w : r.left;
1458
+ left = Math.max(TOP_MARGIN, Math.min(left, vw - w - TOP_MARGIN));
1459
+ // Important, so no page or UA rule (a popover's inset) moves it.
1460
+ el.style.setProperty("top", Math.max(TOP_MARGIN, top) + "px", "important");
1461
+ el.style.setProperty("left", left + "px", "important");
1462
+ }
1463
+ function topIndex(el) {
1464
+ for (var i = 0; i < topOpen.length; i++) if (topOpen[i].el === el) return i;
1465
+ return -1;
1466
+ }
1467
+ function topShow(el, anchor, opts) {
1468
+ if (!el || !anchor) return;
1469
+ opts = opts || {};
1470
+ var i = topIndex(el);
1471
+ if (i >= 0) {
1472
+ topOpen[i].anchor = anchor;
1473
+ topOpen[i].opts = opts;
1474
+ } else {
1475
+ if (topSupported(el)) {
1476
+ if (!el.hasAttribute("popover")) {
1477
+ el.setAttribute("popover", "manual");
1478
+ el.setAttribute("data-den-top-added", "");
1479
+ }
1480
+ try {
1481
+ if (!el.matches(":popover-open")) el.showPopover();
1482
+ } catch (e) {
1483
+ /* not in the document yet: placed as fixed */
1484
+ }
1485
+ }
1486
+ var o = { el: el, anchor: anchor, opts: opts, mo: null };
1487
+ if (typeof MutationObserver === "function") {
1488
+ o.mo = new MutationObserver(function () {
1489
+ topCheck(o);
1490
+ });
1491
+ o.mo.observe(el, {
1492
+ attributes: true,
1493
+ attributeFilter: ["style", "class", "hidden"],
1494
+ });
1495
+ }
1496
+ topOpen.push(o);
1497
+ }
1498
+ el.classList.add("den-toplayer");
1499
+ topPlace(el, anchor, opts);
1500
+ }
1501
+ function topHide(el) {
1502
+ var i = topIndex(el);
1503
+ if (i < 0) return;
1504
+ var o = topOpen.splice(i, 1)[0];
1505
+ if (o.mo) o.mo.disconnect();
1506
+ try {
1507
+ if (el.matches && el.matches(":popover-open")) el.hidePopover();
1508
+ } catch (e) {
1509
+ /* already gone */
1510
+ }
1511
+ if (el.hasAttribute("data-den-top-added")) {
1512
+ el.removeAttribute("popover");
1513
+ el.removeAttribute("data-den-top-added");
1514
+ }
1515
+ el.classList.remove("den-toplayer");
1516
+ el.style.removeProperty("top");
1517
+ el.style.removeProperty("left");
1518
+ }
1519
+ /** The page hid it, or its anchor went: it's closed. */
1520
+ function topCheck(o) {
1521
+ if (
1522
+ !o.el.isConnected ||
1523
+ !o.anchor.isConnected ||
1524
+ o.el.hidden ||
1525
+ getComputedStyle(o.el).display === "none"
1526
+ )
1527
+ topHide(o.el);
1528
+ }
1529
+ function topReplace() {
1530
+ topOpen.slice().forEach(function (o) {
1531
+ topCheck(o);
1532
+ if (topIndex(o.el) >= 0) topPlace(o.el, o.anchor, o.opts);
1533
+ });
1534
+ }
1535
+ window.addEventListener("resize", topReplace);
1536
+ document.addEventListener("scroll", topReplace, true);
1537
+
1414
1538
  // ── menus (.pop, R18): the scope parts' popovers ─────────────────────
1415
1539
  // A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
1416
1540
  // checked?, href?, onSelect? }] }. Rows with `checked` are radios (✓ when
@@ -1422,6 +1546,7 @@
1422
1546
  if (!openPop) return;
1423
1547
  var o = openPop;
1424
1548
  openPop = null;
1549
+ topHide(o.el);
1425
1550
  if (o.el.remove) o.el.remove();
1426
1551
  o.anchor.setAttribute("aria-expanded", "false");
1427
1552
  // aria-controls names the menu only while it exists.
@@ -1497,15 +1622,14 @@
1497
1622
  }
1498
1623
  });
1499
1624
  pop.innerHTML = html;
1500
- document.body.appendChild(pop);
1625
+ // Inside a modal, in it: anything outside a modal is inert.
1626
+ var host =
1627
+ (anchor.closest && anchor.closest("dialog[open]")) || document.body;
1628
+ host.appendChild(pop);
1501
1629
  openPop = { el: pop, anchor: anchor };
1502
1630
  anchor.setAttribute("aria-expanded", "true");
1503
1631
  anchor.setAttribute("aria-controls", pop.id);
1504
- var r = anchor.getBoundingClientRect();
1505
- var w = pop.offsetWidth;
1506
- pop.style.top = r.bottom + 6 + "px";
1507
- pop.style.left =
1508
- Math.max(8, Math.min(r.left, window.innerWidth - w - 8)) + "px";
1632
+ topShow(pop, anchor, { gap: 6 });
1509
1633
  pop.addEventListener("click", function (e) {
1510
1634
  var row = e.target.closest ? e.target.closest(".den-mi") : null;
1511
1635
  if (!row) return;
@@ -1943,7 +2067,14 @@
1943
2067
  * a link to manage them. onSelect(value, focus): choosing a workspace
1944
2068
  * also makes it the CLI default (header.js).
1945
2069
  */
2070
+ // Every workspace's name, from the Workspace menu's model (header.js).
2071
+ var workspaceNameList = [];
2072
+ function workspaceNames() {
2073
+ return workspaceNameList.slice();
2074
+ }
1946
2075
  function setWorkspaces(model) {
2076
+ workspaceNameList =
2077
+ model && Array.isArray(model.names) ? model.names.slice() : [];
1947
2078
  var btn = document.getElementById("ws-pill");
1948
2079
  if (!btn) return;
1949
2080
  var sep = btn.nextElementSibling;
@@ -4377,6 +4508,7 @@
4377
4508
  setOrg: setOrg,
4378
4509
  setOrgs: setOrgs,
4379
4510
  setWorkspaces: setWorkspaces,
4511
+ workspaceNames: workspaceNames,
4380
4512
  /** Repaint the Env part from #env-sel (after setting its value). */
4381
4513
  syncEnv: syncEnv,
4382
4514
  syncPinToggles: syncPinToggles,
@@ -4385,6 +4517,12 @@
4385
4517
  commands: commands,
4386
4518
  /** A .pop menu below `anchor` (the model as the scope menus'). */
4387
4519
  openMenu: openMenu,
4520
+ /**
4521
+ * The top layer (0.4.17): show(el, anchor, { align: "left"|"right",
4522
+ * gap }) puts a page's own menu above everything, placed by its
4523
+ * anchor; hide(el). The page hiding it (display: none) closes it too.
4524
+ */
4525
+ topLayer: { show: topShow, hide: topHide },
4388
4526
  closeMenu: function () {
4389
4527
  closeMenu(false);
4390
4528
  },
@@ -4544,6 +4682,13 @@
4544
4682
  " max-height: calc(100vh - 72px); overflow-y: auto; box-sizing: border-box;",
4545
4683
  " font: 14px/1.45 var(--sans); color: var(--text); }",
4546
4684
  ".den-pop:focus { outline: none; }",
4685
+ // The top layer (0.4.17): a page's menu placed by its anchor (topShow), not
4686
+ // where the UA puts a popover.
4687
+ // No inset here: top and left are topPlace's (an !important inset beat them,
4688
+ // and every menu sat at the window's corner, 0.4.17-dev.0).
4689
+ ".den-toplayer { position: fixed !important; margin: 0 !important;",
4690
+ " right: auto !important; bottom: auto !important; z-index: 1000; overflow-y: auto;",
4691
+ " color: var(--text); }",
4547
4692
  // LK-1: the admin-password prompt is a <dialog> in the top layer (a popover, or
4548
4693
  // a modal over another modal): placed by its top/left, no UA box.
4549
4694
  "dialog.den-pop { margin: 0; inset: auto; max-width: none; }",