Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions frontend/app/view/term/term-link-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { createBlock, globalStore, WOS } from "@/store/global";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { fireAndForget } from "@/util/util";
import type { IBufferRange, ILink, ILinkProvider, Terminal } from "@xterm/xterm";

// Matches file paths with optional line/col numbers:
// /absolute/path/file.ts
// ~/home/relative/file.ts
// ./relative/file.ts
// relative/file.ts (must contain / and end with known extension)
// file.ts:10 file.ts:10:5 (file.ts:42)
// at /path/file.js:10:5 (stack traces)
const FILE_PATH_REGEX =
/(?:^|[\s('"`:])((\/[\w.+\-@/]*[\w.+\-@])|(~\/[\w.+\-@/]*[\w.+\-@])|(\.\/?[\w.+\-@/]*[\w.+\-@])|([\w.+\-@]+(?:\/[\w.+\-@]+)+))(?::(\d+)(?::(\d+))?)?/g;

// File extensions we recognize for bare relative paths (the ones without ./ prefix)
const KNOWN_EXTENSIONS =
/\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|c|cpp|h|hpp|css|scss|less|html|json|yaml|yml|toml|md|txt|sh|bash|zsh|fish|lua|zig|swift|kt|scala|ex|exs|erl|hrl|vue|svelte|astro|sql|graphql|gql|proto|Makefile|Dockerfile|conf|cfg|ini|env|xml|csv|log)$/;
Comment on lines +20 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Makefile, Dockerfile, etc. will never match as "extensions".

The KNOWN_EXTENSIONS regex requires a leading dot (\.), so it will match .ts, .py, etc., but it will never match Makefile or Dockerfile since those are filenames, not extensions. A bare relative path like src/Makefile would be incorrectly filtered out.

Proposed fix: separate filename check
-const KNOWN_EXTENSIONS =
-    /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|c|cpp|h|hpp|css|scss|less|html|json|yaml|yml|toml|md|txt|sh|bash|zsh|fish|lua|zig|swift|kt|scala|ex|exs|erl|hrl|vue|svelte|astro|sql|graphql|gql|proto|Makefile|Dockerfile|conf|cfg|ini|env|xml|csv|log)$/;
+const KNOWN_EXTENSIONS =
+    /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|c|cpp|h|hpp|css|scss|less|html|json|yaml|yml|toml|md|txt|sh|bash|zsh|fish|lua|zig|swift|kt|scala|ex|exs|erl|hrl|vue|svelte|astro|sql|graphql|gql|proto|conf|cfg|ini|env|xml|csv|log)$/;
+
+const KNOWN_FILENAMES = /\/(Makefile|Dockerfile|Rakefile|Gemfile|Justfile)$/;

Then in the filter logic (around line 101):

