mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts: # src/web/public/constants.js
This commit is contained in:
commit
e85b4f34dd
83 files changed
+5545
-177
No files matched your search
+186
-17
@@ -567,6 +567,14 @@ const DEFAULT_SHORTCUTS = [
|
||||
*/
|
||||
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
|
||||
/**
|
||||
* How long a `#session=<id>` link waits for the session list to name its id
|
||||
* before the dashboard drops it with a "Session not found" toast (see
|
||||
* _armUrlSessionWait). Long enough for a page that has just created the
|
||||
* session to see its session:created land here.
|
||||
*/
|
||||
const URL_SESSION_WAIT_MS = 30000;
|
||||
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
@@ -602,6 +610,10 @@ class CodemanApp {
|
||||
// service-worker shell loads), with the server-injected global as a fallback.
|
||||
this.soloSessionId = this._detectSoloSessionId();
|
||||
this.isSoloWindow = !!this.soloSessionId;
|
||||
// A session another page asked for with a `#session=<id>` link. It waits
|
||||
// here until the session list has that id (see _selectUrlSession).
|
||||
this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();
|
||||
this._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
|
||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||
@@ -997,6 +1009,18 @@ class CodemanApp {
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// A page holding this window switches its tab by changing only the
|
||||
// fragment, which keeps the page loaded (see sessionIdFromFragment).
|
||||
if (!this.isSoloWindow) {
|
||||
window.addEventListener('hashchange', () => {
|
||||
const id = this._takeUrlSession();
|
||||
if (!id) return;
|
||||
// A new link replaces one still waiting, and gets a wait of its own.
|
||||
this._retireUrlSession();
|
||||
this._urlSessionId = id;
|
||||
this._selectUrlSession();
|
||||
});
|
||||
}
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -1368,6 +1392,69 @@ class CodemanApp {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Read a `#session=<id>` link off the URL and drop the fragment. The next
|
||||
* link to the same session is then a change the browser reports, even
|
||||
* after you have clicked away to another tab. Returns the id or null. */
|
||||
_takeUrlSession() {
|
||||
const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null;
|
||||
if (id) {
|
||||
try { history.replaceState(history.state, '', location.pathname + location.search); } catch {}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Show the session a `#session=<id>` link asked for, once the session list
|
||||
* has it. A page that has just created a session can link to it before
|
||||
* session:created arrives here, so an unknown id stays pending (for at most
|
||||
* URL_SESSION_WAIT_MS) and _onSessionCreated tries again.
|
||||
*
|
||||
* ⚠️ The selection is `auto`. The page that set the fragment may be a
|
||||
* script, and this window may not even be in front, so following a link is
|
||||
* not a human looking at the session and must not spend its idle alert. */
|
||||
_selectUrlSession() {
|
||||
const id = this._urlSessionId;
|
||||
if (!id) return false;
|
||||
if (!this.sessions.has(id)) {
|
||||
this._armUrlSessionWait(id);
|
||||
return false;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.selectSession(id, { auto: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bound the wait for a link whose id the session list does not have. A
|
||||
* stale link (that session is closed), a typo, or in multi-user mode another
|
||||
* user's session (never in this client's list) would otherwise wait with
|
||||
* nothing on screen, and take the tab whenever a matching session turned up.
|
||||
* One timer per link: handleInit running again (an SSE reconnect) does not
|
||||
* restart it, and every way a link ends goes through _retireUrlSession. */
|
||||
_armUrlSessionWait(id) {
|
||||
if (this._urlSessionWaitTimer) return;
|
||||
this._urlSessionWaitTimer = setTimeout(() => {
|
||||
this._urlSessionWaitTimer = null;
|
||||
if (this._urlSessionId !== id) return;
|
||||
// Listed by a path other than session:created (a session:updated): select it.
|
||||
if (this.sessions.has(id)) {
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.showToast?.('Session not found', 'warning');
|
||||
}, URL_SESSION_WAIT_MS);
|
||||
}
|
||||
|
||||
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
|
||||
* replaced by a newer one, timed out, or the user chose something else
|
||||
* (another tab, Home, a web tab). */
|
||||
_retireUrlSession() {
|
||||
this._urlSessionId = null;
|
||||
if (this._urlSessionWaitTimer) {
|
||||
clearTimeout(this._urlSessionWaitTimer);
|
||||
this._urlSessionWaitTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||
@@ -1949,6 +2036,7 @@ class CodemanApp {
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
if (this.sessions.size === 1) this.startSystemStatsPolling();
|
||||
if (this._urlSessionId === data.id) this._selectUrlSession();
|
||||
}
|
||||
|
||||
_onSessionUpdated(data) {
|
||||
@@ -2255,13 +2343,18 @@ class CodemanApp {
|
||||
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
/**
|
||||
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
|
||||
* `breaks` turns every source newline into a <br>: right for chat, where a
|
||||
* newline is the agent's line break, wrong for a file (the File Viewer passes
|
||||
* false), where a README hard-wrapped at 80 columns would break at every wrap.
|
||||
*/
|
||||
_renderMarkdown(text, { breaks = true } = {}) {
|
||||
const src = text || '';
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
const prepared = this._preprocessAsciiArt(src);
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, gfm: true }));
|
||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||
// on mobile without collapsing into block-level cells.
|
||||
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
||||
@@ -2358,7 +2451,9 @@ class CodemanApp {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const filePath = pathLink.dataset.path;
|
||||
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||
// A rendered document's links name the session the preview was opened
|
||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4367,6 +4462,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `#session=<id>` link wins over restoring the last active tab.
|
||||
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
||||
this.activeSessionId = null;
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
// Not listed yet: its wait starts now that the list has loaded, and the
|
||||
// last active tab is restored meanwhile.
|
||||
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
||||
|
||||
const previousActiveId = this.activeSessionId;
|
||||
if (this.sessionOrder.length === 0) {
|
||||
this.activeSessionId = null;
|
||||
@@ -6320,10 +6425,15 @@ class CodemanApp {
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
// A shell's full capture can be many megabytes. Replaying it from an
|
||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||
// behind the explicit "Load full history" button.
|
||||
if (!force && session?.mode === 'shell') return;
|
||||
// A shell's full capture can be many megabytes, and replaying all of it from
|
||||
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
||||
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
|
||||
// loads, but of the scrollback rather than the visible frame) and the
|
||||
// unbounded pull stays behind the "Load full history" button. Declining
|
||||
// outright left a shell pane about one screen of browser scrollback after any
|
||||
// burst, and the button only renders once a replay was truncated, so a young
|
||||
// shell tab had no way back to output tmux was still holding.
|
||||
const boundedShellPull = !force && session?.mode === 'shell';
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
@@ -6336,7 +6446,12 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
boundedShellPull
|
||||
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
|
||||
: `/api/sessions/${sessionId}/terminal?full=1`,
|
||||
{ full: true }
|
||||
);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
@@ -6357,7 +6472,44 @@ class CodemanApp {
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
|
||||
// A bounded window no longer than the browser's buffer buys nothing, and
|
||||
// resetting to rewrite it would jump the viewport on every scroll that
|
||||
// outlasts the cooldown at the top. This runs BEFORE the downgrade guard
|
||||
// on purpose: that guard reads "smaller than the browser" as "tmux has
|
||||
// nothing more to give", which is true of an unbounded capture but not of a
|
||||
// window cut at the tail size, so a bounded window must never reach the
|
||||
// exhausted path, which would take Load full history off the banner while
|
||||
// tmux still holds the rest. Nothing was written here, so the banner state
|
||||
// is left as the load that produced it set it: re-labelling it from this
|
||||
// payload would call a terminal that holds ALL of a Load full history pull
|
||||
// "the most recent 1 MiB".
|
||||
//
|
||||
// A browser already at xterm's cap buys nothing either. xterm keeps at most
|
||||
// `scrollback + rows` rows (DEFAULT_SCROLLBACK 50k) while tmux keeps 100k
|
||||
// lines by default, so a 1 MiB window of short lines can render to more rows
|
||||
// than the browser can ever hold, and `windowRows <= rowsNow` then never
|
||||
// comes true: without this every scroll-to-top would reset and re-parse it.
|
||||
const rowsNow = this.terminal.buffer.active.length;
|
||||
const scrollbackCap = this.terminal.options?.scrollback || 0;
|
||||
const browserFull = scrollbackCap > 0 && rowsNow >= scrollbackCap + this.terminal.rows;
|
||||
if (boundedShellPull && (windowRows <= rowsNow || browserFull)) {
|
||||
// An untruncated window IS all of tmux's history, so nothing is missing,
|
||||
// and the next burst of output can put more in tmux than the browser has:
|
||||
// keep the normal 4 s cooldown. A truncated one is the opposite case, since
|
||||
// the gesture can never reach anything older than what the browser already
|
||||
// shows, and every ask costs the server a synchronous capture-pane of the
|
||||
// whole history (`tail` is applied after the capture): back off to 60 s.
|
||||
// A full browser backs off too, since no window can ever fit in it.
|
||||
// Trade-off: only a successful replay clears that latch, so a tab switch or
|
||||
// burst that shrinks the browser's buffer below the window can leave a
|
||||
// scroll-to-top inert for up to a minute. Load full history (`force`)
|
||||
// bypasses the cooldown, and the latch is bounded, never permanent.
|
||||
if (payload.truncated || browserFull) (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-skipped-bounded');
|
||||
return;
|
||||
}
|
||||
if (this._replayWouldShrinkBuffer(buffer, windowRows)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
@@ -6368,7 +6520,14 @@ class CodemanApp {
|
||||
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
||||
return;
|
||||
}
|
||||
this._setHistoryTruncation(sessionId, payload);
|
||||
// A bounded window that was cut is always recoverable: a capture over the
|
||||
// byte cap keeps `truncationReason: 'capped'` through the tail cut, and that
|
||||
// would tell the user the rest "cannot be recovered" and drop Load full
|
||||
// history, whose unbounded pull returns up to the cap itself.
|
||||
this._setHistoryTruncation(
|
||||
sessionId,
|
||||
boundedShellPull && payload.truncated ? { ...payload, truncationReason: 'tail' } : payload
|
||||
);
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
const replayStartedAt = performance.now();
|
||||
@@ -6506,6 +6665,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async selectSession(sessionId, options = {}) {
|
||||
// Picking another tab yourself retires a `#session=<id>` link still
|
||||
// waiting for its session, which would otherwise take the tab from you
|
||||
// whenever that session turned up (see _selectUrlSession).
|
||||
if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) {
|
||||
this._retireUrlSession();
|
||||
}
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||
@@ -6515,12 +6680,13 @@ class CodemanApp {
|
||||
}
|
||||
const forceReload = options?.forceReload === true;
|
||||
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
|
||||
// the boot restore, a solo window opening its target, the fallback after
|
||||
// the active session is deleted. Those must NOT spend a pending idle alert
|
||||
// (the yellow survives until a real tap), because "the app put this on
|
||||
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
|
||||
// site nobody tagged fails toward acknowledging rather than toward an
|
||||
// alert that can never be cleared.
|
||||
// the boot restore, a solo window opening its target, a `#session=<id>`
|
||||
// link from another page, the fallback after the active session is
|
||||
// deleted. Those must NOT spend a pending idle alert (the yellow survives
|
||||
// until a real tap), because "the app put this on screen" is not "I
|
||||
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
|
||||
// fails toward acknowledging rather than toward an alert that can never be
|
||||
// cleared.
|
||||
const userInitiated = options?.auto !== true;
|
||||
if (this.activeSessionId === sessionId && !forceReload) {
|
||||
// Tapping the tab you are already on is still "I checked it". The alert
|
||||
@@ -7483,6 +7649,9 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
goHome() {
|
||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
||||
// waiting for its session must not take the screen later.
|
||||
this._retireUrlSession();
|
||||
// Deselect active session and show welcome screen
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
|
||||
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
|
||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp bmp svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
@@ -1853,10 +1853,27 @@ function reconcilePtyGeometry(local, pty) {
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which session does a dashboard URL's fragment ask for? Another page that
|
||||
* holds the dashboard's window, such as a task board, points it at
|
||||
* `/#session=<id>`. Only the fragment changes between two such links, so the
|
||||
* browser keeps the page loaded and fires `hashchange`, and the dashboard
|
||||
* switches tabs without reloading. Any other fragment asks for nothing.
|
||||
*
|
||||
* @param {string} hash - `location.hash`, with or without its leading `#`
|
||||
* @returns {string|null} the session id, or null
|
||||
*/
|
||||
function sessionIdFromFragment(hash) {
|
||||
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
|
||||
const id = params.get('session');
|
||||
return id && id.trim() ? id.trim() : null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -727,6 +729,9 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
|
||||
@@ -573,6 +573,9 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
|
||||
+117
-16
@@ -332,6 +332,39 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
||||
Registered so the keyframes can interpolate them as lengths; @property is
|
||||
only valid at the top level, hence out here. */
|
||||
@property --tab-strip-fade-start {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --tab-strip-fade-end {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* Driven by the strip's own scroll position, not by time: at the start only the
|
||||
end edge fades, at the end only the start edge, and in between both. */
|
||||
@keyframes tab-strip-edge-fade {
|
||||
0% {
|
||||
--tab-strip-fade-start: 0px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
10%,
|
||||
90% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
100% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Phone Breakpoint (<600px)
|
||||
============================================================================ */
|
||||
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 36px;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fade the strip's edges while there is more to scroll to, so the tab that
|
||||
does not fit dissolves into the edge instead of being cut mid-word against
|
||||
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
|
||||
inline scroll (keyframes + registered properties above this block). A strip
|
||||
that does not overflow has an INACTIVE timeline, so the animation applies
|
||||
nothing and both widths stay at their registered 0px, which is no mask at
|
||||
all. Browsers without scroll timelines skip the block and keep the hard
|
||||
edge. Header only: in sidebar layout the same list scrolls vertically. */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.header .session-tabs {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
/* The shorthand resets animation-timeline, so the timeline comes after. */
|
||||
animation: tab-strip-edge-fade linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smaller tabs for mobile */
|
||||
.session-tab {
|
||||
flex-shrink: 0;
|
||||
@@ -671,10 +734,46 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Smaller status indicator on mobile */
|
||||
/* Every tab in the header strip is a chip, not only the active one. Left
|
||||
transparent, the strip read as a row of disabled labels: grey 11px text
|
||||
floating in unmarked gaps, with nothing saying "tap me". Fill and border
|
||||
come from the skin's control tokens, so the four light skins (which repaint
|
||||
the header with --glass-bg) get a matching chip with no override block, and
|
||||
the active tab's !important fill and border in styles.css still win.
|
||||
`:where(.header)` keeps this at (0,1,0): the per-colour left border
|
||||
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
|
||||
border-color here, and in sidebar layout the list leaves the header, so
|
||||
its rows are untouched. */
|
||||
:where(.header) .session-tab {
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:where(.header) .session-tab .tab-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
:where(.header) .session-tab:not(.active) .tab-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
|
||||
here it was only a second grey box inside every tab, and 20px of the name's
|
||||
width. Every header tab is therefore numberless on a phone, which is the
|
||||
case the active-tab reserve below is already sized for. */
|
||||
:where(.header) .session-tab .tab-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
|
||||
.session-tab .tab-status {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||
@@ -710,9 +809,12 @@ html.mobile-init .file-browser-panel {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
|
||||
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
|
||||
and a clipped tab just "w1-", which says nothing about which session it is.
|
||||
The 20px the hidden tab number gave back pays for most of the difference. */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -726,20 +828,19 @@ html.mobile-init .file-browser-panel {
|
||||
difference instead, which costs a little strip space on exactly one tab
|
||||
and keeps tap-to-switch the majority of it.
|
||||
|
||||
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
|
||||
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
|
||||
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
|
||||
further right. The centre clears the icons when
|
||||
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
|
||||
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
|
||||
(above), so every phone tab is that case now; a numbered one would sit 10px
|
||||
further left and hide the problem. The centre clears the icons when
|
||||
|
||||
reserved > icons + rightEdge - leftRunUp - gap
|
||||
= 50 + 9 - 17 - 4 = 38px
|
||||
= 50 + 9 - 19 - 4 = 36px
|
||||
|
||||
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
|
||||
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
|
||||
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
|
||||
clears it at 20px, so reasoning from the tabs on screen is exactly what
|
||||
would put the centre back on the gear. Pinned by
|
||||
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so a centre half a pixel short still
|
||||
lands on the gear: the practical floor was measured at 40px (with the
|
||||
older 4px dot) and 44px keeps headroom. Pinned by
|
||||
test/mobile-tab-tap-zones.test.ts. */
|
||||
.session-tab.active .tab-name {
|
||||
min-width: 44px;
|
||||
|
||||
+320
-10
@@ -20,6 +20,20 @@ const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
||||
// a huge log is a partial read rather than a download the viewer throws away.
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||||
const TEXT_PREVIEW_MAX_LINES = 500;
|
||||
// A markdown document gets the route's ceiling instead of the 500-line preview
|
||||
// cap: a rendered README cut mid-way reads as the whole document.
|
||||
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
|
||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
// File Viewer text-view prefs: per-device, in their own localStorage keys for
|
||||
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
|
||||
// rebuilt from the settings modal on save, so a key toggled from the viewer
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -339,6 +353,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save what had focus before a search-first overlay takes it.
|
||||
*
|
||||
* The Command Palette and the Session Manager both call `search.focus()` on
|
||||
* open, and both used to close by removing the `active` class and nothing
|
||||
* else. Hiding the focused input does not hand focus back to anyone — the
|
||||
* browser drops it on `<body>` — so after Escape closed the overlay every
|
||||
* keystroke went nowhere and the user had to click the terminal to type
|
||||
* again (measured: `document.activeElement` is BODY afterwards and the
|
||||
* terminal emits no onData at all). Every close path has the same hole, so
|
||||
* the restore lives in the close functions, not in the global Escape chain.
|
||||
*
|
||||
* ⚠️ Deliberately only the save/restore half of {@link FocusTrap}, not the
|
||||
* whole thing. `FocusTrap.activate()` moves focus to the first focusable
|
||||
* element, which in both of these overlays is not the search box — adopting
|
||||
* it wholesale would fix the focus loss by breaking the thing Cmd+K exists
|
||||
* for, typing a filter the moment it opens.
|
||||
*/
|
||||
_rememberOverlayFocus(key) {
|
||||
this[key] = (typeof document !== 'undefined' && document.activeElement) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hand focus back to whatever {@link _rememberOverlayFocus} saved.
|
||||
*
|
||||
* ⚠️ The terminal fallback is gated on there being an active session: an
|
||||
* overlay opened from the welcome screen has no terminal to return to, and
|
||||
* focusing one on a phone summons the on-screen keyboard over a screen that
|
||||
* has no input on it.
|
||||
*
|
||||
* ⚠️ A focus that already left the overlay is kept, not overridden. The
|
||||
* Session Manager's row menu ("Switch to session", "Open folder") calls
|
||||
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
|
||||
* restoring there would pull focus back to the header button that opened the
|
||||
* modal. Only a focus still inside `modal`, or one dropped on `<body>`, is
|
||||
* the overlay's to hand back.
|
||||
*/
|
||||
_restoreOverlayFocus(key, modal) {
|
||||
const prev = this[key];
|
||||
this[key] = null;
|
||||
const body = typeof document !== 'undefined' ? document.body : null;
|
||||
const current = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (current && current !== body && modal?.contains?.(current) === false) return;
|
||||
// `isConnected === false` means the element was removed while the overlay
|
||||
// was open (a re-render of the tab strip, say); anything else — including
|
||||
// a stub with no such property — is treated as still focusable.
|
||||
if (prev && prev !== body && prev.isConnected !== false && typeof prev.focus === 'function') {
|
||||
prev.focus();
|
||||
return;
|
||||
}
|
||||
// ⚠️ `activeSessionId` alone only covers the welcome screen. On a touch device
|
||||
// with the keyboard down, focus sits on `<body>`, so focusing the terminal here
|
||||
// would summon the on-screen keyboard — `selectSession()` deliberately skips the
|
||||
// focus for exactly that reason, and this would override it. The app's own
|
||||
// predicate already encodes the rule (true on desktop, on touch only while the
|
||||
// keyboard is open); the optional call keeps the vm test harness working.
|
||||
if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) {
|
||||
this.terminal?.focus?.();
|
||||
}
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
@@ -351,13 +426,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
// BEFORE the steal, not after: `search.focus()` below is what loses the
|
||||
// caller's focus, so the read has to happen while it is still there.
|
||||
this._rememberOverlayFocus('_commandPalettePrevFocus');
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// ⚠️ Bail out when it was not open. The global Escape handler calls this on
|
||||
// EVERY Escape (app.js), in the CAPTURE phase, so an unconditional restore
|
||||
// runs before the focused element's own Escape handler and steals focus into
|
||||
// the terminal: keys typed after Escape in split Pane B land in Pane A, keys
|
||||
// typed in any text field land in the terminal, and the inline tab rename's
|
||||
// Escape fires the input's blur (which commits) before its own handler
|
||||
// (which cancels), turning a cancel into a rename.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
|
||||
},
|
||||
|
||||
_wireCommandPalette() {
|
||||
@@ -618,6 +705,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
this._rememberOverlayFocus('_sessionManagerPrevFocus');
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
@@ -625,7 +713,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// Same guard as closeCommandPalette — see the note there.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_sessionManagerPrevFocus', modal);
|
||||
},
|
||||
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
@@ -4012,6 +4103,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
// Same for the text-view toggles: they act on the text this load has not
|
||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
@@ -4097,12 +4192,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const text = await res.text();
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
|
||||
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
|
||||
// Markdown keeps every line the Range read returned: the byte bound is
|
||||
// what protects the tab, and a rendered document cut at 500 lines
|
||||
// reads as the whole document.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? Infinity : TEXT_PREVIEW_MAX_LINES;
|
||||
const clippedByLines = lines.length > lineCap;
|
||||
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
|
||||
this.filePreviewContent = shown;
|
||||
// attachmentId: a card's filePath is the bare file name, so the
|
||||
// rebase pass must know there is no directory to resolve against.
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -4148,8 +4250,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// 500 lines is what keeps a huge log from locking the tab in one <pre>;
|
||||
// markdown is rendered as a document and takes the route's ceiling instead.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? MARKDOWN_PREVIEW_MAX_LINES : TEXT_PREVIEW_MAX_LINES;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
const res = await fetch(
|
||||
`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=${lineCap}`
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -4177,10 +4284,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
// Text content
|
||||
// Text content: rendered markdown or plain text, per the viewer's toggles.
|
||||
this.filePreviewContent = data.content;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||
this.filePreviewText = { ext, sessionId, filePath };
|
||||
this._renderFilePreviewText();
|
||||
const truncNote = data.truncated ? ` (showing ${lineCap}/${data.totalLines} lines)` : '';
|
||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||
// succeed (workspace text file inside the allowlist and size cap).
|
||||
@@ -4208,6 +4316,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// audible and keeps streaming from the server. Closing has to stop it.
|
||||
this._stopFilePreviewMedia();
|
||||
this.filePreviewContent = '';
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
@@ -4295,6 +4405,204 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_filePreviewPref(name) {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILE_PREVIEW_PREF_KEYS[name]);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
/* private mode: fall through to the default */
|
||||
}
|
||||
return FILE_PREVIEW_PREF_DEFAULTS[name];
|
||||
},
|
||||
|
||||
_setFilePreviewPref(name, on) {
|
||||
try {
|
||||
localStorage.setItem(FILE_PREVIEW_PREF_KEYS[name], on ? '1' : '0');
|
||||
} catch {
|
||||
/* private mode: the toggle still applies for this page load */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paint the loaded text (filePreviewContent) into the preview body: a
|
||||
* rendered document for .md/.markdown while the MD toggle is on, otherwise
|
||||
* plain text with one span per line so the Lines toggle can number them.
|
||||
* The MD toggle re-runs this without a refetch.
|
||||
*
|
||||
* Markdown goes through the same pipeline as the Response Viewer
|
||||
* (`_renderMarkdown`: marked + the DOMPurify allowlist), never a second
|
||||
* parser, and is built inside a <template>: a detached div with innerHTML
|
||||
* already set starts fetching every <img src>, so the document's relative
|
||||
* image paths would hit the server as /docs/img.png 404s before
|
||||
* `_rebaseFilePreviewMarkdownRefs` rewrote them. `breaks: false` because a
|
||||
* file is not a chat message: a paragraph hard-wrapped in the source is one
|
||||
* paragraph, as on GitHub.
|
||||
*/
|
||||
_renderFilePreviewText() {
|
||||
const info = this.filePreviewText;
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
if (!info || !bodyEl) return;
|
||||
const isMarkdown = MARKDOWN_EXTS.has(info.ext);
|
||||
const rendered = isMarkdown && this._filePreviewPref('mdRendered');
|
||||
if (rendered) {
|
||||
// data-i18n-skip: the translator's MutationObserver would otherwise
|
||||
// rewrite the document's own headings and paragraphs.
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent, { breaks: false })}</div>`;
|
||||
const doc = tmpl.content.firstElementChild;
|
||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
||||
this._linkifyFilePaths(doc);
|
||||
// The linkifier's absolute paths name no session; give them the
|
||||
// preview's, like the rebased links, or they open in the active tab's.
|
||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
||||
bodyEl.replaceChildren(tmpl.content);
|
||||
// The Response Viewer's click delegate (path links, code-block copy
|
||||
// buttons, loopback links): container-bound and idempotent, so binding it
|
||||
// on the body once serves every preview.
|
||||
this._bindResponseViewerInteractions(bodyEl);
|
||||
} else {
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'file-preview-text';
|
||||
pre.classList.toggle('wrap', this._filePreviewPref('wrap'));
|
||||
pre.classList.toggle('show-lines', this._filePreviewPref('lineNumbers'));
|
||||
const code = document.createElement('code');
|
||||
// One span per line joined by real newlines: empty lines survive, select
|
||||
// and copy return the exact text, and the gutter counter hangs off the
|
||||
// spans' ::before so the numbers are never part of the text.
|
||||
code.innerHTML = this.filePreviewContent
|
||||
.split('\n')
|
||||
.map((line) => `<span class="fp-line">${escapeHtml(line)}</span>`)
|
||||
.join('\n');
|
||||
pre.appendChild(code);
|
||||
bodyEl.replaceChildren(pre);
|
||||
}
|
||||
this._updateFilePreviewToolbar(rendered ? 'markdown' : 'text');
|
||||
},
|
||||
|
||||
/**
|
||||
* Point a rendered document's workspace references at the file it came from.
|
||||
*
|
||||
* Images are rebased onto the workspace-confined file-raw route under the
|
||||
* document's directory, root-relative ones (`/docs/x.png`) under the
|
||||
* workspace root as on GitHub (the server refuses escapes, so `..` is safe
|
||||
* to forward). Whatever fails to load degrades to its alt text with one
|
||||
* error handler: a remote image the page CSP blocks, a 404 for a document
|
||||
* outside the workspace, an SVG that file-raw serves as a download. Links
|
||||
* take the `a.rv-path` shape the Response Viewer delegate already opens in
|
||||
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
|
||||
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
|
||||
* preview's own session so a document opened from another session's
|
||||
* attachment card resolves against that workspace, not the active tab's.
|
||||
*
|
||||
* A preview opened by attachment id under a bare file name (attachment
|
||||
* cards and the history drawer: the registry keeps no relative path) has no
|
||||
* directory to resolve against, and the workspace root is the wrong one for
|
||||
* docs/report.md and for a file outside the workspace alike. Its workspace
|
||||
* refs degrade instead: images to their alt text, links to their text,
|
||||
* rather than a missing image or a silently different file.
|
||||
*/
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
const isWorkspaceRef = (ref) =>
|
||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
||||
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
|
||||
// `img.png?raw=true`: neither fragment nor query is part of the path.
|
||||
// marked percent-encodes destinations (`my image.png` arrives as
|
||||
// `my%20image.png`), so decode before the route encodes again, or file-raw
|
||||
// looks for a file literally named `my%20image.png`; a malformed escape
|
||||
// keeps the ref as written. `.` and `..` segments are collapsed so the
|
||||
// title reads `README.md`, not `docs/../README.md`; a `..` that climbs
|
||||
// past the start is kept and left for the server to refuse.
|
||||
const resolveRef = (ref) => {
|
||||
let rel = ref.split('#')[0].split('?')[0];
|
||||
try {
|
||||
rel = decodeURIComponent(rel);
|
||||
} catch {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
||||
else parts.push(seg);
|
||||
}
|
||||
return parts.join('/');
|
||||
};
|
||||
for (const img of root.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (unresolvable && isWorkspaceRef(src)) {
|
||||
img.replaceWith(img.getAttribute('alt') || src);
|
||||
continue;
|
||||
}
|
||||
if (isWorkspaceRef(src)) {
|
||||
const path = resolveRef(src);
|
||||
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
|
||||
}
|
||||
img.addEventListener('error', () => img.replaceWith(img.getAttribute('alt') || src), { once: true });
|
||||
}
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!isWorkspaceRef(href)) continue;
|
||||
if (unresolvable) {
|
||||
a.replaceWith(...a.childNodes);
|
||||
continue;
|
||||
}
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = resolveRef(href);
|
||||
a.dataset.sessionId = sessionId;
|
||||
a.setAttribute('href', '#');
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
||||
* image, media, PDF, edit mode) hides all three.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
const btn = this.$(id);
|
||||
if (!btn) return;
|
||||
btn.hidden = !shown;
|
||||
if (shown) btn.setAttribute('aria-pressed', String(pressed));
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
},
|
||||
|
||||
toggleFilePreviewLines() {
|
||||
this._toggleFilePreviewTextClass('lineNumbers', 'show-lines');
|
||||
},
|
||||
|
||||
toggleFilePreviewWrap() {
|
||||
this._toggleFilePreviewTextClass('wrap', 'wrap');
|
||||
},
|
||||
|
||||
/** Lines and Wrap are pure class flips on the <pre>; no re-render needed. */
|
||||
_toggleFilePreviewTextClass(pref, className) {
|
||||
const on = !this._filePreviewPref(pref);
|
||||
this._setFilePreviewPref(pref, on);
|
||||
const pre = this.$('filePreviewBody')?.querySelector(':scope > pre.file-preview-text');
|
||||
if (pre) pre.classList.toggle(className, on);
|
||||
this._updateFilePreviewToolbar('text');
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -4362,6 +4670,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||
bodyEl.innerHTML = '';
|
||||
bodyEl.appendChild(textarea);
|
||||
// The MD/Lines/Wrap toggles act on the text view this textarea replaced.
|
||||
this._updateFilePreviewToolbar('none');
|
||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||
// before the user has scrolled to the line they want to change.
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
/**
|
||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||
* blocks may carry `language-*` classes from marked.
|
||||
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
|
||||
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
|
||||
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
|
||||
* README could break every button until a reload.
|
||||
*/
|
||||
var ALLOWED_ATTR = [
|
||||
'href',
|
||||
@@ -93,7 +96,6 @@
|
||||
'title',
|
||||
'class',
|
||||
'id',
|
||||
'name',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'align',
|
||||
|
||||
@@ -10984,6 +10984,19 @@ kbd {
|
||||
.file-preview-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
/* The title yields on a phone, not the buttons. */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview-actions .btn-icon-sm[aria-pressed='true'] {
|
||||
color: var(--accent, #4ea1ff);
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.file-preview-actions .file-preview-pill {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.file-preview-body {
|
||||
@@ -11008,6 +11021,58 @@ kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---- File Viewer text view: Lines / Wrap toggles ----
|
||||
Child-combinator scoped so none of this leaks into the code blocks of a
|
||||
rendered markdown document, which are <pre>s too. */
|
||||
|
||||
.file-preview-body > pre.file-preview-text {
|
||||
counter-reset: fp-line;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text:not(.wrap) {
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Numbers hug the left edge (4px, left-aligned) instead of sitting behind the
|
||||
pre's own padding right-aligned in a 4ch column, where "1" landed 40px in. */
|
||||
.file-preview-body > pre.file-preview-text.show-lines {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text.show-lines .fp-line::before {
|
||||
counter-increment: fp-line;
|
||||
content: counter(fp-line);
|
||||
display: inline-block;
|
||||
min-width: 4ch;
|
||||
margin-right: 1ch;
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- File Viewer rendered markdown ----
|
||||
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
|
||||
carries no padding or base font (the chat card supplies those). */
|
||||
|
||||
.file-preview-body > .file-preview-md {
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
/* Relative links in the document are rebased onto a.rv-path so the Response
|
||||
Viewer delegate opens them here; keep them reading as prose, not as paths.
|
||||
Three-class selector on purpose: `.rv-text a.rv-path` (the monospace path
|
||||
style) is declared later in this file and ties on specificity otherwise. */
|
||||
.file-preview-body .file-preview-md a.rv-path {
|
||||
font: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.file-preview-body img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
@@ -68,10 +71,18 @@
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._wsClosed = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
||||
// are held there with their arrival time instead of written under it.
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -95,6 +106,8 @@
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this._installWheelListener();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
@@ -254,9 +267,9 @@
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
this._onLiveOutput(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
this._onLiveClear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
@@ -280,16 +293,30 @@
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
this.ws.onclose = () => this._onSocketClosed();
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// The socket's close, split out of connect() so the tests can drive it.
|
||||
// While a history pull is running the marker waits for the pull's finally
|
||||
// block: written now, it would sit above the output the pull is still
|
||||
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
|
||||
// land in the middle of a chunked replay.
|
||||
_onSocketClosed() {
|
||||
this._wsReady = false;
|
||||
this._wsClosed = true;
|
||||
if (!this._liveQueue) this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a history pull that ends on a
|
||||
// closed socket can write it (see _pullHistory()'s finally block).
|
||||
_writeDisconnectedMarker() {
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
@@ -324,14 +351,168 @@
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||
_endBufferLoad() {
|
||||
this._bufferLoading = false;
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Live terminal output. Written straight through, except while a history
|
||||
// pull is replaying: a capture is current only up to the instant tmux took
|
||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
||||
// _finishBufferLoad `since` rule), never written underneath it.
|
||||
_onLiveOutput(data) {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||
else this.terminal?.write(data);
|
||||
}
|
||||
|
||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this.terminal?.clear();
|
||||
}
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||
// xterm keeps doing the scrolling.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||
};
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||
// so this pane's xterm ends up with about one screen of scrollback while
|
||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||
// history was unreachable. The primary pane has the same pull
|
||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||
// tmux the browser xterm never enters the alternate buffer.
|
||||
_maybeLoadMoreHistory() {
|
||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||
if (this._bufferLoading) return;
|
||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||
// _sendResize(): a detached session's own window already owns its PTY
|
||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const active = this.terminal.buffer.active;
|
||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||
// rather than latch; a pull that could only have downgraded the pane
|
||||
// waits far longer.
|
||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||
const now = Date.now();
|
||||
if (now - this._historyPullAt < cooldown) return;
|
||||
this._historyPullAt = now;
|
||||
void this._pullHistory();
|
||||
}
|
||||
|
||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||
// main thread) and replays it under the reader's current place. Holds the
|
||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||
async _pullHistory() {
|
||||
// A close before the pull already wrote its marker; one during it did not.
|
||||
const closedBefore = this._wsClosed;
|
||||
this._bufferLoading = true;
|
||||
this._liveQueue = [];
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
try {
|
||||
// A deadline, because live output is held for as long as this runs: a
|
||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
||||
// lands in the catch below, which releases the flag and the queue. It
|
||||
// covers the body read too, not just the headers.
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
||||
});
|
||||
// The cutoff below is the response's arrival, the same `since` rule the
|
||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||
// for the instant tmux took the capture, which lies somewhere in the
|
||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||
// by one round trip and not closable without a server-side capture time.
|
||||
capturedAt = performance.now();
|
||||
const payload = (await res.json())?.data;
|
||||
const buffer = payload?.terminalBuffer;
|
||||
const term = this.terminal;
|
||||
if (!buffer || !term || this._destroyed) return;
|
||||
const rowsBefore = term.buffer.active.length;
|
||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||
// lines, so a window of short lines can carry more rows than this pane
|
||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||
const scrollbackCap = term.options?.scrollback || 0;
|
||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||
// Nothing to gain (this also covers a downgrade, which would delete
|
||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||
// untruncated window IS all of tmux's history and the next burst can add
|
||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||
// what the pane shows, and every ask costs the server a capture-pane of
|
||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||
// off too, since no window can ever fit in it.
|
||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||
return;
|
||||
}
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
replayed = true;
|
||||
await writeChunked(term, buffer, () => this._destroyed);
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// xterm parses asynchronously: an empty write's callback fires only
|
||||
// after everything before it, so the row count below is the settled one.
|
||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||
// rows down; land there and the recovered history sits above it.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
} catch {
|
||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||
} finally {
|
||||
const queued = this._liveQueue ?? [];
|
||||
this._liveQueue = null;
|
||||
// After a replay, only frames that arrived after the capture are news;
|
||||
// earlier ones are already in it. With no replay, every held frame is.
|
||||
const cutoff = replayed ? capturedAt : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < cutoff) continue;
|
||||
if (entry.clear) this.terminal?.clear();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// A replay's own `\x1bc` wipes a marker written before the pull,
|
||||
// painting a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket, so re-stamp it
|
||||
// after a replay. A close DURING the pull wrote no marker at all
|
||||
// (_onSocketClosed() defers it while the queue is live), so write it
|
||||
// whether or not this pull replayed. Checked after the queue flush so
|
||||
// it is the last thing on screen, matching what the close would have
|
||||
// left had the pull never run.
|
||||
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
@@ -381,6 +562,10 @@
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this._onWheel) {
|
||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||
this._onWheel = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
|
||||
@@ -650,8 +650,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// wheel reports (claude 2.1.187+ while it tracks the mouse, which only its
|
||||
// fullscreen renderer does; see _shouldForwardWheelToApp), local scrollback
|
||||
// otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||
// live and stay clickable — and its select menus no longer capture wheel
|
||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||
@@ -723,7 +724,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
|
||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||
// opt-out setting and the CLI-version gate apply to touch exactly as they
|
||||
// opt-out setting and the version/tracking gate apply to touch exactly as they
|
||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||
// when that gate is false and there is no local scrollback to scroll
|
||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||
@@ -3304,9 +3305,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
||||
* automatic pulls because their captures can be large; their banner button is
|
||||
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions pull a
|
||||
* bounded window because their captures can be large; their banner button is
|
||||
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
||||
* the resulting position, and it is deliberately not folded into
|
||||
* _noteTerminalUserScroll for exactly that reason.
|
||||
*/
|
||||
@@ -3354,13 +3355,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||
* Only a capture that is worse by more than a full screen counts as a
|
||||
* downgrade, which leaves every genuine recovery case untouched.
|
||||
*
|
||||
* A caller that already estimated the capture's rows passes them as
|
||||
* `estimatedRows`, so a megabyte capture is not scanned twice.
|
||||
*/
|
||||
_replayWouldShrinkBuffer(capture) {
|
||||
_replayWouldShrinkBuffer(capture, estimatedRows) {
|
||||
const term = this.terminal;
|
||||
const rowsNow = term?.buffer?.active?.length || 0;
|
||||
if (!rowsNow) return false;
|
||||
const screen = term?.rows || 24;
|
||||
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||
const rows = estimatedRows ?? this._estimateReplayRows(capture, term?.cols);
|
||||
return rows + screen < rowsNow;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5082,9 +5087,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports — which today is claude 2.1.187+
|
||||
// and nothing else (older Claude Code captures wheel as select-menu option
|
||||
// navigation; an unknown version is treated as older). Gemini and codex are
|
||||
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
|
||||
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
|
||||
// captures wheel as select-menu option navigation, an unknown version is
|
||||
// treated as older, and inline Claude ignores it). Gemini and codex are
|
||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||
// for them (harmless no-ops at worst).
|
||||
//
|
||||
@@ -5151,6 +5157,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude is actually listening for the mouse. In its default
|
||||
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
|
||||
// transcript lives in real scrollback, like codex, and SGR wheel reports are
|
||||
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
|
||||
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
|
||||
// turns on alt-screen + mode 1003/1006, which the server records as
|
||||
// cliMouseTracking. A stale-false flag after a server restart falls through
|
||||
// to _maybePageCliTranscript, so it never goes dead.
|
||||
if (session?.cliMouseTracking !== true) return false;
|
||||
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
||||
// that leaving the bottom handed the wheel back to local scrollback and both
|
||||
// histories stayed reachable without a mode switch. In practice that inverted
|
||||
@@ -5224,11 +5239,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
||||
* on a Claude session that has no local history to fall back on: the CLI
|
||||
* version probe failed or is genuinely older than 2.1.187, or the user turned
|
||||
* on "Wheel scrolls local history" (which pins the wheel to a buffer that,
|
||||
* for a repaint-mode CLI, is empty — the setting's footgun). Before this, all
|
||||
* of those produced a completely dead gesture; the #205 reporter proved the
|
||||
* keyboard route works by paging back through intact text with Fn+Up.
|
||||
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
|
||||
* tracking flag is unset (the inline renderer, or fullscreen right after a
|
||||
* server restart), or the user turned on "Wheel scrolls local history" (which
|
||||
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
|
||||
* setting's footgun). Before this, all of those produced a completely dead
|
||||
* gesture; the #205 reporter proved the keyboard route works by paging back
|
||||
* through intact text with Fn+Up.
|
||||
*
|
||||
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
||||
@@ -5272,14 +5289,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions?.get(sessionId);
|
||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||
// xterm's own mode above stays 'none' for a strip mode (the server removes
|
||||
// the DECSETs), so the CLI's real tracking state is reported separately.
|
||||
const cliTracking = session?.cliMouseTracking === true;
|
||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
|
||||
const signature =
|
||||
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`;
|
||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||
this._scrollRoutingLogged.set(sessionId, signature);
|
||||
console.log(
|
||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` +
|
||||
`localScrollbackRows=${baseY})`
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -274,7 +274,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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 : '/' });
|
||||
void this.openWebview(id, {
|
||||
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
|
||||
auto: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -356,15 +359,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* @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.
|
||||
* 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();
|
||||
@@ -513,7 +524,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
this.openWebview(next, { auto: true });
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
|
||||
@@ -437,6 +437,26 @@ export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?:
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an input body is a plain prompt: printable text followed by exactly one
|
||||
* carriage return, and nothing else.
|
||||
*
|
||||
* That is the shape a script, a bot or a curl call sends to submit a prompt, and the
|
||||
* one that must NOT be written into the pane in one piece. Measured on Claude Code
|
||||
* 2.1.283 (2026-09-28): a direct write of `<text>\r` arrives as a single burst, and a
|
||||
* burst of about a hundred characters or more is taken as a paste, so its trailing
|
||||
* `\r` lands as a NEWLINE in the composer and the prompt sits there unsent. A later
|
||||
* bare `\r` written the same way does not recover it; a tmux `send-keys Enter` does.
|
||||
* Short bursts (tens of characters) submit, which is why the failure looked random.
|
||||
*
|
||||
* Anything with another control character (escape sequences, a bracketed-paste frame,
|
||||
* a line feed, a tab, C1 controls) is raw terminal input and keeps the direct write.
|
||||
*/
|
||||
export function isPlainPromptInput(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
return /^[^\x00-\x1f\x7f-\x9f]+\r$/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session state and broadcast a SessionUpdated event.
|
||||
* Replaces the repeated two-line pattern across route handlers.
|
||||
|
||||
@@ -210,13 +210,22 @@ const installsInFlight = new Set<string>();
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs.
|
||||
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
|
||||
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
|
||||
* so an `npm install -g` lands on the persistent home mount instead of the image.
|
||||
*/
|
||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
}
|
||||
// ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE
|
||||
// content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...)
|
||||
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
|
||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||
@@ -1754,7 +1754,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
|
||||
// Shared with the attachment registry so a video plays the same whether it
|
||||
// sits in the workspace or is reached by id from outside it.
|
||||
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
||||
@@ -2081,6 +2081,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
mp4: 'video/mp4',
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isPlainPromptInput,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
@@ -1835,10 +1836,18 @@ export function registerSessionRoutes(
|
||||
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
||||
let delivered = false;
|
||||
|
||||
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
|
||||
// ask for it: written straight into the pane it arrives as one burst, and Claude
|
||||
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
|
||||
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
|
||||
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
|
||||
// raw write for a caller that really wants it.
|
||||
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
|
||||
|
||||
if (duplicate) {
|
||||
// Redelivery of an already-applied input: skip the write, but still honor the
|
||||
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
||||
} else if (useMux && waitPromise) {
|
||||
} else if ((useMux || autoMux) && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
@@ -1849,6 +1858,16 @@ export function registerSessionRoutes(
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (autoMux) {
|
||||
// Awaited, unlike the explicit useMux branch below. This shape also reaches here
|
||||
// from the browser's POST fallback, which sends its frames one at a time and
|
||||
// waits for each 2xx; answering only once Enter has gone out is what keeps the
|
||||
// next keystroke from overtaking it.
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
if (!ok) {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
|
||||
@@ -165,7 +165,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'created',
|
||||
id: created.id,
|
||||
owner: ownerLayoutKey(created.owner),
|
||||
});
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
@@ -195,7 +199,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'updated',
|
||||
id,
|
||||
owner: ownerLayoutKey(updated.owner),
|
||||
});
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
@@ -231,7 +239,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'deleted',
|
||||
id,
|
||||
owner: ownerLayoutKey(result.owner),
|
||||
});
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
import { SseStreamManager } from './sse-stream-manager.js';
|
||||
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
|
||||
import { deriveWebviewSseHint } from './webview-sse.js';
|
||||
import {
|
||||
type SessionListenerRefs,
|
||||
createSessionListeners,
|
||||
@@ -2461,6 +2462,12 @@ export class WebServer extends EventEmitter {
|
||||
if (event.startsWith('tab:')) {
|
||||
return deriveTabLayoutSseHint(data);
|
||||
}
|
||||
// Saved-webview invalidations carry the trusted resource owner. Route them to
|
||||
// that owner (plus admins), so an admin editing a user's web tab notifies the
|
||||
// user, and no other user learns the ids of someone else's web tabs.
|
||||
if (event.startsWith('webview:')) {
|
||||
return deriveWebviewSseHint(data);
|
||||
}
|
||||
// Session-scoped families: resolve the owner from the payload's session id.
|
||||
const SESSION_PREFIXES = [
|
||||
'session:',
|
||||
|
||||
@@ -481,8 +481,10 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id, owner }`. The client
|
||||
* re-fetches the list rather than patching from the payload. `owner` is the web
|
||||
* tab's owner (`'@single'` when multi-user mode is off); in multi-user mode the
|
||||
* event is delivered only to that owner and admins (`deriveWebviewSseHint`). */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
|
||||
export const TabLayoutChanged = 'tab:layoutChanged' as const;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @fileoverview Trusted owner routing metadata for saved-webview invalidations. */
|
||||
import type { SseRoutingHint } from './sse-stream-manager.js';
|
||||
|
||||
export function deriveWebviewSseHint(data: unknown): SseRoutingHint {
|
||||
return { username: (data as { owner?: string }).owner, sessionScoped: true };
|
||||
}
|
||||
Reference in new issue
Block a user