mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #735: fix(links): preserve wrapped configuration filenames
This commit is contained in:
commit
76e9f364f9
12 files changed
+500
-38
No files matched your search
+68
-25
@@ -2111,7 +2111,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|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -2164,14 +2164,14 @@ function previewsInFileViewer(filePath) {
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|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`, sorted by position. Spaced candidates
|
||||
* reserve their whole span first, even when unlinkable; rooted, `~/`, relative
|
||||
* reserve their whole span first, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative
|
||||
* and folder detectors then skip reserved spans, earlier matches and URLs.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
@@ -2200,19 +2200,25 @@ function findFilePathLinks(text) {
|
||||
// A path with spaces is taken whole, or the span it covers links nothing
|
||||
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
|
||||
const reserved = [];
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
const reserve = (spaced, index) => {
|
||||
if (!spaced) return;
|
||||
const start = index + spaced.index;
|
||||
if (inUrl(start, spaced.path.length) || isReserved(start, spaced.path.length)) return;
|
||||
reserved.push({ index: start, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index: start });
|
||||
};
|
||||
let lineStart = 0;
|
||||
for (const line of value.split('\n')) {
|
||||
const spaced = spacedFilePathOnLine(line);
|
||||
if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
|
||||
const index = lineStart + spaced.index;
|
||||
reserved.push({ index, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index });
|
||||
reserve(spacedFilePathOnLine(line), lineStart);
|
||||
// Parentheses bound an inline path just as a whole line bounds a standalone path.
|
||||
const parenthesised = /\(([^()\r\n]{1,1000})\)/g;
|
||||
while ((match = parenthesised.exec(line)) !== null) {
|
||||
reserve(spacedFilePathOnLine(match[1], { parenthesised: true }), lineStart + match.index + 1);
|
||||
}
|
||||
lineStart += line.length + 1;
|
||||
}
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
|
||||
const absolute = absoluteFilePathPattern();
|
||||
while ((match = absolute.exec(value)) !== null) {
|
||||
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
|
||||
@@ -2246,8 +2252,9 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
|
||||
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
|
||||
* wrong targets. A space is only safe inside a path where the line itself bounds
|
||||
* the path, so a spaced path is recognised in exactly these shapes:
|
||||
* the path or a parenthesised group bounds it, so a spaced path is recognised in these shapes:
|
||||
*
|
||||
* Updated (/mnt/c/My Docs/report.docx) inside parentheses in prose
|
||||
* /mnt/c/My Docs/report.docx the whole line is the path
|
||||
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
|
||||
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
|
||||
@@ -2261,7 +2268,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* it ends on a known extension, a rooted one has a directory before its first
|
||||
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
|
||||
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has
|
||||
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
|
||||
* a run of spaces (a column gap) is refused, so two paths on a line are never
|
||||
* merged. A relative candidate whose only `/` word is its last is prose after the
|
||||
@@ -2283,9 +2290,10 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* Linear: one bounded prefix match, then string scans.
|
||||
*
|
||||
* @param {string} line One line, no newline.
|
||||
* @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only.
|
||||
* @returns {{path: string, index: number, link: boolean} | null}
|
||||
*/
|
||||
function spacedFilePathOnLine(line) {
|
||||
function spacedFilePathOnLine(line, options = {}) {
|
||||
const text = String(line || '');
|
||||
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
|
||||
const index = prefix[0].length;
|
||||
@@ -2309,6 +2317,7 @@ function spacedFilePathOnLine(line) {
|
||||
}
|
||||
for (const word of words.slice(0, -1)) {
|
||||
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
|
||||
if (options.parenthesised && word.endsWith(',')) return null;
|
||||
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
|
||||
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
|
||||
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
|
||||
@@ -2562,9 +2571,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]
|
||||
* top directory the broken token did (the next entry of a listing).
|
||||
*/
|
||||
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 previous = String(prevText || '').trimEnd();
|
||||
const next = String(nextText || '').replace(/^\s+/, '');
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
||||
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
||||
@@ -2591,7 +2601,9 @@ function pathContinuation(prevText, nextText) {
|
||||
* 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.
|
||||
* `maxRows` as the logical line. Hard breaks concatenate without inserting spaces.
|
||||
* An ambiguous parenthesised filename also carries `pathAlternatives` for a
|
||||
* guarded lookup on activation; its text and cell mapping remain unchanged.
|
||||
*
|
||||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||||
*/
|
||||
@@ -2612,6 +2624,7 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||||
const extend = (first) => {
|
||||
const parts = [first];
|
||||
const alternatives = [];
|
||||
let text = first.text;
|
||||
let rows = rowsOf(first);
|
||||
let confirmed = 1;
|
||||
@@ -2621,25 +2634,41 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
const kind = continues(parts[parts.length - 1], next, text);
|
||||
if (!kind) break;
|
||||
parts.push(next);
|
||||
if (kind === 'join') {
|
||||
const opening = text.lastIndexOf('(');
|
||||
const closing = next.text.indexOf(')');
|
||||
if (opening >= 0 && closing > 0) {
|
||||
const before = text.slice(opening + 1);
|
||||
const after = next.text.slice(0, closing).trimStart();
|
||||
const spaced = spacedFilePathOnLine(`${before} ${after}`, { parenthesised: true });
|
||||
if (!before.includes(')') && !/[/-]$/.test(before) && !after.includes('/') && spaced?.link) {
|
||||
alternatives.push({ path: before.slice(spaced.index) + after, spacedPath: spaced.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
text += next.text.replace(/^\s+/, '');
|
||||
rows += rowsOf(next);
|
||||
if (kind === 'join') confirmed = parts.length;
|
||||
}
|
||||
return parts.slice(0, confirmed);
|
||||
return { parts: parts.slice(0, confirmed), alternatives };
|
||||
};
|
||||
|
||||
// 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.
|
||||
// A middle fragment may have no slash, so only a forward pass from its
|
||||
// rooted start can confirm a path spanning three or more rows.
|
||||
let extended = extend(base);
|
||||
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;
|
||||
if (!prev || rows + rowsOf(prev) > bound) break;
|
||||
first = prev;
|
||||
rows += rowsOf(prev);
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(first.text.trimEnd())?.[0] || '';
|
||||
if (!tail.includes('/')) continue;
|
||||
const candidate = extend(first);
|
||||
if (candidate.parts[candidate.parts.length - 1].endRow >= base.startRow) extended = candidate;
|
||||
}
|
||||
let parts = extend(first);
|
||||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||||
const { parts, alternatives } = extended;
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const segments = [];
|
||||
@@ -2664,7 +2693,14 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow: parts[0].startRow,
|
||||
endRow: parts[parts.length - 1].endRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(alternatives.length ? { pathAlternatives: alternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2920,7 +2956,14 @@ function terminalLinkLine(buffer, row, cols, maxRows) {
|
||||
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
|
||||
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
|
||||
};
|
||||
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow,
|
||||
endRow: lastRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4195,7 +4195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an out-of-workspace path as a live external attachment and return
|
||||
* Register a file path as a live external attachment and return
|
||||
* its id, so the preview can render it through the by-id attachment routes.
|
||||
*
|
||||
* `notify: false` keeps this quiet: the caller is already opening the file in
|
||||
@@ -4204,7 +4204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* full attachment guard (blocked secret trees, extension allowlist, symlinks
|
||||
* resolved), so a refusal here is a policy answer worth showing verbatim.
|
||||
*
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string, status?: number}>}
|
||||
*/
|
||||
async _registerExternalPreview(filePath, sessionId) {
|
||||
try {
|
||||
@@ -4220,14 +4220,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
|
||||
// The registry's type answer is a policy term, not an explanation, and the
|
||||
// user just clicked a file they can see on disk. Say what IS previewable
|
||||
// from outside the workspace instead.
|
||||
// through this lookup instead, using the path's actual workspace scope.
|
||||
if (/unsupported/i.test(reason)) {
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
const target = this._isExternalPreviewPath(filePath, sessionId)
|
||||
? `.${ext} from outside the session workspace`
|
||||
: `"${filePath}" through the attachment lookup`;
|
||||
return {
|
||||
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
|
||||
status: res.status,
|
||||
error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`,
|
||||
};
|
||||
}
|
||||
return { error: reason };
|
||||
return { error: reason, status: res.status };
|
||||
} catch (err) {
|
||||
return { error: err.message || 'Cannot open this file' };
|
||||
}
|
||||
|
||||
@@ -2318,7 +2318,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
const activateFilePath = (text, folder = false) => {
|
||||
const activateFilePath = (text, folder = false, verifiedSessionId = null) => {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images, PDFs, documents and media inline — and it
|
||||
// now reaches files outside the workspace too, which is where an
|
||||
@@ -2336,8 +2336,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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();
|
||||
const sessionId = verifiedSessionId || getSessionId();
|
||||
if (!sessionId) return;
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
const alternative = !folder && logical.pathAlternatives?.find((candidate) => candidate.path === text);
|
||||
if (alternative && !verifiedSessionId) {
|
||||
return (async () => {
|
||||
const original = await self._registerExternalPreview(target, sessionId);
|
||||
// Attachment policy must not gate the workspace's normal opener.
|
||||
if (original.status !== 404) return activateFilePath(target, false, sessionId);
|
||||
const path = self._resolveLinkedFilePath(alternative.spacedPath, sessionId);
|
||||
const registered = await self._registerExternalPreview(path, sessionId);
|
||||
if (registered.status === 404) return activateFilePath(target, false, sessionId);
|
||||
if (!registered.attachmentId) {
|
||||
self.showToast(registered.error || 'Cannot open this file', 'error');
|
||||
return;
|
||||
}
|
||||
// The normal opener rechecks its guards; attachment registration deduplicates the path.
|
||||
return activateFilePath(path, false, sessionId);
|
||||
})();
|
||||
}
|
||||
// A folder path opens the folder grid (which falls back to the
|
||||
// preview if the server finds a file there after all).
|
||||
if (folder) {
|
||||
@@ -2372,7 +2390,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
underline: true,
|
||||
},
|
||||
activate(_event, text) {
|
||||
activateFilePath(text, folder);
|
||||
return activateFilePath(text, folder);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
|
||||
Reference in new issue
Block a user