@grifhinz/logics-manager 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,58 @@
1
1
  (() => {
2
2
  const stateKey = "logics.localViewer.state";
3
3
  const preferenceKey = "logics.localViewer.preferences.v1";
4
+ const lanTokenKey = "logics.lan.token";
5
+
6
+ function captureLanTokenFromUrl() {
7
+ try {
8
+ const url = new URL(window.location.href);
9
+ const queryToken = url.searchParams.get("t");
10
+ if (queryToken) {
11
+ window.sessionStorage.setItem(lanTokenKey, queryToken);
12
+ url.searchParams.delete("t");
13
+ const cleaned = `${url.pathname}${url.search}${url.hash}`;
14
+ window.history.replaceState(null, "", cleaned || "/");
15
+ }
16
+ } catch {
17
+ // sessionStorage / history may be unavailable in some embed contexts.
18
+ }
19
+ }
20
+
21
+ function getLanToken() {
22
+ try {
23
+ return window.sessionStorage.getItem(lanTokenKey) || "";
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ captureLanTokenFromUrl();
30
+
31
+ const originalFetch = window.fetch.bind(window);
32
+ window.fetch = (input, init) => {
33
+ const token = getLanToken();
34
+ if (!token) return originalFetch(input, init);
35
+ const next = init ? { ...init } : {};
36
+ const headers = new Headers(next.headers || (input instanceof Request ? input.headers : undefined));
37
+ if (!headers.has("Authorization")) headers.set("Authorization", `Bearer ${token}`);
38
+ next.headers = headers;
39
+ return originalFetch(input, next);
40
+ };
41
+
42
+ if (typeof window.EventSource === "function") {
43
+ const NativeEventSource = window.EventSource;
44
+ window.EventSource = function PatchedEventSource(url, init) {
45
+ const token = getLanToken();
46
+ if (!token || typeof url !== "string") {
47
+ return new NativeEventSource(url, init);
48
+ }
49
+ const separator = url.includes("?") ? "&" : "?";
50
+ const tokenized = `${url}${separator}t=${encodeURIComponent(token)}`;
51
+ return new NativeEventSource(tokenized, init);
52
+ };
53
+ window.EventSource.prototype = NativeEventSource.prototype;
54
+ }
55
+
4
56
  const preferenceVersion = 1;
5
57
  const meta = () => document.getElementById("viewer-meta");
6
58
  const documentPanel = () => document.getElementById("viewer-document");
@@ -19,6 +71,7 @@
19
71
  const repoGithubLink = () => document.getElementById("viewer-repo-github");
20
72
  const repoFolderButton = () => document.getElementById("viewer-repo-folder");
21
73
  const workspaceButton = () => document.getElementById("viewer-workspace");
74
+ const workshopButton = () => document.getElementById("viewer-workshop");
22
75
  const ciButton = () => document.getElementById("viewer-ci");
23
76
  const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
24
77
  const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
@@ -681,11 +734,64 @@
681
734
  setMeta(created > 0 ? `Logics bootstrapped · ${created} paths created.` : "Logics bootstrap checked.");
682
735
  }
683
736
 
737
+ let latestLanShareUrl = "";
738
+
739
+ async function copyTextToClipboard(text) {
740
+ if (!text) return false;
741
+ try {
742
+ if (navigator.clipboard && window.isSecureContext) {
743
+ await navigator.clipboard.writeText(text);
744
+ return true;
745
+ }
746
+ } catch { /* fall through to legacy path */ }
747
+ try {
748
+ const textarea = document.createElement("textarea");
749
+ textarea.value = text;
750
+ textarea.setAttribute("readonly", "");
751
+ textarea.style.position = "fixed";
752
+ textarea.style.top = "0";
753
+ textarea.style.left = "0";
754
+ textarea.style.opacity = "0";
755
+ textarea.style.pointerEvents = "none";
756
+ document.body.appendChild(textarea);
757
+ textarea.focus();
758
+ textarea.select();
759
+ textarea.setSelectionRange(0, text.length);
760
+ const ok = document.execCommand("copy");
761
+ document.body.removeChild(textarea);
762
+ return ok;
763
+ } catch {
764
+ return false;
765
+ }
766
+ }
767
+
768
+ function applyLanBanner(active, shareUrl) {
769
+ const banner = document.getElementById("viewer-lan-banner");
770
+ if (!(banner instanceof HTMLElement)) return;
771
+ banner.hidden = !active;
772
+ latestLanShareUrl = active ? String(shareUrl || "") : "";
773
+ const urlNode = document.getElementById("viewer-lan-banner-url");
774
+ const copyButton = document.getElementById("viewer-lan-banner-copy");
775
+ if (urlNode instanceof HTMLElement) {
776
+ if (latestLanShareUrl) {
777
+ urlNode.hidden = false;
778
+ urlNode.textContent = latestLanShareUrl;
779
+ } else {
780
+ urlNode.hidden = true;
781
+ urlNode.textContent = "";
782
+ }
783
+ }
784
+ if (copyButton instanceof HTMLButtonElement) {
785
+ copyButton.hidden = !latestLanShareUrl;
786
+ }
787
+ }
788
+
684
789
  function normalizeCapabilities(payload) {
685
790
  const capabilities = payload?.capabilities && typeof payload.capabilities === "object" ? payload.capabilities : {};
686
791
  return {
687
792
  logics: capabilities.logics || { state: "ready", available: true, message: "" },
688
793
  workspace: capabilities.workspace || { state: "ready", available: true, message: "" },
794
+ workshop: capabilities.workshop || { state: "missing", available: false, message: "" },
689
795
  git: capabilities.git || { state: "ready", available: true, message: "" },
690
796
  ci: capabilities.ci || { state: "ready", available: true, message: "" },
691
797
  cdx: capabilities.cdx || { state: "ready", available: true, message: "" },
@@ -734,6 +840,19 @@
734
840
  }
735
841
  }
736
842
 
843
+ const workshop = workshopButton();
844
+ if (workshop instanceof HTMLElement) {
845
+ const workshopAvailable = isCapabilityAvailable("workshop");
846
+ workshop.hidden = !workshopAvailable;
847
+ if (workshopAvailable) {
848
+ setButtonAvailable(workshop, "Show Workshop (terminals and commands)");
849
+ } else {
850
+ setButtonUnavailable(workshop, capabilityMessage("workshop", "Workshop is not available for this project."));
851
+ }
852
+ updateWorkshopBadges();
853
+ hydrateWorkshopTerminals();
854
+ }
855
+
737
856
  const gitButton = document.getElementById("viewer-git");
738
857
  if (gitButton instanceof HTMLElement) {
739
858
  gitButton.hidden = !isCapabilityAvailable("git");
@@ -843,6 +962,42 @@
843
962
  return html ? `<span class="viewer-git-badges" data-viewer-git-badges="${escapeHtml(scope)}">${html}</span>` : "";
844
963
  }
845
964
 
965
+ let workshopBadgeCounts = { terminals: 0, commands: 0 };
966
+
967
+ function updateWorkshopBadges() {
968
+ const button = document.getElementById("viewer-workshop");
969
+ if (!(button instanceof HTMLElement)) return;
970
+ button.querySelector('[data-viewer-workshop-badges]')?.remove();
971
+ const { terminals, commands } = workshopBadgeCounts;
972
+ if (terminals <= 0 && commands <= 0) return;
973
+ const html = [
974
+ terminals > 0
975
+ ? `<span class="viewer-git-badge viewer-git-badge--commits" title="${escapeHtml(terminals + " terminal session(s) running")}" aria-label="${escapeHtml(terminals + " terminal session(s) running")}">${escapeHtml(String(terminals))}</span>`
976
+ : "",
977
+ commands > 0
978
+ ? `<span class="viewer-git-badge viewer-git-badge--files" title="${escapeHtml(commands + " command(s) running")}" aria-label="${escapeHtml(commands + " command(s) running")}">${escapeHtml(String(commands))}</span>`
979
+ : "",
980
+ ].filter(Boolean).join("");
981
+ if (html) {
982
+ button.insertAdjacentHTML("beforeend", `<span class="viewer-git-badges" data-viewer-workshop-badges>${html}</span>`);
983
+ }
984
+ }
985
+
986
+ function recomputeWorkshopBadges() {
987
+ const isRunning = (state) => state === "running" || state === "starting";
988
+ let terminals = 0;
989
+ for (const entry of workshopTerminalState.sessions.values()) {
990
+ if (isRunning(entry.state)) terminals += 1;
991
+ }
992
+ let commands = 0;
993
+ for (const entry of workshopCommandState.sessions.values()) {
994
+ if (isRunning(entry.state)) commands += 1;
995
+ }
996
+ if (workshopBadgeCounts.terminals === terminals && workshopBadgeCounts.commands === commands) return;
997
+ workshopBadgeCounts = { terminals, commands };
998
+ updateWorkshopBadges();
999
+ }
1000
+
846
1001
  function updateMainGitBadges() {
847
1002
  const button = document.getElementById("viewer-git");
848
1003
  if (!(button instanceof HTMLElement)) {
@@ -1346,6 +1501,7 @@
1346
1501
  }
1347
1502
  updateRepositoryIdentity(payload);
1348
1503
  latestCapabilities = normalizeCapabilities(payload);
1504
+ applyLanBanner(Boolean(payload?.lanMode), String(payload?.lanShareUrl || ""));
1349
1505
  updateCapabilityControls();
1350
1506
  const payloadWithActivity = { ...payload, items: latestItems };
1351
1507
  const nextPayload = applyFocusRequest(payloadWithActivity, { silent: Boolean(options.silent) });
@@ -2177,14 +2333,42 @@
2177
2333
  return parts.join("/");
2178
2334
  }
2179
2335
 
2336
+ function renderWorkspaceBreadcrumb(currentPath) {
2337
+ const segments = String(currentPath || "").split("/").filter(Boolean);
2338
+ const crumbs = [
2339
+ `<button class="viewer-workspace__crumb" type="button" data-viewer-workspace-tree="" title="Workspace root">/</button>`,
2340
+ ];
2341
+ let accum = "";
2342
+ segments.forEach((segment, idx) => {
2343
+ accum = accum ? `${accum}/${segment}` : segment;
2344
+ const isLast = idx === segments.length - 1;
2345
+ crumbs.push(`<span class="viewer-workspace__crumb-sep" aria-hidden="true">/</span>`);
2346
+ crumbs.push(
2347
+ `<button class="viewer-workspace__crumb${isLast ? " is-current" : ""}" type="button" data-viewer-workspace-tree="${escapeHtml(accum)}" title="${escapeHtml(accum)}"${isLast ? ' aria-current="location"' : ""}>${escapeHtml(segment)}</button>`,
2348
+ );
2349
+ });
2350
+ return `<nav class="viewer-workspace__breadcrumb" aria-label="Workspace breadcrumb">${crumbs.join("")}</nav>`;
2351
+ }
2352
+
2353
+ function workspaceEntryIcon(kind, ignored) {
2354
+ if (kind === "directory") {
2355
+ return ignored
2356
+ ? '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M2 4h4l1 1h7v8H2V4Zm9.5 3.2L9.7 9l1.8 1.8-.7.7L9 9.7l-1.8 1.8-.7-.7L8.3 9 6.5 7.2l.7-.7L9 8.3l1.8-1.8.7.7Z"/></svg>'
2357
+ : '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M2 4h4l1 1h7v8H2V4Z"/></svg>';
2358
+ }
2359
+ return '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M4 2h6l3 3v9H4V2Zm6 0v3h3"/></svg>';
2360
+ }
2361
+
2180
2362
  function renderWorkspaceTree(treePayload, selectedPath = "") {
2181
2363
  if (!treePayload || treePayload.state !== "ok") {
2182
- return `<div class="viewer-workspace__empty">${escapeHtml(treePayload?.message || "Workspace tree is unavailable.")}</div>`;
2364
+ const message = treePayload?.message || "Workspace tree is unavailable.";
2365
+ const state = treePayload?.state === "unavailable" ? "unavailable" : "empty";
2366
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--${state}"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">${state === "unavailable" ? "!" : "·"}</span><span>${escapeHtml(message)}</span></div>`;
2183
2367
  }
2184
2368
  const currentPath = String(treePayload.path || "");
2185
2369
  const parentPath = workspaceParentPath(currentPath);
2186
2370
  const upButton = currentPath
2187
- ? `<button class="viewer-workspace__item viewer-workspace__item--up" type="button" data-viewer-workspace-tree="${escapeHtml(parentPath)}">..</button>`
2371
+ ? `<button class="viewer-workspace__item viewer-workspace__item--up" type="button" data-viewer-workspace-tree="${escapeHtml(parentPath)}" title="Parent directory"><span class="viewer-workspace__item-icon" aria-hidden="true"><svg viewBox="0 0 16 16" focusable="false"><path fill="currentColor" d="M8 3 3 8h3v5h4V8h3L8 3Z"/></svg></span><span class="viewer-workspace__item-name">..</span></button>`
2188
2372
  : "";
2189
2373
  const rows = (Array.isArray(treePayload.entries) ? treePayload.entries : []).map((entry) => {
2190
2374
  const path = String(entry.path || "");
@@ -2194,29 +2378,34 @@
2194
2378
  const actionAttr = kind === "directory" && !ignored
2195
2379
  ? `data-viewer-workspace-tree="${escapeHtml(path)}"`
2196
2380
  : `data-viewer-workspace-preview="${escapeHtml(path)}"`;
2197
- const icon = kind === "directory" ? (ignored ? "x" : ">") : "-";
2381
+ const classes = [
2382
+ "viewer-workspace__item",
2383
+ `viewer-workspace__item--${kind === "directory" ? "directory" : "file"}`,
2384
+ ];
2385
+ if (selected) classes.push("is-selected");
2386
+ if (ignored) classes.push("is-muted");
2198
2387
  return `
2199
- <button class="viewer-workspace__item${selected ? " is-selected" : ""}${ignored ? " is-muted" : ""}" type="button" ${actionAttr} title="${escapeHtml(path)}">
2200
- <span class="viewer-workspace__item-icon" aria-hidden="true">${escapeHtml(icon)}</span>
2388
+ <button class="${classes.join(" ")}" type="button" ${actionAttr} title="${escapeHtml(path)}"${selected ? ' aria-current="true"' : ""}>
2389
+ <span class="viewer-workspace__item-icon" aria-hidden="true">${workspaceEntryIcon(kind, ignored)}</span>
2201
2390
  <span class="viewer-workspace__item-name">${escapeHtml(entry.name || path || "/")}</span>
2202
2391
  </button>
2203
2392
  `;
2204
2393
  }).join("");
2205
2394
  return `
2206
2395
  <div class="viewer-workspace__tree-header">
2207
- <span>${escapeHtml(currentPath || "/")}</span>
2396
+ ${renderWorkspaceBreadcrumb(currentPath)}
2208
2397
  </div>
2209
- <div class="viewer-workspace__tree-list">
2398
+ <div class="viewer-workspace__tree-list" role="list">
2210
2399
  ${upButton}
2211
- ${rows || '<div class="viewer-workspace__empty">Directory is empty.</div>'}
2400
+ ${rows || '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Directory is empty.</span></div>'}
2212
2401
  </div>
2213
- ${treePayload.truncated ? '<div class="viewer-workspace__empty">Directory listing truncated.</div>' : ""}
2402
+ ${treePayload.truncated ? '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--warn"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>Directory listing truncated.</span></div>' : ""}
2214
2403
  `;
2215
2404
  }
2216
2405
 
2217
2406
  function renderWorkspacePreview(previewPayload) {
2218
2407
  if (!previewPayload) {
2219
- return '<div class="viewer-workspace__empty">Select a file or directory.</div>';
2408
+ return '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Select a file or directory.</span></div>';
2220
2409
  }
2221
2410
  const path = previewPayload.path || "/";
2222
2411
  const name = previewPayload.name || path || "/";
@@ -2227,7 +2416,7 @@
2227
2416
  <div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(path)}</span></div>
2228
2417
  <em>${escapeHtml(previewPayload.truncated ? "truncated" : `${previewPayload.size || 0} bytes`)}</em>
2229
2418
  </div>
2230
- ${previewPayload.truncated ? '<div class="viewer-cdx__state viewer-cdx__state--warn">Preview truncated.</div>' : ""}
2419
+ ${previewPayload.truncated ? '<div class="viewer-workspace__placeholder viewer-workspace__placeholder--warn"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>Preview truncated.</span></div>' : ""}
2231
2420
  <pre class="viewer-workspace__code">${escapeHtml(previewPayload.content || "")}</pre>
2232
2421
  `;
2233
2422
  }
@@ -2240,12 +2429,14 @@
2240
2429
  <img class="viewer-workspace__image" src="/api/workspace-file?path=${encodeURIComponent(path)}" alt="${escapeHtml(name)}">
2241
2430
  `;
2242
2431
  }
2432
+ const placeholderState = state === "unavailable" ? "unavailable" : "empty";
2433
+ const placeholderIcon = placeholderState === "unavailable" ? "!" : "·";
2243
2434
  return `
2244
2435
  <div class="viewer-workspace__preview-header">
2245
2436
  <div><strong>${escapeHtml(name)}</strong><span>${escapeHtml(path)}</span></div>
2246
2437
  <em>${escapeHtml(state)}</em>
2247
2438
  </div>
2248
- <div class="viewer-workspace__empty">${escapeHtml(previewPayload.message || "No preview is available.")}</div>
2439
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--${placeholderState}"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">${placeholderIcon}</span><span>${escapeHtml(previewPayload.message || "No preview is available.")}</span></div>
2249
2440
  `;
2250
2441
  }
2251
2442
 
@@ -2297,6 +2488,630 @@
2297
2488
  setMeta(options.silent ? "Explorer refreshed." : "Explorer loaded.");
2298
2489
  }
2299
2490
 
2491
+ const workshopTabs = [
2492
+ { id: "terminals", label: "Terminals", title: "In-app PTY terminals" },
2493
+ { id: "commands", label: "Commands", title: "Discovered package and project scripts" },
2494
+ ];
2495
+
2496
+ function preferredWorkshopTab() {
2497
+ const stored = String(viewerPreferences.workshopActiveTab || "");
2498
+ return workshopTabs.some((tab) => tab.id === stored) ? stored : "terminals";
2499
+ }
2500
+
2501
+ function setWorkshopActiveTab(tabId) {
2502
+ const next = workshopTabs.some((tab) => tab.id === tabId) ? tabId : "terminals";
2503
+ if (next === viewerPreferences.workshopActiveTab) return;
2504
+ updateViewerPreferences({ workshopActiveTab: next });
2505
+ }
2506
+
2507
+ function renderWorkshopTabs(activeTab) {
2508
+ const buttons = workshopTabs.map((tab) => {
2509
+ const isActive = tab.id === activeTab;
2510
+ return `<button class="viewer-cdx__mode${isActive ? " is-active" : ""}" type="button" role="tab" aria-selected="${isActive ? "true" : "false"}" data-viewer-workshop-tab="${escapeHtml(tab.id)}" title="${escapeHtml(tab.title)}">${escapeHtml(tab.label)}</button>`;
2511
+ }).join("");
2512
+ return `<div class="viewer-cdx__modes" role="tablist" aria-label="Workshop sub-screens">${buttons}</div>`;
2513
+ }
2514
+
2515
+ function renderWorkshopPanel(tabId) {
2516
+ if (tabId === "commands") {
2517
+ return `
2518
+ <div class="viewer-workshop__panel" role="tabpanel" data-viewer-workshop-panel="commands" data-viewer-workshop-commands>
2519
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty">
2520
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span>
2521
+ <span>Discovering commands...</span>
2522
+ </div>
2523
+ </div>
2524
+ `;
2525
+ }
2526
+ const terminalsAvailable = Boolean(capability("workshop").detail?.terminalsAvailable);
2527
+ if (!terminalsAvailable) {
2528
+ return `
2529
+ <div class="viewer-workshop__panel viewer-workshop__panel--terminals" role="tabpanel" data-viewer-workshop-panel="terminals">
2530
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable">
2531
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span>
2532
+ <span>In-app terminals require a Unix host with stdlib pty support (macOS or Linux). Use the Commands tab to run discovered scripts in the meantime.</span>
2533
+ </div>
2534
+ </div>
2535
+ `;
2536
+ }
2537
+ return `
2538
+ <div class="viewer-workshop__panel viewer-workshop__panel--terminals-active" role="tabpanel" data-viewer-workshop-panel="terminals">
2539
+ <aside class="viewer-workshop__terminal-list" data-viewer-workshop-terminal-list aria-label="Terminal sessions"></aside>
2540
+ <section class="viewer-workshop__terminal-stage" data-viewer-workshop-terminal-stage>
2541
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty" data-viewer-workshop-terminal-empty>
2542
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span>
2543
+ <span>No terminal session yet. Click "New terminal" to spawn one.</span>
2544
+ </div>
2545
+ </section>
2546
+ </div>
2547
+ `;
2548
+ }
2549
+
2550
+ const workshopCommandState = {
2551
+ catalog: null,
2552
+ sessions: new Map(),
2553
+ streams: new Map(),
2554
+ };
2555
+
2556
+ function renderWorkshopCommandRow(entry) {
2557
+ const session = workshopCommandState.sessions.get(entry.id) || null;
2558
+ const state = session?.state || "idle";
2559
+ const running = state === "running" || state === "starting";
2560
+ const exitBadge = session && session.exitCode !== null && session.exitCode !== undefined
2561
+ ? `<span class="viewer-workshop__exit viewer-workshop__exit--${session.exitCode === 0 ? "ok" : "fail"}">exit ${escapeHtml(String(session.exitCode))}</span>`
2562
+ : "";
2563
+ return `
2564
+ <li class="viewer-workshop__command" data-viewer-workshop-command="${escapeHtml(entry.id)}">
2565
+ <div class="viewer-workshop__command-header">
2566
+ <div class="viewer-workshop__command-name">
2567
+ <strong>${escapeHtml(entry.name)}</strong>
2568
+ <span class="viewer-workshop__command-source">${escapeHtml(entry.source)}</span>
2569
+ </div>
2570
+ <div class="viewer-workshop__command-actions">
2571
+ <span class="viewer-workshop__state viewer-workshop__state--${escapeHtml(state)}">${escapeHtml(state)}</span>
2572
+ ${exitBadge}
2573
+ ${running
2574
+ ? `<button class="btn" type="button" data-viewer-workshop-command-stop="${escapeHtml(entry.id)}">Stop</button>`
2575
+ : `<button class="btn" type="button" data-viewer-workshop-command-run="${escapeHtml(entry.id)}">Run</button>`}
2576
+ </div>
2577
+ </div>
2578
+ <div class="viewer-workshop__command-meta"><code>${escapeHtml(entry.command)}</code></div>
2579
+ <pre class="viewer-workshop__log" data-viewer-workshop-command-log="${escapeHtml(entry.id)}" aria-live="polite">${escapeHtml(session?.logText || "")}</pre>
2580
+ </li>
2581
+ `;
2582
+ }
2583
+
2584
+ function renderWorkshopCommandList(catalog) {
2585
+ if (!catalog || catalog.state === "unavailable") {
2586
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span><span>${escapeHtml(catalog?.message || "Commands are unavailable.")}</span></div>`;
2587
+ }
2588
+ const commands = Array.isArray(catalog.commands) ? catalog.commands : [];
2589
+ if (commands.length === 0) {
2590
+ return `<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>${escapeHtml(catalog.message || "No commands discovered.")}</span></div>`;
2591
+ }
2592
+ const groups = new Map();
2593
+ commands.forEach((entry) => {
2594
+ const group = entry.group || "Commands";
2595
+ if (!groups.has(group)) groups.set(group, []);
2596
+ groups.get(group).push(entry);
2597
+ });
2598
+ const sections = [...groups.entries()].map(([group, entries]) => `
2599
+ <section class="viewer-workshop__group">
2600
+ <h3 class="viewer-workshop__group-title">${escapeHtml(group)}</h3>
2601
+ <ul class="viewer-workshop__commands">
2602
+ ${entries.map(renderWorkshopCommandRow).join("")}
2603
+ </ul>
2604
+ </section>
2605
+ `).join("");
2606
+ return sections;
2607
+ }
2608
+
2609
+ function renderWorkshopCommands() {
2610
+ const container = document.querySelector("[data-viewer-workshop-commands]");
2611
+ if (!(container instanceof HTMLElement)) return;
2612
+ container.innerHTML = renderWorkshopCommandList(workshopCommandState.catalog);
2613
+ }
2614
+
2615
+ async function loadWorkshopCommands() {
2616
+ try {
2617
+ const response = await fetch("/api/workshop-commands");
2618
+ const data = await response.json();
2619
+ workshopCommandState.catalog = data?.payload || null;
2620
+ } catch (error) {
2621
+ workshopCommandState.catalog = { state: "unavailable", commands: [], message: String(error?.message || error) };
2622
+ }
2623
+ renderWorkshopCommands();
2624
+ }
2625
+
2626
+ function updateWorkshopCommandSession(commandId, patch) {
2627
+ const previous = workshopCommandState.sessions.get(commandId) || { logText: "" };
2628
+ workshopCommandState.sessions.set(commandId, { ...previous, ...patch });
2629
+ renderWorkshopCommands();
2630
+ recomputeWorkshopBadges();
2631
+ }
2632
+
2633
+ function appendWorkshopCommandLog(commandId, line) {
2634
+ const previous = workshopCommandState.sessions.get(commandId) || { logText: "" };
2635
+ const next = previous.logText ? `${previous.logText}\n${line}` : line;
2636
+ workshopCommandState.sessions.set(commandId, { ...previous, logText: next });
2637
+ const node = document.querySelector(`[data-viewer-workshop-command-log="${commandId}"]`);
2638
+ if (node instanceof HTMLElement) {
2639
+ node.textContent = next;
2640
+ node.scrollTop = node.scrollHeight;
2641
+ }
2642
+ }
2643
+
2644
+ function closeWorkshopCommandStream(commandId) {
2645
+ const stream = workshopCommandState.streams.get(commandId);
2646
+ if (stream) {
2647
+ try { stream.close(); } catch { /* noop */ }
2648
+ workshopCommandState.streams.delete(commandId);
2649
+ }
2650
+ }
2651
+
2652
+ async function startWorkshopCommand(commandId) {
2653
+ try {
2654
+ const response = await fetch("/api/workshop-command-start", {
2655
+ method: "POST",
2656
+ headers: { "Content-Type": "application/json" },
2657
+ body: JSON.stringify({ commandId }),
2658
+ });
2659
+ const data = await response.json();
2660
+ if (!response.ok || !data.ok) {
2661
+ throw new Error(data.error || "Unable to start command.");
2662
+ }
2663
+ const session = data.payload;
2664
+ updateWorkshopCommandSession(commandId, {
2665
+ sessionId: session.id,
2666
+ state: session.state,
2667
+ exitCode: session.exitCode,
2668
+ logText: "",
2669
+ });
2670
+ openWorkshopCommandStream(commandId, session.id);
2671
+ } catch (error) {
2672
+ updateWorkshopCommandSession(commandId, { state: "error", logText: `! ${error?.message || error}` });
2673
+ }
2674
+ }
2675
+
2676
+ function openWorkshopCommandStream(commandId, sessionId) {
2677
+ closeWorkshopCommandStream(commandId);
2678
+ const source = new EventSource(`/api/workshop-session/${encodeURIComponent(sessionId)}/stream`);
2679
+ workshopCommandState.streams.set(commandId, source);
2680
+ source.addEventListener("line", (event) => {
2681
+ try {
2682
+ const payload = JSON.parse(event.data || "{}");
2683
+ appendWorkshopCommandLog(commandId, String(payload.line || ""));
2684
+ } catch { /* noop */ }
2685
+ });
2686
+ source.addEventListener("end", (event) => {
2687
+ try {
2688
+ const payload = JSON.parse(event.data || "{}");
2689
+ updateWorkshopCommandSession(commandId, {
2690
+ state: payload.state,
2691
+ exitCode: payload.exitCode,
2692
+ });
2693
+ } catch { /* noop */ }
2694
+ closeWorkshopCommandStream(commandId);
2695
+ });
2696
+ source.addEventListener("error", () => {
2697
+ closeWorkshopCommandStream(commandId);
2698
+ });
2699
+ }
2700
+
2701
+ async function stopWorkshopCommand(commandId) {
2702
+ const session = workshopCommandState.sessions.get(commandId);
2703
+ if (!session?.sessionId) return;
2704
+ try {
2705
+ await fetch("/api/workshop-command-stop", {
2706
+ method: "POST",
2707
+ headers: { "Content-Type": "application/json" },
2708
+ body: JSON.stringify({ sessionId: session.sessionId }),
2709
+ });
2710
+ } catch { /* noop */ }
2711
+ }
2712
+
2713
+ const workshopTerminalState = {
2714
+ sessions: new Map(),
2715
+ activeId: "",
2716
+ streams: new Map(),
2717
+ hydrated: false,
2718
+ };
2719
+
2720
+ async function hydrateWorkshopTerminals() {
2721
+ if (workshopTerminalState.hydrated) return;
2722
+ if (!isCapabilityAvailable("workshop")) return;
2723
+ if (!capability("workshop").detail?.terminalsAvailable) return;
2724
+ workshopTerminalState.hydrated = true;
2725
+ try {
2726
+ const response = await fetch("/api/workshop-terminals");
2727
+ const data = await response.json();
2728
+ const sessions = Array.isArray(data?.payload?.sessions) ? data.payload.sessions : [];
2729
+ for (const remote of sessions) {
2730
+ const id = String(remote?.id || "");
2731
+ if (!id) continue;
2732
+ if (workshopTerminalState.sessions.has(id)) continue;
2733
+ const state = String(remote?.state || "");
2734
+ // Only restore live sessions; the server reaps stopped/failed ones via TTL.
2735
+ if (state !== "running" && state !== "starting") continue;
2736
+ workshopTerminalState.sessions.set(id, {
2737
+ id,
2738
+ label: String(remote?.label || "shell"),
2739
+ state,
2740
+ bufferedOutput: "",
2741
+ });
2742
+ }
2743
+ if (!workshopTerminalState.activeId) {
2744
+ const next = workshopTerminalState.sessions.keys().next();
2745
+ workshopTerminalState.activeId = next.done ? "" : next.value;
2746
+ }
2747
+ recomputeWorkshopBadges();
2748
+ } catch {
2749
+ workshopTerminalState.hydrated = false;
2750
+ }
2751
+ }
2752
+
2753
+ function workshopTerminalListNode() {
2754
+ return document.querySelector("[data-viewer-workshop-terminal-list]");
2755
+ }
2756
+
2757
+ function workshopTerminalStageNode() {
2758
+ return document.querySelector("[data-viewer-workshop-terminal-stage]");
2759
+ }
2760
+
2761
+ function renderWorkshopTerminalList() {
2762
+ const node = workshopTerminalListNode();
2763
+ if (!(node instanceof HTMLElement)) return;
2764
+ const entries = [...workshopTerminalState.sessions.values()];
2765
+ const header = `<div class="viewer-workshop__terminal-list-header">
2766
+ <span>Terminals</span>
2767
+ <span class="viewer-workshop__terminal-actions">
2768
+ <button class="btn viewer-workshop__terminal-new" type="button" data-viewer-workshop-terminal-new title="Spawn a shell session">+ Shell</button>
2769
+ <button class="btn viewer-workshop__terminal-new" type="button" data-viewer-workshop-terminal-custom title="Spawn a session with a custom command">+ Custom</button>
2770
+ </span>
2771
+ </div>`;
2772
+ if (entries.length === 0) {
2773
+ node.innerHTML = `${header}<div class="viewer-workspace__placeholder viewer-workspace__placeholder--empty"><span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>No sessions yet.</span></div>`;
2774
+ return;
2775
+ }
2776
+ const rows = entries.map((entry) => {
2777
+ const isActive = entry.id === workshopTerminalState.activeId;
2778
+ const stateBadge = entry.state ? `<span class="viewer-workshop__state viewer-workshop__state--${escapeHtml(entry.state)}">${escapeHtml(entry.state)}</span>` : "";
2779
+ const closing = Boolean(entry.closing);
2780
+ const closeAttrs = closing
2781
+ ? `aria-busy="true" aria-label="Closing session" title="Closing session..."`
2782
+ : `data-viewer-workshop-terminal-close="${escapeHtml(entry.id)}" role="button" tabindex="0" title="Close session" aria-label="Close session"`;
2783
+ const closeGlyph = closing
2784
+ ? `<span class="viewer-workshop__spinner" aria-hidden="true"></span>`
2785
+ : `×`;
2786
+ return `<button class="viewer-workshop__terminal-row${isActive ? " is-active" : ""}${closing ? " is-closing" : ""}" type="button" data-viewer-workshop-terminal-select="${escapeHtml(entry.id)}" title="${escapeHtml(entry.label || entry.id)}">
2787
+ <span class="viewer-workshop__terminal-row-label">${escapeHtml(entry.label || entry.id)}</span>
2788
+ ${stateBadge}
2789
+ <span class="viewer-workshop__terminal-row-close${closing ? " is-closing" : ""}" ${closeAttrs}>${closeGlyph}</span>
2790
+ </button>`;
2791
+ }).join("");
2792
+ node.innerHTML = `${header}<div class="viewer-workshop__terminal-rows">${rows}</div>`;
2793
+ }
2794
+
2795
+ function ensureWorkshopTerminalStage() {
2796
+ const stage = workshopTerminalStageNode();
2797
+ if (!(stage instanceof HTMLElement)) return null;
2798
+ const active = workshopTerminalState.activeId
2799
+ ? workshopTerminalState.sessions.get(workshopTerminalState.activeId)
2800
+ : null;
2801
+ // Clean up placeholder and host elements for sessions that no longer exist.
2802
+ const placeholder = stage.querySelector("[data-viewer-workshop-terminal-empty]");
2803
+ if (placeholder) placeholder.remove();
2804
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2805
+ if (!(node instanceof HTMLElement)) return;
2806
+ const id = node.getAttribute("data-viewer-workshop-terminal-host") || "";
2807
+ if (!workshopTerminalState.sessions.has(id)) {
2808
+ node.remove();
2809
+ }
2810
+ });
2811
+ if (!active) {
2812
+ if (!stage.querySelector("[data-viewer-workshop-terminal-empty]")) {
2813
+ const empty = document.createElement("div");
2814
+ empty.className = "viewer-workspace__placeholder viewer-workspace__placeholder--empty";
2815
+ empty.setAttribute("data-viewer-workshop-terminal-empty", "");
2816
+ empty.innerHTML = '<span class="viewer-workspace__placeholder-icon" aria-hidden="true">·</span><span>Select or create a terminal session to start.</span>';
2817
+ stage.appendChild(empty);
2818
+ }
2819
+ // Hide every existing host while no session is active.
2820
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2821
+ if (node instanceof HTMLElement) node.style.display = "none";
2822
+ });
2823
+ return null;
2824
+ }
2825
+ // Toggle visibility: only the active host shows, every other host stays
2826
+ // mounted in the DOM so its xterm.js instance and scrollback survive.
2827
+ let host = stage.querySelector(`[data-viewer-workshop-terminal-host="${active.id}"]`);
2828
+ stage.querySelectorAll("[data-viewer-workshop-terminal-host]").forEach((node) => {
2829
+ if (!(node instanceof HTMLElement)) return;
2830
+ const id = node.getAttribute("data-viewer-workshop-terminal-host") || "";
2831
+ node.style.display = id === active.id ? "" : "none";
2832
+ });
2833
+ if (!(host instanceof HTMLElement)) {
2834
+ host = document.createElement("div");
2835
+ host.className = "viewer-workshop__terminal-host";
2836
+ host.setAttribute("data-viewer-workshop-terminal-host", active.id);
2837
+ stage.appendChild(host);
2838
+ }
2839
+ return host instanceof HTMLElement ? host : null;
2840
+ }
2841
+
2842
+ function ensureWorkshopTerminalHostFor(sessionId) {
2843
+ const stage = workshopTerminalStageNode();
2844
+ if (!(stage instanceof HTMLElement)) return null;
2845
+ const placeholder = stage.querySelector("[data-viewer-workshop-terminal-empty]");
2846
+ if (placeholder) placeholder.remove();
2847
+ let host = stage.querySelector(`[data-viewer-workshop-terminal-host="${sessionId}"]`);
2848
+ if (!(host instanceof HTMLElement)) {
2849
+ host = document.createElement("div");
2850
+ host.className = "viewer-workshop__terminal-host";
2851
+ host.setAttribute("data-viewer-workshop-terminal-host", sessionId);
2852
+ stage.appendChild(host);
2853
+ }
2854
+ return host;
2855
+ }
2856
+
2857
+ function mountWorkshopTerminalEmulator(entry) {
2858
+ if (typeof window.Terminal !== "function") return;
2859
+ if (entry.terminal) return;
2860
+ const host = ensureWorkshopTerminalHostFor(entry.id);
2861
+ if (!host) return;
2862
+ const term = new window.Terminal({
2863
+ fontSize: 12,
2864
+ fontFamily: 'var(--vscode-editor-font-family, "Menlo", "Consolas", monospace)',
2865
+ theme: { background: "#0a0a0a", foreground: "#d4d4d4" },
2866
+ cursorBlink: true,
2867
+ scrollback: 5000,
2868
+ convertEol: true,
2869
+ });
2870
+ const fitAddon = typeof window.FitAddon === "function"
2871
+ ? new window.FitAddon()
2872
+ : (window.FitAddon && typeof window.FitAddon.FitAddon === "function" ? new window.FitAddon.FitAddon() : null);
2873
+ const linksAddon = window.WebLinksAddon && typeof window.WebLinksAddon.WebLinksAddon === "function"
2874
+ ? new window.WebLinksAddon.WebLinksAddon()
2875
+ : null;
2876
+ if (fitAddon) term.loadAddon(fitAddon);
2877
+ if (linksAddon) term.loadAddon(linksAddon);
2878
+ term.open(host);
2879
+ if (fitAddon) {
2880
+ try { fitAddon.fit(); } catch { /* noop */ }
2881
+ }
2882
+ term.onData((data) => {
2883
+ writeWorkshopTerminalInput(entry.id, data);
2884
+ });
2885
+ term.onResize((size) => {
2886
+ resizeWorkshopTerminal(entry.id, size.rows, size.cols);
2887
+ });
2888
+ entry.terminal = term;
2889
+ entry.fitAddon = fitAddon;
2890
+ if (fitAddon) {
2891
+ try {
2892
+ const dim = fitAddon.proposeDimensions();
2893
+ if (dim) resizeWorkshopTerminal(entry.id, dim.rows, dim.cols);
2894
+ } catch { /* noop */ }
2895
+ }
2896
+ openWorkshopTerminalStream(entry.id);
2897
+ if (entry.bufferedOutput) {
2898
+ term.write(entry.bufferedOutput);
2899
+ }
2900
+ }
2901
+
2902
+ function setActiveWorkshopTerminal(sessionId) {
2903
+ workshopTerminalState.activeId = sessionId || "";
2904
+ renderWorkshopTerminalList();
2905
+ const entry = sessionId ? workshopTerminalState.sessions.get(sessionId) : null;
2906
+ ensureWorkshopTerminalStage();
2907
+ if (entry) {
2908
+ mountWorkshopTerminalEmulator(entry);
2909
+ try { entry.terminal?.focus(); } catch { /* noop */ }
2910
+ try { entry.fitAddon?.fit(); } catch { /* noop */ }
2911
+ }
2912
+ }
2913
+
2914
+ async function spawnWorkshopTerminal(options = {}) {
2915
+ try {
2916
+ const body = {};
2917
+ if (Array.isArray(options.command) && options.command.length) body.command = options.command;
2918
+ if (options.label) body.label = String(options.label);
2919
+ const response = await fetch("/api/workshop-terminal-start", {
2920
+ method: "POST",
2921
+ headers: { "Content-Type": "application/json" },
2922
+ body: JSON.stringify(body),
2923
+ });
2924
+ const data = await response.json();
2925
+ if (!response.ok || !data.ok) throw new Error(data.error || "Unable to start terminal.");
2926
+ const session = data.payload;
2927
+ workshopTerminalState.sessions.set(session.id, {
2928
+ id: session.id,
2929
+ label: session.label || "shell",
2930
+ state: session.state,
2931
+ bufferedOutput: "",
2932
+ });
2933
+ recomputeWorkshopBadges();
2934
+ // Ensure the Workshop view is mounted before activating.
2935
+ if (preferredWorkshopTab() !== "terminals") {
2936
+ await showWorkshop({ tab: "terminals" });
2937
+ } else {
2938
+ await showWorkshop({ tab: "terminals" });
2939
+ }
2940
+ setActiveWorkshopTerminal(session.id);
2941
+ return session.id;
2942
+ } catch (error) {
2943
+ setMeta(`Terminal: ${error?.message || error}`);
2944
+ return "";
2945
+ }
2946
+ }
2947
+
2948
+ function spawnCustomWorkshopTerminal() {
2949
+ const raw = window.prompt("Command to run (space-separated, e.g. 'node --version'):", "");
2950
+ if (!raw) return;
2951
+ const command = raw.trim().split(/\s+/).filter(Boolean);
2952
+ if (!command.length) return;
2953
+ const label = command.slice(0, 2).join(" ").slice(0, 32) || "custom";
2954
+ spawnWorkshopTerminal({ command, label });
2955
+ }
2956
+
2957
+ // Public API for CDX / handoff launchers and other callers that want to
2958
+ // open a Workshop terminal pre-running a canonical command.
2959
+ window.logicsViewer = window.logicsViewer || {};
2960
+ window.logicsViewer.launchTerminal = (command, label) => spawnWorkshopTerminal({ command, label });
2961
+
2962
+ function writeWorkshopTerminalInput(sessionId, data) {
2963
+ if (!sessionId || !data) return;
2964
+ fetch("/api/workshop-terminal-input", {
2965
+ method: "POST",
2966
+ headers: { "Content-Type": "application/json" },
2967
+ body: JSON.stringify({ sessionId, data }),
2968
+ }).catch(() => { /* noop */ });
2969
+ }
2970
+
2971
+ function resizeWorkshopTerminal(sessionId, rows, cols) {
2972
+ if (!sessionId || rows <= 0 || cols <= 0) return;
2973
+ fetch("/api/workshop-terminal-resize", {
2974
+ method: "POST",
2975
+ headers: { "Content-Type": "application/json" },
2976
+ body: JSON.stringify({ sessionId, rows, cols }),
2977
+ }).catch(() => { /* noop */ });
2978
+ }
2979
+
2980
+ async function stopWorkshopTerminal(sessionId) {
2981
+ if (!sessionId) return;
2982
+ const pending = workshopTerminalState.sessions.get(sessionId);
2983
+ if (pending?.closing) return;
2984
+ if (pending) {
2985
+ pending.closing = true;
2986
+ renderWorkshopTerminalList();
2987
+ }
2988
+ try {
2989
+ await fetch("/api/workshop-terminal-stop", {
2990
+ method: "POST",
2991
+ headers: { "Content-Type": "application/json" },
2992
+ body: JSON.stringify({ sessionId }),
2993
+ });
2994
+ } catch { /* noop */ }
2995
+ closeWorkshopTerminalStream(sessionId);
2996
+ const entry = workshopTerminalState.sessions.get(sessionId);
2997
+ if (entry?.terminal) {
2998
+ try { entry.terminal.dispose(); } catch { /* noop */ }
2999
+ }
3000
+ workshopTerminalState.sessions.delete(sessionId);
3001
+ if (workshopTerminalState.activeId === sessionId) {
3002
+ const next = workshopTerminalState.sessions.keys().next();
3003
+ setActiveWorkshopTerminal(next.done ? "" : next.value);
3004
+ } else {
3005
+ renderWorkshopTerminalList();
3006
+ }
3007
+ recomputeWorkshopBadges();
3008
+ }
3009
+
3010
+ function closeWorkshopTerminalStream(sessionId) {
3011
+ const stream = workshopTerminalState.streams.get(sessionId);
3012
+ if (stream) {
3013
+ try { stream.close(); } catch { /* noop */ }
3014
+ workshopTerminalState.streams.delete(sessionId);
3015
+ }
3016
+ }
3017
+
3018
+ function openWorkshopTerminalStream(sessionId) {
3019
+ closeWorkshopTerminalStream(sessionId);
3020
+ const source = new EventSource(`/api/workshop-terminal/${encodeURIComponent(sessionId)}/stream`);
3021
+ workshopTerminalState.streams.set(sessionId, source);
3022
+ source.addEventListener("data", (event) => {
3023
+ try {
3024
+ const payload = JSON.parse(event.data || "{}");
3025
+ const chunk = String(payload.data || "");
3026
+ const entry = workshopTerminalState.sessions.get(sessionId);
3027
+ if (!entry) return;
3028
+ if (entry.terminal) {
3029
+ entry.terminal.write(chunk);
3030
+ } else {
3031
+ entry.bufferedOutput = (entry.bufferedOutput || "") + chunk;
3032
+ }
3033
+ } catch { /* noop */ }
3034
+ });
3035
+ source.addEventListener("end", (event) => {
3036
+ try {
3037
+ const payload = JSON.parse(event.data || "{}");
3038
+ const entry = workshopTerminalState.sessions.get(sessionId);
3039
+ if (entry) entry.state = payload.state;
3040
+ renderWorkshopTerminalList();
3041
+ recomputeWorkshopBadges();
3042
+ } catch { /* noop */ }
3043
+ closeWorkshopTerminalStream(sessionId);
3044
+ });
3045
+ source.addEventListener("error", () => {
3046
+ closeWorkshopTerminalStream(sessionId);
3047
+ });
3048
+ }
3049
+
3050
+ function renderWorkshop(activeTab, options = {}) {
3051
+ if (options.unavailable) {
3052
+ return `
3053
+ <div class="viewer-workshop">
3054
+ <div class="viewer-workspace__placeholder viewer-workspace__placeholder--unavailable">
3055
+ <span class="viewer-workspace__placeholder-icon" aria-hidden="true">!</span>
3056
+ <span>${escapeHtml(options.message || "Workshop is not available for this project.")}</span>
3057
+ </div>
3058
+ </div>
3059
+ `;
3060
+ }
3061
+ return `
3062
+ <div class="viewer-workshop">
3063
+ <div class="viewer-workshop__tabs" role="tablist" aria-label="Workshop sub-screens">
3064
+ ${renderWorkshopTabs(activeTab)}
3065
+ </div>
3066
+ ${renderWorkshopPanel(activeTab)}
3067
+ </div>
3068
+ `;
3069
+ }
3070
+
3071
+ async function showWorkshop(options = {}) {
3072
+ if (!isCapabilityAvailable("workshop")) {
3073
+ const message = capabilityMessage("workshop", "Workshop is not available for this project.");
3074
+ setDocument("Workshop", renderWorkshop("terminals", { unavailable: true, message }));
3075
+ setMeta(message);
3076
+ return;
3077
+ }
3078
+ const activeTab = options.tab && workshopTabs.some((tab) => tab.id === options.tab)
3079
+ ? options.tab
3080
+ : preferredWorkshopTab();
3081
+ setWorkshopActiveTab(activeTab);
3082
+ setDocument("Workshop", renderWorkshop(activeTab));
3083
+ setMeta(`Workshop / ${activeTab}`);
3084
+ if (activeTab === "commands") {
3085
+ await loadWorkshopCommands();
3086
+ } else if (activeTab === "terminals") {
3087
+ // The Workshop DOM was just re-rendered, so every prior xterm host /
3088
+ // EventSource is gone. Drop them from the in-memory state too so the
3089
+ // remount path recreates fresh ones and the SSE stream replays the
3090
+ // session buffer.
3091
+ for (const entry of workshopTerminalState.sessions.values()) {
3092
+ if (entry.terminal) {
3093
+ try { entry.terminal.dispose(); } catch { /* noop */ }
3094
+ }
3095
+ entry.terminal = null;
3096
+ entry.fitAddon = null;
3097
+ closeWorkshopTerminalStream(entry.id);
3098
+ }
3099
+ renderWorkshopTerminalList();
3100
+ // Remount every session so switching between rows is instant and
3101
+ // none of the terminals show a black/empty stage.
3102
+ for (const entry of workshopTerminalState.sessions.values()) {
3103
+ mountWorkshopTerminalEmulator(entry);
3104
+ if (entry.id !== workshopTerminalState.activeId) {
3105
+ const host = workshopTerminalStageNode()?.querySelector(`[data-viewer-workshop-terminal-host="${entry.id}"]`);
3106
+ if (host instanceof HTMLElement) host.style.display = "none";
3107
+ }
3108
+ }
3109
+ if (workshopTerminalState.activeId) {
3110
+ setActiveWorkshopTerminal(workshopTerminalState.activeId);
3111
+ }
3112
+ }
3113
+ }
3114
+
2300
3115
  async function openWorkspaceTree(path) {
2301
3116
  const [tree, preview] = await Promise.all([fetchWorkspaceTree(path), fetchWorkspacePreview(path)]);
2302
3117
  setDocument("Explorer", renderWorkspace(tree, preview));
@@ -4218,6 +5033,19 @@
4218
5033
  setRefreshMenuOpen(false);
4219
5034
  withPrimaryAction("health", "Checking health", showHealth);
4220
5035
  });
