mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(preview): open ~/, relative and line-broken file paths
Terminal and response-viewer links only knew rooted absolute paths. Agent output is full of the other forms: Claude Code echoes an attached image as `~/repos/x/garage.png`, scripts print `builds/captures/x.png`, and Claude Code wraps tool output a few columns short of the edge, so a long path arrives as `.../captures/2` + `026-10-10/x.png` and only its prefix linked. - constants.js: RELATIVE_FILE_PATH_LINK_PATTERN + findFilePathLinks() (one entry point for both consumers; never inside an http(s) URL), resolveLinkedFilePath() (relative -> session workingDir, ~/ as is), terminalPathLine() (glues a path a program broke over lines; guarded so ordinary short lines are never joined). - terminal-ui.js: provider uses them, no overlapping links, opens the resolved path. Touch selection keeps terminalLogicalLine. - app.js: response viewer links the same forms; only its own links are resolved, a markdown document's rebased links stay as they were. - Server: registerExternalAttachment accepts ~/ (local homedir(); remote probe expands $HOME on the host). Guards run on the resolved path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
aa3730aace
commit
7d46a9d336
9 files changed
+609
-38
No files matched your search
@@ -9,7 +9,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, extname, isAbsolute, join } from 'node:path';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
@@ -330,13 +331,19 @@ export async function registerExternalAttachment(
|
||||
requestedPath: string,
|
||||
options: RegisterExternalAttachmentOptions = {}
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
if (!requestedPath || !isAbsolute(requestedPath)) {
|
||||
// `~/…` is the home of the session's HOST: expanded here for a local session,
|
||||
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
|
||||
// whose home this server does not know. A clicked terminal link sends it as is
|
||||
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
|
||||
// resolved absolute path either way.
|
||||
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
|
||||
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||
}
|
||||
|
||||
const resolved = await (options.remote
|
||||
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
|
||||
: resolveLocalAttachment(requestedPath));
|
||||
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
|
||||
|
||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||
// path before doing anything else.
|
||||
|
||||
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
|
||||
* `echo` (which land before the script runs) from the records without any "last N
|
||||
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
||||
*
|
||||
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
|
||||
* server does not know that home, and a clicked `~/…` link means the host the
|
||||
* session runs on. The record's `realPath` is absolute, as for any other path.
|
||||
*
|
||||
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
||||
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
||||
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
||||
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||
'probe() {',
|
||||
' i=$1',
|
||||
' p=$2',
|
||||
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
|
||||
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
|
||||
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
||||
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
|
||||
+16
-13
@@ -2744,7 +2744,12 @@ class CodemanApp {
|
||||
const filePath = pathLink.dataset.path;
|
||||
// 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);
|
||||
// A path the linkifier found in prose may be relative: resolve it against
|
||||
// that session's working directory. A markdown document's own links
|
||||
// (rebased, no `linkified` mark) already are what the routes expect.
|
||||
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
|
||||
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
|
||||
if (filePath) this.openFilePreview(target || filePath, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2862,7 +2867,8 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make absolute file paths in a rendered message clickable.
|
||||
* Make file paths in a rendered message clickable: absolute, `~/` and
|
||||
* relative ones (`findFilePathLinks`, constants.js).
|
||||
*
|
||||
* The terminal's link provider never sees these: the response viewer is
|
||||
* markdown, and a path the agent wrote as prose or inline code renders as
|
||||
@@ -2880,8 +2886,7 @@ class CodemanApp {
|
||||
if (!root || typeof document === 'undefined') return;
|
||||
// Guarded: a stale cached constants.js must degrade to plain text, not throw
|
||||
// out of the middle of rendering a message.
|
||||
if (typeof absoluteFilePathPattern !== 'function') return;
|
||||
const pattern = absoluteFilePathPattern();
|
||||
if (typeof findFilePathLinks !== 'function') return;
|
||||
|
||||
// Collect first: replacing a node while the walker is positioned on it
|
||||
// invalidates the traversal.
|
||||
@@ -2889,27 +2894,25 @@ class CodemanApp {
|
||||
const targets = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (node.parentElement?.closest('a')) continue;
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(node.nodeValue || '')) targets.push(node);
|
||||
const found = findFilePathLinks(node.nodeValue || '');
|
||||
if (found.length > 0) targets.push([node, found]);
|
||||
}
|
||||
|
||||
for (const node of targets) {
|
||||
for (const [node, found] of targets) {
|
||||
const value = node.nodeValue;
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
let match;
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
const path = match[1];
|
||||
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
|
||||
for (const { path, index } of found) {
|
||||
if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
|
||||
const link = document.createElement('a');
|
||||
link.className = 'rv-path';
|
||||
link.href = '#';
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
cursor = match.index + path.length;
|
||||
cursor = index + path.length;
|
||||
}
|
||||
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
|
||||
node.parentNode?.replaceChild(frag, node);
|
||||
|
||||
+216
-2
@@ -1976,6 +1976,95 @@ function previewsInFileViewer(filePath) {
|
||||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
|
||||
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
|
||||
* or a bare `look_montage.png`. Same extension list and body as
|
||||
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
|
||||
* equal).
|
||||
*
|
||||
* Without a known root to anchor on, the START is what has to be unambiguous:
|
||||
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
|
||||
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
|
||||
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
|
||||
* a URL. The path itself is group 2. Its first character is never `/`: absolute
|
||||
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
|
||||
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
|
||||
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
|
||||
* the file, not the punctuation around it.
|
||||
*
|
||||
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\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;
|
||||
|
||||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
|
||||
/**
|
||||
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
|
||||
* then `~/` and relative ones that do not overlap them or a URL.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
* viewer), so a path form is clickable in both or in neither.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{path: string, index: number}>} `index` is where `path` starts in `text`.
|
||||
*/
|
||||
function findFilePathLinks(text) {
|
||||
const value = String(text || '');
|
||||
const found = [];
|
||||
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
|
||||
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
|
||||
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
|
||||
// (`file:///home/x.png` is not one of them: its path is the file.)
|
||||
const urls = [];
|
||||
const urlPattern = /\bhttps?:\/\/\S+/gi;
|
||||
let match;
|
||||
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
|
||||
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
|
||||
|
||||
const absolute = absoluteFilePathPattern();
|
||||
while ((match = absolute.exec(value)) !== null) {
|
||||
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
|
||||
}
|
||||
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
|
||||
while ((match = relative.exec(value)) !== null) {
|
||||
const path = match[2];
|
||||
const index = match.index + match[1].length;
|
||||
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
||||
found.push({ path, index });
|
||||
}
|
||||
return found.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a clicked path points, as the preview routes need it.
|
||||
*
|
||||
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
|
||||
* session's HOST, which only the server knows (a remote case's home is on the
|
||||
* remote machine), so the server expands it. A relative path is resolved
|
||||
* against the session's working directory, `.` and `..` collapsed, so a
|
||||
* `../other/x.png` ends up outside the workspace and routes like any other
|
||||
* external path. Without a working directory it is returned as is.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {string} [workingDir]
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveLinkedFilePath(filePath, workingDir) {
|
||||
const path = String(filePath || '');
|
||||
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
|
||||
const parts = [];
|
||||
for (const part of `${workingDir}/${path}`.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
||||
@@ -2080,6 +2169,125 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
|
||||
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
|
||||
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
|
||||
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
|
||||
|
||||
/**
|
||||
* Whether the line `nextText` carries on a file path `prevText` broke off.
|
||||
*
|
||||
* Answers `'join'` when the path ends (reaches a known extension) on the next
|
||||
* line, `'chain'` when the next line is nothing but more of the path (a path
|
||||
* spanning three or more lines; only worth keeping once a later line ends it),
|
||||
* and `null` otherwise. The broken-off token must already look like a path in
|
||||
* progress: it contains a `/`, ends on a path character and does not yet end
|
||||
* on an extension. A next line that opens a NEW rooted path (`/home/…`, `~/…`)
|
||||
* is never glued on.
|
||||
*/
|
||||
function pathContinuation(prevText, nextText) {
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const next = String(nextText || '').replace(/^\s+/, '');
|
||||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head)) return null;
|
||||
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
|
||||
return head === next.replace(/\s+$/, '') ? 'chain' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link terminalLogicalLine}, extended across the line breaks a program puts
|
||||
* INSIDE a file path. Used by the link provider only; touch selection keeps the
|
||||
* plain logical line.
|
||||
*
|
||||
* Claude Code wraps its tool output itself, a few columns short of the terminal
|
||||
* edge, so neither continuation signal of terminalLogicalLine fires (no
|
||||
* `isWrapped`, the row does not reach the last column) and a long path was only
|
||||
* linked up to the break:
|
||||
*
|
||||
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
|
||||
* 026-10-10/ghost1b_gameplay.png err=0 …
|
||||
*
|
||||
* A following logical line is glued on (its indent dropped, cell mapping kept
|
||||
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
||||
* broke off is at least as long as the row it continues on. Bounded by the same
|
||||
* `maxRows` as the logical line.
|
||||
*
|
||||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||||
*/
|
||||
function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
const base = terminalLogicalLine(buffer, row, cols, maxRows);
|
||||
if (!base) return null;
|
||||
const bound = Math.max(1, maxRows || 12);
|
||||
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
|
||||
const rowsOf = (line) => line.endRow - line.startRow + 1;
|
||||
// A program breaking a long token fills the row to its wrap width, and no
|
||||
// later row of that block is wider. So the row a path broke off is at least as
|
||||
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
|
||||
// to a longer ` file.png written` below it.
|
||||
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
|
||||
const continues = (prev, next, prevText) =>
|
||||
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
|
||||
|
||||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||||
const extend = (first) => {
|
||||
const parts = [first];
|
||||
let text = first.text;
|
||||
let rows = rowsOf(first);
|
||||
let confirmed = 1;
|
||||
for (;;) {
|
||||
const next = lineAt(parts[parts.length - 1].endRow + 1);
|
||||
if (!next || rows + rowsOf(next) > bound) break;
|
||||
const kind = continues(parts[parts.length - 1], next, text);
|
||||
if (!kind) break;
|
||||
parts.push(next);
|
||||
text += next.text.replace(/^\s+/, '');
|
||||
rows += rowsOf(next);
|
||||
if (kind === 'join') confirmed = parts.length;
|
||||
}
|
||||
return parts.slice(0, confirmed);
|
||||
};
|
||||
|
||||
// The hovered row may be the second or third line of a broken path: walk back
|
||||
// to where it could start, then let the forward pass decide what really joins.
|
||||
let first = base;
|
||||
let rows = rowsOf(base);
|
||||
while (first.startRow > 0) {
|
||||
const prev = lineAt(first.startRow - 1);
|
||||
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
||||
first = prev;
|
||||
rows += rowsOf(prev);
|
||||
}
|
||||
let parts = extend(first);
|
||||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const segments = [];
|
||||
let text = '';
|
||||
for (const [i, part] of parts.entries()) {
|
||||
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
|
||||
segments.push({ part, textStart: text.length, skip });
|
||||
text += part.text.slice(skip);
|
||||
}
|
||||
const offsetToCell = (offset) => {
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
const seg = segments[i];
|
||||
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
|
||||
}
|
||||
return segments[0].part.offsetToCell(offset);
|
||||
};
|
||||
const cellToOffset = (targetRow, targetCol) => {
|
||||
for (const seg of segments) {
|
||||
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
|
||||
const offset = seg.part.cellToOffset(targetRow, targetCol);
|
||||
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2992,8 +3200,14 @@ function describeSessionHarness(session, catalog) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanFilePaths = {
|
||||
absoluteFilePathPattern,
|
||||
findFilePathLinks,
|
||||
resolveLinkedFilePath,
|
||||
previewsInFileViewer,
|
||||
FILE_PREVIEW_EXTENSIONS,
|
||||
};
|
||||
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
|
||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
|
||||
@@ -4037,6 +4037,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* unknown workingDir answers false, leaving the historical path untouched.
|
||||
*/
|
||||
_isExternalPreviewPath(filePath, sessionId) {
|
||||
// `~/…` (a terminal or response-viewer link): only the server knows the home
|
||||
// of the session's host, and only the attachment route expands it.
|
||||
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
|
||||
@@ -1918,6 +1918,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* A clicked path as the preview needs it: relative paths resolved against the
|
||||
* session's working directory, absolute and `~/` paths unchanged
|
||||
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
|
||||
* provider and the response viewer, so one path opens the same file in both.
|
||||
*/
|
||||
_resolveLinkedFilePath(filePath, sessionId) {
|
||||
if (typeof resolveLinkedFilePath !== 'function') return filePath;
|
||||
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
@@ -1970,10 +1981,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// both continuation kinds, the indent a hard wrap leaves on its
|
||||
// continuation, and the offset↔cell mapping — because touch selection
|
||||
// measures the SAME lines and the two must not disagree.
|
||||
// `terminalPathLine` adds one thing on top, for links only: a path the
|
||||
// program broke over lines itself (Claude Code wraps tool output short of
|
||||
// the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png`
|
||||
// links as the one file it is.
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
const logical = window.CodemanTerminalLines?.terminalPathLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
terminal.cols,
|
||||
@@ -1991,12 +2006,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { x: cell.col + 1, y: cell.row + 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
// A path needs a `/` or an extension's `.`; a line with neither has no link.
|
||||
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const links = [];
|
||||
// Text offsets of every link so far: a later pattern must not cut a
|
||||
// second, overlapping link out of one already found (a relative `b.png`
|
||||
// inside a URL's `?img=b.png`).
|
||||
const taken = [];
|
||||
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
//
|
||||
@@ -2016,6 +2037,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
taken.push({ start: startCol, end: startCol + cleaned.length });
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
@@ -2045,15 +2067,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
|
||||
// Pattern 2: Paths with common extensions — absolute, `~/` and relative
|
||||
// (`findFilePathLinks`, constants.js). Image/PDF/media extensions are
|
||||
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
|
||||
// screenshots an agent just wrote are clickable; those open the file
|
||||
// preview rather than the log viewer (see addLink).
|
||||
//
|
||||
// The literal lives in constants.js because the response viewer linkifies
|
||||
// the SAME paths out of markdown — one definition, two consumers. A fresh
|
||||
// instance per call: `lastIndex` is per-object state.
|
||||
const extPattern = absoluteFilePathPattern();
|
||||
// The patterns live in constants.js because the response viewer linkifies
|
||||
// the SAME paths out of markdown — one definition, two consumers.
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
@@ -2064,8 +2085,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
// Skip if already have link at this position, or one overlapping it
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
if (isTaken(startCol, filePath.length)) return;
|
||||
taken.push({ start: startCol, end: startCol + filePath.length });
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
@@ -2088,12 +2111,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
//
|
||||
// A relative path is resolved against the session's working
|
||||
// directory first, and a `~/` path always previews (only the
|
||||
// server knows that host's home, and the log viewer cannot ask).
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
self.openLogViewerWindow(target, sessionId);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
@@ -2117,9 +2145,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
addLink(match[2], match.index);
|
||||
}
|
||||
|
||||
extPattern.lastIndex = 0;
|
||||
while ((match = extPattern.exec(lineText)) !== null) {
|
||||
addLink(match[1], match.index);
|
||||
for (const found of findFilePathLinks(lineText)) {
|
||||
addLink(found.path, found.index);
|
||||
}
|
||||
|
||||
bashPattern.lastIndex = 0;
|
||||
|
||||
Reference in new issue
Block a user