@grifhinz/logics-manager 2.8.1 → 2.9.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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/vendor/xterm/PROVENANCE.md +31 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-fit.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-web-links.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm.css +209 -0
- package/clients/shared-web/media/vendor/xterm/xterm.js +2 -0
- package/clients/viewer/browser-host.js +893 -12
- package/clients/viewer/index.html +14 -0
- package/clients/viewer/viewer.css +642 -25
- package/logics_manager/viewer.py +1119 -8
- package/package.json +1 -1
- package/pyproject.toml +4 -1
|
@@ -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
|
-
|
|
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
|
|
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="
|
|
2200
|
-
<span class="viewer-workspace__item-icon" aria-hidden="true">${
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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") || ""));
|