mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
feat(tabs): full-row activation and tree semantics for the grouped rail
751 lines
33 KiB
JavaScript
751 lines
33 KiB
JavaScript
/**
|
|
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
|
|
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
|
|
* browser tabs.
|
|
*
|
|
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
|
|
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
|
|
* dashboard that reloads and re-authenticates on every tab switch is worse than
|
|
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
|
|
* least-recently-viewed eviction.
|
|
*
|
|
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
|
|
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
|
|
* trusted. Without that omission the page could read this document and call the
|
|
* API that spawns agents.
|
|
*
|
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
|
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
|
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
|
*/
|
|
|
|
// ── Loopback links ──────────────────────────────────────────────────────────
|
|
//
|
|
// An agent prints `http://localhost:5173/` and the user taps it on a phone.
|
|
// That link can only ever resolve on the Codeman box itself, so opening it in
|
|
// the browser is a guaranteed connection error from anywhere else — while the
|
|
// proxied web tab fetches from the server, where it works. Only loopback is
|
|
// routed this way: a LAN or tailnet address may well be reachable from the
|
|
// device (a VPN, the same Wi-Fi), and a direct open is the cheaper, richer path.
|
|
|
|
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
|
|
|
|
function normalizeHostname(hostname) {
|
|
return String(hostname || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\.$/, '');
|
|
}
|
|
|
|
/**
|
|
* `localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms: names that can
|
|
* only ever mean this box.
|
|
*
|
|
* ⚠️ `*.localhost` is deliberately NOT here. The link source is agent-written
|
|
* terminal output and response-viewer markdown, i.e. prompt-injectable, and
|
|
* this set is the whole confinement on a tap that makes Codeman fetch a URL
|
|
* server-side and persist it. Every other member is an address literal; a
|
|
* `*.localhost` DNS name is not one: on a resolver that does not synthesise it
|
|
* locally and has a search domain configured, `evil.localhost` NXDOMAINs as
|
|
* absolute and is retried as `evil.localhost.<search domain>`, which an
|
|
* attacker can control. A user who really runs `api.localhost` dev hosts can
|
|
* still save that dashboard by hand, which is an explicit action.
|
|
*/
|
|
function isLoopbackHostname(hostname) {
|
|
const host = normalizeHostname(hostname);
|
|
if (!host) return false;
|
|
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
|
const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
|
|
return !!ipv4 && Number(ipv4[1]) === 127;
|
|
}
|
|
|
|
/**
|
|
* Whether the PAGE is being viewed on the box itself. Broader than the
|
|
* auto-route set on purpose, and safe in the opposite direction: a false
|
|
* positive here only ever DECLINES to proxy, leaving the caller's direct open.
|
|
*/
|
|
function isOnBoxHostname(hostname) {
|
|
const host = normalizeHostname(hostname);
|
|
return isLoopbackHostname(host) || host.endsWith('.localhost');
|
|
}
|
|
|
|
/**
|
|
* One key per dev server, so `localhost:5173` and `127.0.0.1:5173` reuse a
|
|
* single saved dashboard and a single tab instead of one per host spelling.
|
|
*/
|
|
function webTabOriginKey(url) {
|
|
return isLoopbackHostname(url.hostname) ? `${url.protocol}//loopback:${url.port}` : url.origin;
|
|
}
|
|
|
|
/**
|
|
* Whether a link should open through a proxied web tab rather than directly:
|
|
* an http(s) URL on a loopback host, viewed from a page that is NOT itself on
|
|
* that host (on the box, the browser can reach localhost and the direct open
|
|
* keeps devtools, extensions and the real origin).
|
|
*/
|
|
function linkNeedsWebTabProxy(rawUrl, pageHostname) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(rawUrl || ''));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
if (!isLoopbackHostname(url.hostname)) return false;
|
|
return !isOnBoxHostname(pageHostname);
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.CodemanWebviewLinks = { isLoopbackHostname, isOnBoxHostname, webTabOriginKey, linkNeedsWebTabProxy };
|
|
}
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
// ── Loopback links ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Take a link the device cannot reach and open it through a proxied web tab.
|
|
* Returns true when it took the link; false leaves the caller's own opening
|
|
* path (window.open, an anchor's default) untouched.
|
|
*/
|
|
openLinkThroughWebTabIfLoopback(rawUrl) {
|
|
if (!linkNeedsWebTabProxy(rawUrl, window.location?.hostname)) return false;
|
|
void this.openUrlInWebTab(rawUrl);
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Open an arbitrary URL as a proxied web tab, deep path included. A saved
|
|
* proxied dashboard on the same origin is reused (one tab per dev server,
|
|
* not one per link); otherwise one is saved under the host:port name so it
|
|
* is there in the Run dropdown next time.
|
|
*/
|
|
async openUrlInWebTab(rawUrl) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(rawUrl || ''));
|
|
} catch {
|
|
return;
|
|
}
|
|
// ⚠️ "is `this.webviews` set" does NOT answer "is it loaded": initWebviews()
|
|
// assigns a truthy EMPTY map synchronously and only then awaits the list, so
|
|
// a tap during page load used to find nothing to reuse and POST a duplicate
|
|
// record for an origin that already exists server-side. Join an in-flight
|
|
// load; start one only when none has ever run.
|
|
if (this._webviewsRefresh) await this._webviewsRefresh;
|
|
else if (!this._webviewsLoaded) await this.refreshWebviews();
|
|
if (!this.webviews) {
|
|
this.showToast?.('Could not open URL', 'error');
|
|
return;
|
|
}
|
|
|
|
const wantedKey = webTabOriginKey(url);
|
|
let existing = null;
|
|
for (const webview of this.webviews.values()) {
|
|
// ⚠️ `trusted` is excluded alongside `managed` and direct-mode records: a
|
|
// trusted frame runs with `allow-same-origin`, i.e. on Codeman's origin with
|
|
// the user's cookie, and the link being followed came from agent output. An
|
|
// agent that can write into the dev server's tree (it IS the workspace) could
|
|
// otherwise print a path that one tap navigates that privileged frame to.
|
|
// Opening such a dashboard from the Run dropdown is still an explicit action.
|
|
if (webview.managed || webview.trusted || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
|
|
try {
|
|
if (webTabOriginKey(new URL(webview.url)) === wantedKey) {
|
|
existing = webview;
|
|
break;
|
|
}
|
|
} catch {
|
|
/* a saved URL that no longer parses is not a match */
|
|
}
|
|
}
|
|
|
|
let id = existing?.id;
|
|
if (!id) {
|
|
const created = await this._apiJson('/api/webviews', {
|
|
method: 'POST',
|
|
body: { name: url.host.slice(0, 60), url: `${url.origin}/`, embedMode: 'proxy', trusted: false },
|
|
});
|
|
if (!created?.id) {
|
|
this.showToast?.('Could not open URL', 'error');
|
|
return;
|
|
}
|
|
await this.refreshWebviews();
|
|
id = created.id;
|
|
// The create is a persisted record: it writes webviews.json, broadcasts
|
|
// over SSE, adds a Run-dropdown row on every device this owner is signed
|
|
// in on and counts toward MAX_WEBVIEWS. Adding one by hand goes through a
|
|
// modal; a tap should not do all that with a new tab as its only signal.
|
|
this.showToast?.(`Saved ${url.host} as a web tab`, 'success');
|
|
}
|
|
// `/` is passed through rather than flattened to '': openWebview reads an
|
|
// empty path as "no deep link" and leaves an already-open frame on whatever
|
|
// page it was showing, so a link to the origin root did nothing visible.
|
|
const path = `${url.pathname}${url.search}${url.hash}`;
|
|
await this.openWebview(id, { path: path || '/' });
|
|
},
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Load the saved list and restore which tabs were open. */
|
|
async initWebviews() {
|
|
this.webviews = this.webviews || new Map();
|
|
this.webviewOrder = this.webviewOrder || [];
|
|
this.activeWebviewId = this.activeWebviewId || null;
|
|
this._webviewMaxFrames = this._webviewMaxFrames || 6;
|
|
this._webviewFrameLru = this._webviewFrameLru || [];
|
|
|
|
await this.refreshWebviews();
|
|
this._installWebviewLostListener();
|
|
|
|
// Restore the previously open web tabs (per device: which dashboards you keep
|
|
// open is a workspace-layout choice, not something to sync across machines).
|
|
let saved = [];
|
|
try {
|
|
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
|
|
} catch {
|
|
saved = [];
|
|
}
|
|
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
|
|
this.renderSessionTabs();
|
|
},
|
|
|
|
async refreshWebviews() {
|
|
const inFlight = this._apiJson('/api/webviews').then((data) => {
|
|
if (!data) return;
|
|
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
|
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
|
this._webviewsLoaded = true;
|
|
this.renderWebviewMenuItems();
|
|
});
|
|
this._webviewsRefresh = inFlight;
|
|
try {
|
|
await inFlight;
|
|
} finally {
|
|
if (this._webviewsRefresh === inFlight) this._webviewsRefresh = null;
|
|
}
|
|
},
|
|
|
|
/** SSE: the saved list changed (possibly on another device). */
|
|
async _onWebviewChanged(data) {
|
|
await this.refreshWebviews();
|
|
// A dashboard deleted elsewhere must not linger as a dead tab here.
|
|
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
|
|
this.renderSessionTabs();
|
|
},
|
|
|
|
/**
|
|
* Take back a frame that navigated itself off its proxy prefix.
|
|
*
|
|
* The proxy's runtime shim masks `/webview/<cap>/` off the document URL so a
|
|
* single-page app routes on the path it expects. A navigation the page then
|
|
* starts itself — `location.reload()` (a dev server's full-reload HMR), a
|
|
* root-absolute `location.href = '/login'` — lands on Codeman's root with no
|
|
* capability, where the server answers a static page that does nothing but
|
|
* post `{type:'codeman:webview-lost', path}` here. The frame is identified by
|
|
* `event.source` against the iframes this tab mounted (never by the payload),
|
|
* and remounted inside the prefix at that path. Bounded per frame so a page
|
|
* that reloads itself on every boot cannot spin.
|
|
*/
|
|
_installWebviewLostListener() {
|
|
if (this._webviewLostListener) return;
|
|
this._webviewLostListener = (event) => {
|
|
const data = event.data;
|
|
if (!data || typeof data !== 'object' || data.type !== 'codeman:webview-lost') return;
|
|
if (typeof data.path !== 'string' || !event.source) return;
|
|
const layer = document.getElementById('webviewLayer');
|
|
if (!layer) return;
|
|
for (const wrap of layer.querySelectorAll('.webview-frame')) {
|
|
const frame = wrap.querySelector('iframe');
|
|
if (!frame || frame.contentWindow !== event.source) continue;
|
|
const id = wrap.dataset.webviewId;
|
|
if (!id || !this.webviews?.has(id)) return;
|
|
const now = Date.now();
|
|
this._webviewRecoveries = this._webviewRecoveries || new Map();
|
|
const recent = (this._webviewRecoveries.get(id) || []).filter((at) => now - at < 60000);
|
|
if (recent.length >= 5) return;
|
|
recent.push(now);
|
|
this._webviewRecoveries.set(id, recent);
|
|
// Path only, never an origin. Three spellings would resolve to a foreign
|
|
// origin (in direct mode `new URL(path, src)` is the frame's src, so the
|
|
// frame would remount there): the protocol-relative `//host/x`; a
|
|
// backslash, which the WHATWG parser treats as `/` for http(s), so
|
|
// `/\host/x` too; and an ASCII tab or newline, which the parser deletes
|
|
// before it looks at anything, so `/<tab>/host/x` IS `//host/x` by the time
|
|
// it resolves. Drop the invisible ones, collapse the leading separators to
|
|
// one `/`, and refuse whatever still opens a second one. The proxied form
|
|
// is refused server-side as well (resolveUpstreamUrl).
|
|
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
|
void this.openWebview(id, {
|
|
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
|
|
auto: true,
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
window.addEventListener('message', this._webviewLostListener);
|
|
},
|
|
|
|
_persistWebviewOrder() {
|
|
try {
|
|
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
|
} catch {
|
|
/* private mode / quota, order is a convenience, never fatal */
|
|
}
|
|
},
|
|
|
|
// ── Tab strip ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
|
|
* `startIndex` continues the Alt+N numbering after the session tabs.
|
|
*/
|
|
renderWebviewTabs(startIndex) {
|
|
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
|
|
const parts = [];
|
|
let idx = startIndex;
|
|
|
|
for (const id of this.webviewOrder) {
|
|
if (!this.webviews.get(id)) continue;
|
|
parts.push(this.renderWebviewTab(id, idx));
|
|
idx++;
|
|
}
|
|
return parts.join('');
|
|
},
|
|
|
|
/**
|
|
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
|
|
* The grouped vertical rail places single web tabs into their group with this,
|
|
* so a web tab's markup is the same in every layout.
|
|
*/
|
|
renderWebviewTab(id, idx) {
|
|
const webview = this.webviews.get(id);
|
|
if (!webview) return '';
|
|
const isActive = id === this.activeWebviewId;
|
|
const jsonId = escapeHtml(JSON.stringify(id));
|
|
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
|
|
|
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
|
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
|
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
|
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
|
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
|
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
|
|
<span class="tab-info">
|
|
<span class="tab-name-row">
|
|
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
|
</span>
|
|
</span>
|
|
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span></span>
|
|
</div>`;
|
|
},
|
|
|
|
_webviewGlobeIcon() {
|
|
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
|
|
},
|
|
|
|
handleWebviewTabClick(event, id) {
|
|
event?.preventDefault?.();
|
|
return this.openWebview(id);
|
|
},
|
|
|
|
/** Mark exactly one tab active across BOTH tab kinds. */
|
|
_updateActiveWebviewTab() {
|
|
const container = this.$('sessionTabs');
|
|
if (!container) return;
|
|
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
|
|
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
|
|
}
|
|
if (this.activeWebviewId) {
|
|
// A web tab is active, so no session tab may also look active.
|
|
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
|
}
|
|
this._syncTabTreeSelection?.(container);
|
|
},
|
|
|
|
// ── Opening / closing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
|
|
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
|
* the dead URL from the previous run.
|
|
*/
|
|
/**
|
|
* @param {string} id
|
|
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
|
|
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
|
* against the real URL in direct mode. A mounted frame is navigated there
|
|
* rather than left on whatever page it was showing. `auto: true` marks an
|
|
* open the APP made (a frame recovering itself, the fallback after the
|
|
* active web tab closes), as on selectSession().
|
|
*/
|
|
async openWebview(id, options = {}) {
|
|
const webview = this.webviews.get(id);
|
|
if (!webview) return;
|
|
|
|
// Opening a web tab yourself is choosing something else, so a
|
|
// `#session=<id>` link still waiting for its session must not take the
|
|
// screen from this tab later. Retired before the await below, which a
|
|
// session:created could otherwise land inside.
|
|
if (options.auto !== true) this._retireUrlSession?.();
|
|
|
|
if (!this.webviewOrder.includes(id)) {
|
|
this.webviewOrder.push(id);
|
|
this._persistWebviewOrder();
|
|
}
|
|
|
|
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
|
|
if (!data) {
|
|
this.showToast?.('Could not open URL', 'error');
|
|
return;
|
|
}
|
|
if (data.webview) this.webviews.set(id, data.webview);
|
|
|
|
let src = data.embedUrl || data.webview?.url || webview.url;
|
|
// A string `path` (even '') means "go there": the proxy prefix is
|
|
// `/webview/<cap>/` and the wildcard rides after it; in direct mode the deep
|
|
// link resolves against the dashboard's own origin. No `path` means "show
|
|
// the tab", leaving a mounted frame on whatever page it reached.
|
|
const path = typeof options.path === 'string' ? options.path : null;
|
|
if (path) {
|
|
src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
|
|
}
|
|
this._mountWebviewFrame(id, src, data.webview || webview, { navigate: path !== null });
|
|
this.activeWebviewId = id;
|
|
this.hideWelcome?.();
|
|
document.querySelector('.main')?.classList.add('webview-active');
|
|
this.renderSessionTabs();
|
|
this._updateActiveWebviewTab();
|
|
// Web tabs live in the same list as sessions, so picking one from the
|
|
// handheld session drawer has to dismiss it too (no-op elsewhere).
|
|
this.closeSessionSidebarOnHandheld?.();
|
|
},
|
|
|
|
/** Create the frame if absent, then reveal it and hide its siblings. */
|
|
_mountWebviewFrame(id, src, webview, { navigate = false } = {}) {
|
|
const layer = document.getElementById('webviewLayer');
|
|
if (!layer) return;
|
|
|
|
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
|
if (wrap && navigate) {
|
|
// A deep link into an already-mounted dashboard: navigate the live frame
|
|
// instead of tearing it down, so its login and state survive.
|
|
const frame = wrap.querySelector('iframe');
|
|
if (frame) frame.src = CodemanBase.url(src);
|
|
}
|
|
if (!wrap) {
|
|
wrap = document.createElement('div');
|
|
wrap.className = 'webview-frame';
|
|
wrap.dataset.webviewId = id;
|
|
|
|
const frame = document.createElement('iframe');
|
|
frame.className = 'webview-iframe';
|
|
frame.setAttribute('title', webview.name);
|
|
// No allow-same-origin unless explicitly trusted: a proxied page is served
|
|
// from THIS origin, so granting it would let the dashboard read this document
|
|
// and drive the Codeman API.
|
|
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
|
|
if (webview.trusted) sandbox.push('allow-same-origin');
|
|
frame.setAttribute('sandbox', sandbox.join(' '));
|
|
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
|
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
|
|
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
|
|
frame.src = CodemanBase.url(src);
|
|
|
|
const failure = document.createElement('div');
|
|
failure.className = 'webview-failure';
|
|
failure.innerHTML = this._webviewFailureHtml(id);
|
|
|
|
wrap.appendChild(frame);
|
|
wrap.appendChild(failure);
|
|
layer.appendChild(wrap);
|
|
|
|
// A frame that never fires `load` is the normal symptom of a refused embed or
|
|
// an unreachable host. Show an actionable panel instead of a blank rectangle.
|
|
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
|
|
frame.addEventListener('load', () => {
|
|
clearTimeout(timer);
|
|
wrap.classList.remove('webview-frame--failed');
|
|
});
|
|
}
|
|
|
|
this._touchWebviewFrame(id);
|
|
for (const other of layer.querySelectorAll('.webview-frame')) {
|
|
other.classList.toggle('active', other.dataset.webviewId === id);
|
|
}
|
|
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
|
|
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
|
},
|
|
|
|
_webviewFailureHtml(id) {
|
|
const jsonId = escapeHtml(JSON.stringify(id));
|
|
return `<div class="webview-failure-inner">
|
|
<h3>This URL did not load</h3>
|
|
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
|
|
<div class="webview-failure-actions">
|
|
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
|
|
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
|
|
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
|
|
</div>
|
|
</div>`;
|
|
},
|
|
|
|
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
|
|
_touchWebviewFrame(id) {
|
|
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
|
this._webviewFrameLru.push(id);
|
|
const layer = document.getElementById('webviewLayer');
|
|
if (!layer) return;
|
|
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
|
|
const evict = this._webviewFrameLru.shift();
|
|
if (evict === this.activeWebviewId) continue;
|
|
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
|
|
}
|
|
},
|
|
|
|
reloadWebview(id) {
|
|
const target = id || this.activeWebviewId;
|
|
if (!target) return;
|
|
document
|
|
.getElementById('webviewLayer')
|
|
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
|
|
?.remove();
|
|
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
|
|
return this.openWebview(target);
|
|
},
|
|
|
|
openWebviewExternal(id) {
|
|
const webview = this.webviews.get(id || this.activeWebviewId);
|
|
if (webview) window.open(webview.url, '_blank', 'noopener');
|
|
},
|
|
|
|
closeWebviewTab(id) {
|
|
this._removeWebviewTab(id);
|
|
this.renderSessionTabs();
|
|
},
|
|
|
|
_removeWebviewTab(id) {
|
|
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
|
|
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
|
this._persistWebviewOrder();
|
|
document
|
|
.getElementById('webviewLayer')
|
|
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
|
|
?.remove();
|
|
|
|
if (this.activeWebviewId === id) {
|
|
this.activeWebviewId = null;
|
|
const next = this.webviewOrder[0];
|
|
if (next) {
|
|
this.openWebview(next, { auto: true });
|
|
} else {
|
|
this._hideWebviewLayer();
|
|
// Fall back to whatever session was last shown, or the welcome screen.
|
|
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
|
|
else this.showWelcome?.();
|
|
}
|
|
}
|
|
},
|
|
|
|
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
|
|
_hideWebviewLayer() {
|
|
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
|
|
this.activeWebviewId = null;
|
|
document.querySelector('.main')?.classList.remove('webview-active');
|
|
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
|
|
frame.classList.remove('active');
|
|
}
|
|
this._updateActiveWebviewTab();
|
|
},
|
|
|
|
// ── Run-menu entries ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
|
|
*
|
|
* Each row carries its own edit and delete buttons. Without them the only way to
|
|
* change or remove a saved URL was to open it as a tab first and go through the
|
|
* tab's gear, which is a dead end for a URL you no longer want open at all.
|
|
*/
|
|
renderWebviewMenuItems() {
|
|
const container = document.getElementById('runModeWebviews');
|
|
if (!container) return;
|
|
// Managed records are maintained by their own menu entry (the DeepSeek web
|
|
// UI shortcut), so listing them here showed one dashboard twice: the
|
|
// shortcut that starts it, and the row it wrote on the previous click.
|
|
const list = [...(this.webviews?.values() || [])].filter((w) => !w.managed);
|
|
if (list.length === 0) {
|
|
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = list
|
|
.map((w) => {
|
|
const jsonId = escapeHtml(JSON.stringify(w.id));
|
|
const name = escapeHtml(w.name);
|
|
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
|
|
return `<div class="run-mode-row run-mode-row--web">
|
|
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
|
|
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
|
|
</button>
|
|
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
|
|
title="Edit URL" aria-label="Edit ${name}">⚙</button>
|
|
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
|
|
title="Delete URL" aria-label="Delete ${name}">×</button>
|
|
</div>`;
|
|
})
|
|
.join('');
|
|
},
|
|
|
|
openWebviewFromMenu(id) {
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
return this.openWebview(id);
|
|
},
|
|
|
|
// ── Icon picker ───────────────────────────────────────────────────────────
|
|
|
|
/** Common dashboard/service glyphs. The text field stays open for anything else. */
|
|
_webviewIconChoices() {
|
|
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
|
|
},
|
|
|
|
_renderWebviewIconPicker(selected) {
|
|
const picker = document.getElementById('webviewIconPicker');
|
|
if (!picker) return;
|
|
picker.innerHTML = this._webviewIconChoices()
|
|
.map(
|
|
(icon) =>
|
|
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
|
|
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
|
|
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
|
|
)
|
|
.join('');
|
|
},
|
|
|
|
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
|
|
pickWebviewIcon(icon) {
|
|
const field = document.getElementById('webviewIcon');
|
|
if (!field) return;
|
|
field.value = field.value === icon ? '' : icon;
|
|
this._renderWebviewIconPicker(field.value);
|
|
},
|
|
|
|
// ── Editor modal ──────────────────────────────────────────────────────────
|
|
|
|
showWebviewModal(id) {
|
|
const modal = document.getElementById('webviewModal');
|
|
if (!modal) return;
|
|
const webview = id ? this.webviews.get(id) : null;
|
|
this._editingWebviewId = webview ? webview.id : null;
|
|
|
|
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
|
|
this._renderWebviewIconPicker(webview?.icon || '');
|
|
document.getElementById('webviewName').value = webview?.name || '';
|
|
document.getElementById('webviewUrl').value = webview?.url || '';
|
|
document.getElementById('webviewIcon').value = webview?.icon || '';
|
|
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
|
|
document.getElementById('webviewProbeResult').textContent = '';
|
|
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
|
|
|
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
|
modal.classList.add('active');
|
|
document.getElementById('webviewName').focus();
|
|
},
|
|
|
|
closeWebviewModal() {
|
|
document.getElementById('webviewModal')?.classList.remove('active');
|
|
this._editingWebviewId = null;
|
|
},
|
|
|
|
/** Server-side probe: it runs from the network position the proxy will use. */
|
|
async testWebviewUrl() {
|
|
const url = document.getElementById('webviewUrl').value.trim();
|
|
const out = document.getElementById('webviewProbeResult');
|
|
if (!url) {
|
|
out.textContent = 'Enter a URL first.';
|
|
return;
|
|
}
|
|
out.textContent = 'Testing...';
|
|
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
|
|
if (!probe) {
|
|
out.textContent = 'Test failed (invalid URL?).';
|
|
return;
|
|
}
|
|
// #238: the probe runs server-to-upstream; say so, or a passing Test reads as
|
|
// "the embedded page will work" when the browser sandbox / a cookie-auth
|
|
// reverse proxy in front of Codeman can still break it.
|
|
out.textContent = probe.reachable
|
|
? `Reachable (HTTP ${probe.status}) from the Codeman server. ${probe.reason} ` +
|
|
`(Tests server-to-upstream reachability only, not how the page behaves in a sandboxed frame.)`
|
|
: `Not reachable from the Codeman server. ${probe.reason}`;
|
|
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
|
},
|
|
|
|
async saveWebview() {
|
|
const name = document.getElementById('webviewName').value.trim();
|
|
const url = document.getElementById('webviewUrl').value.trim();
|
|
const icon = document.getElementById('webviewIcon').value.trim();
|
|
const trusted = !document.getElementById('webviewSandboxed').checked;
|
|
if (!name || !url) {
|
|
this.showToast?.('Name and URL are required', 'error');
|
|
return;
|
|
}
|
|
|
|
// `icon: undefined` rather than null, the schema uses .optional(), which
|
|
// rejects an explicit null on the wire.
|
|
const body = { name, url, icon: icon || undefined, trusted };
|
|
const editing = this._editingWebviewId;
|
|
const data = editing
|
|
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
|
|
: await this._apiJson('/api/webviews', { method: 'POST', body });
|
|
|
|
if (!data) {
|
|
this.showToast?.('Could not save (check the URL)', 'error');
|
|
return;
|
|
}
|
|
|
|
await this.refreshWebviews();
|
|
this.closeWebviewModal();
|
|
if (editing) {
|
|
// The capability was revoked server-side by the edit, so a mounted frame is
|
|
// now pointing at a dead URL. Remount it.
|
|
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
|
|
} else {
|
|
this.openWebview(data.id);
|
|
}
|
|
},
|
|
|
|
async deleteWebview() {
|
|
const id = this._editingWebviewId;
|
|
if (!id) return;
|
|
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
|
|
},
|
|
|
|
/**
|
|
* Delete straight from a Run-dropdown row, without opening the editor first.
|
|
*
|
|
* The dropdown's outside-click handler closes the menu when the click target is
|
|
* not inside it, and by the time the delete resolves this row is gone, so the
|
|
* menu is re-asserted open: deleting one of several saved URLs should leave you
|
|
* looking at the rest of the list.
|
|
*/
|
|
async deleteWebviewById(id) {
|
|
if (!id) return;
|
|
if (!(await this._confirmAndDeleteWebview(id))) return;
|
|
document.getElementById('runModeMenu')?.classList.add('active');
|
|
},
|
|
|
|
/** Shared by the row button and the editor modal. @returns true when deleted. */
|
|
async _confirmAndDeleteWebview(id) {
|
|
const webview = this.webviews.get(id);
|
|
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
|
|
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
|
|
if (!res || !res.ok) {
|
|
this.showToast?.('Could not delete URL', 'error');
|
|
return false;
|
|
}
|
|
this._removeWebviewTab(id);
|
|
this.webviews.delete(id);
|
|
this.renderWebviewMenuItems();
|
|
this.renderSessionTabs();
|
|
return true;
|
|
},
|
|
});
|