-            if (match[5] && !KNOWN_EXTENSIONS.test(match[5])) {
+            if (match[5] && !KNOWN_EXTENSIONS.test(match[5]) && !KNOWN_FILENAMES.test(match[5])) {
🤖 Prompt for AI Agents
In `@frontend/app/view/term/term-link-provider.ts` around lines 20 - 21,
KNOWN_EXTENSIONS only matches names with a leading dot so files like "Makefile"
or "Dockerfile" are being excluded; add a separate set/array of knownFilenames
(e.g., ["Makefile","Dockerfile","README","LICENSE"]) and update the filter that
uses KNOWN_EXTENSIONS (in term-link-provider.ts) to allow a path if it either
matches KNOWN_EXTENSIONS OR its basename is in knownFilenames; implement
basename extraction using path.basename or similar and use a Set for O(1)
lookups to keep behavior consistent and performant.


function getLineText(terminal: Terminal, lineNumber: number): string {
const buffer = terminal.buffer.active;
const line = buffer.getLine(lineNumber - 1);
if (!line) {
return "";
}
return line.translateToString(true);
}

function resolvePath(rawPath: string, cwd: string | undefined): string {
if (rawPath.startsWith("/")) {
return rawPath;
}
if (rawPath.startsWith("~/")) {
// Can't fully resolve ~ without knowing home dir, but pass through
// The preview block should handle ~ expansion
return rawPath;
}
if (cwd) {
const base = cwd.endsWith("/") ? cwd : cwd + "/";
if (rawPath.startsWith("./")) {
return base + rawPath.slice(2);
}
return base + rawPath;
}
return rawPath;
}

function getCwd(blockId: string): string | undefined {
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
const blockData = globalStore.get(blockAtom);
return blockData?.meta?.["cmd:cwd"];
}

function getConnection(blockId: string): string | undefined {
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
const blockData = globalStore.get(blockAtom);
return blockData?.meta?.connection;
}

function openFileInPreview(filePath: string, blockId: string): void {
const connection = getConnection(blockId);
const meta: Record<string, any> = {
view: "preview",
file: filePath,
};
if (connection) {
meta.connection = connection;
}
const blockDef: BlockDef = { meta };
fireAndForget(() => createBlock(blockDef));
}

export class FilePathLinkProvider implements ILinkProvider {
private blockId: string;
private terminal: Terminal;

constructor(terminal: Terminal, blockId: string) {
this.terminal = terminal;
this.blockId = blockId;
}

provideLinks(bufferLineNumber: number, callback: (links: ILink[] | undefined) => void): void {
const lineText = getLineText(this.terminal, bufferLineNumber);
if (!lineText) {
callback(undefined);
return;
}

const links: ILink[] = [];
let match: RegExpExecArray | null;
FILE_PATH_REGEX.lastIndex = 0;

while ((match = FILE_PATH_REGEX.exec(lineText)) !== null) {
const fullMatch = match[0];
const pathPart = match[1];

// For bare relative paths (group 5), require a known file extension
if (match[5] && !KNOWN_EXTENSIONS.test(match[5])) {
continue;
}

// Calculate the start position (1-based column)
// The fullMatch may have a leading separator char that's not part of the path
const matchStart = match.index;
const pathStartInMatch = fullMatch.indexOf(pathPart);
const startX = matchStart + pathStartInMatch + 1; // 1-based

// Include the line:col suffix in the link text for display
const lineNum = match[6];
const colNum = match[7];
let linkText = pathPart;
if (lineNum) {
linkText += ":" + lineNum;
if (colNum) {
linkText += ":" + colNum;
}
}
const endX = startX + linkText.length - 1; // 1-based, inclusive

const range: IBufferRange = {
start: { x: startX, y: bufferLineNumber },
end: { x: endX, y: bufferLineNumber },
};

const blockId = this.blockId;

links.push({
range,
text: linkText,
decorations: { pointerCursor: true, underline: true },
activate: (event: MouseEvent, text: string) => {
// Require Cmd (Mac) or Ctrl (other) to activate
const isModifierHeld =
PLATFORM === PlatformMacOS ? event.metaKey : event.ctrlKey;
if (!isModifierHeld) {
return;
}
// Strip line:col suffix for the file path
const colonIdx = text.indexOf(":");
const filePath = colonIdx > 0 ? text.substring(0, colonIdx) : text;
const cwd = getCwd(blockId);
const resolved = resolvePath(filePath, cwd);
openFileInPreview(resolved, blockId);
},
});
}

callback(links.length > 0 ? links : undefined);
}
}
2 changes: 2 additions & 0 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
handleOsc7Command,
type ShellIntegrationStatus,
} from "./osc-handlers";
import { FilePathLinkProvider } from "./term-link-provider";
import { createTempFileFromBlob, extractAllClipboardData } from "./termutil";

const dlog = debug("wave:termwrap");
Expand Down Expand Up @@ -171,6 +172,7 @@ export class TermWrap {
this.terminal.parser.registerOscHandler(16162, (data: string) => {
return handleOsc16162Command(data, this.blockId, this.loaded, this);
});
this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal, this.blockId));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

registerLinkProvider returns an IDisposable that is not tracked — potential resource leak.

terminal.registerLinkProvider(...) returns an IDisposable. Other registrations in this constructor (e.g., onContextLoss, onBell) are pushed to this.toDispose for cleanup in dispose(). This one should be too.

Proposed fix
-        this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal, this.blockId));
+        this.toDispose.push(
+            this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal, this.blockId))
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal, this.blockId));
this.toDispose.push(
this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal, this.blockId))
);
🤖 Prompt for AI Agents
In `@frontend/app/view/term/termwrap.ts` at line 175, The call to
this.terminal.registerLinkProvider(new FilePathLinkProvider(this.terminal,
this.blockId)) returns an IDisposable that is not currently stored, causing a
potential leak; modify the constructor to capture that return value and push it
onto this.toDispose (the same array used for onContextLoss/onBell) so it will be
disposed in dispose(), referencing registerLinkProvider, FilePathLinkProvider,
this.toDispose, and dispose() to locate and update the code.

this.toDispose.push(
this.terminal.onBell(() => {
if (!this.loaded) {
Expand Down