5036
+ document.getElementById("viewer-lan-banner-copy")?.addEventListener("click", async () => {
5037
+ const share = latestLanShareUrl;
5038
+ if (!share) return;
5039
+ const ok = await copyTextToClipboard(share);
5040
+ if (ok) {
5041
+ setMeta("LAN share URL copied to the clipboard.");
5042
+ } else {
5043
+ setMeta(`Copy failed — long-press to select: ${share}`);
5044
+ }
5045
+ });
5046
+ document.getElementById("viewer-workshop")?.addEventListener("click", () => {
5047
+ withPrimaryAction("workshop", "Opening Workshop", () => showWorkshop());
5048
+ });
4221
5049
  document.getElementById("viewer-git")?.addEventListener("click", () => {
4222
5050
  withPrimaryAction("git", "Checking Git status", showGitStatus);
4223
5051
  });
@@ -4312,6 +5140,13 @@
4312
5140
  const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
4313
5141
  const workspaceTreeTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workspace-tree]") : null;
4314
5142
  const workspacePreviewTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workspace-preview]") : null;
5143
+ const workshopTabTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-tab]") : null;
5144
+ const workshopRunTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-command-run]") : null;
5145
+ const workshopStopTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-command-stop]") : null;
5146
+ const workshopTerminalNewTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-new]") : null;
5147
+ const workshopTerminalCustomTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-custom]") : null;
5148
+ const workshopTerminalSelectTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-select]") : null;
5149
+ const workshopTerminalCloseTarget = event.target instanceof Element ? event.target.closest("[data-viewer-workshop-terminal-close]") : null;
4315
5150
  const projectSwitcherTarget = event.target instanceof Element ? event.target.closest("#viewer-repo-pill") : null;
4316
5151
  const projectTarget = event.target instanceof Element ? event.target.closest("[data-viewer-project-id]") : null;
4317
5152
  const cdxModeTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-mode]") : null;
@@ -4386,6 +5221,52 @@
4386
5221
  }
4387
5222
  return;
4388
5223
  }
5224
+ if (workshopTabTarget instanceof HTMLElement) {
5225
+ event.preventDefault();
5226
+ const tab = workshopTabTarget.getAttribute("data-viewer-workshop-tab") || "terminals";
5227
+ withPrimaryAction("workshop-tab", `Switching to ${tab}`, () => showWorkshop({ tab }));
5228
+ return;
5229
+ }
5230
+ if (workshopTerminalCloseTarget instanceof HTMLElement) {
5231
+ event.preventDefault();
5232
+ event.stopPropagation();
5233
+ const id = workshopTerminalCloseTarget.getAttribute("data-viewer-workshop-terminal-close") || "";
5234
+ if (id) stopWorkshopTerminal(id);
5235
+ return;
5236
+ }
5237
+ if (workshopTerminalNewTarget instanceof HTMLElement) {
5238
+ event.preventDefault();
5239
+ spawnWorkshopTerminal();
5240
+ return;
5241
+ }
5242
+ if (workshopTerminalCustomTarget instanceof HTMLElement) {
5243
+ event.preventDefault();
5244
+ spawnCustomWorkshopTerminal();
5245
+ return;
5246
+ }
5247
+ if (workshopTerminalSelectTarget instanceof HTMLElement) {
5248
+ event.preventDefault();
5249
+ const id = workshopTerminalSelectTarget.getAttribute("data-viewer-workshop-terminal-select") || "";
5250
+ if (id) setActiveWorkshopTerminal(id);
5251
+ return;
5252
+ }
5253
+ if (workshopRunTarget instanceof HTMLElement) {
5254
+ event.preventDefault();
5255
+ const commandId = workshopRunTarget.getAttribute("data-viewer-workshop-command-run") || "";
5256
+ if (commandId) {
5257
+ updateWorkshopCommandSession(commandId, { state: "starting", logText: "" });
5258
+ startWorkshopCommand(commandId);
5259
+ }
5260
+ return;
5261
+ }
5262
+ if (workshopStopTarget instanceof HTMLElement) {
5263
+ event.preventDefault();
5264
+ const commandId = workshopStopTarget.getAttribute("data-viewer-workshop-command-stop") || "";
5265
+ if (commandId) {
5266
+ stopWorkshopCommand(commandId);
5267
+ }
5268
+ return;
5269
+ }
4389
5270
  if (workspaceTreeTarget instanceof HTMLElement) {
4390
5271
  event.preventDefault();
4391
5272
  withPrimaryAction("workspace-tree", "Loading Explorer folder", () => openWorkspaceTree(workspaceTreeTarget.getAttribute("data-viewer-workspace-tree") || ""));