From ee9fb09673da25fdf367277ae09cb00dd76d0212 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 6 Mar 2026 13:22:49 +0100 Subject: [PATCH] fix(agent-manager): resolve file links against worktree directory for agent sessions (#6696) * fix(agent-manager): resolve file links against worktree directory for agent sessions * fix: address PR review feedback - Guard session.get() against stale responses using abort signal - Strip fragment/query from anchor hrefs and reject non-file schemes - Add Windows drive-letter and UNC path support to FILE_PATH_RE * refactor: extract file-path parsing into testable utility with 46 tests Move parseFilePath and extractFilePathFromHref into packages/ui/src/file-path.ts so the logic is unit-testable without DOM dependencies. marked.tsx and message-part.tsx now import from the shared utility. --- packages/kilo-vscode/src/KiloProvider.ts | 21 +- .../src/agent-manager/AgentManagerProvider.ts | 61 ++++- packages/kilo-vscode/src/path-utils.ts | 27 ++ .../kilo-vscode/tests/unit/path-utils.test.ts | 165 ++++++++++++ .../webview-ui/src/types/messages.ts | 1 + packages/ui/src/components/message-part.tsx | 119 +++++---- packages/ui/src/context/marked.tsx | 21 +- packages/ui/src/file-path.test.ts | 237 ++++++++++++++++++ packages/ui/src/file-path.ts | 51 ++++ 9 files changed, 617 insertions(+), 86 deletions(-) create mode 100644 packages/kilo-vscode/src/path-utils.ts create mode 100644 packages/kilo-vscode/tests/unit/path-utils.test.ts create mode 100644 packages/ui/src/file-path.test.ts create mode 100644 packages/ui/src/file-path.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index f29c2298b4b..0bc315b0ab1 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import * as vscode from "vscode" import { z } from "zod" +import { isAbsolutePath } from "./path-utils" import type { KiloClient, Session, @@ -255,6 +256,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.sessionDirectories.delete(sessionId) } + /** Return the currently active session ID, if any. */ + public getCurrentSessionId(): string | undefined { + return this.currentSession?.id ?? undefined + } + /** * Re-fetch and send the full session list to the webview. * Called by AgentManagerProvider after worktree recovery completes. @@ -761,13 +767,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (abort.signal.aborted) return // Update currentSession so fallback logic in handleSendMessage/handleAbort - // references the correct session after switching to a historical session. + // references the correct session after switching. loadMessages is the + // canonical "user switched to this session" signal, so always update — + // the old guard `this.currentSession.id === sessionID` prevented updates + // when switching between different sessions. // Non-blocking: don't let a failure here prevent messages from loading. // 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs. this.client.session .get({ sessionID, directory: workspaceDir }) .then((result) => { - if (result.data && (!this.currentSession || this.currentSession.id === sessionID)) { + if (result.data && !abort.signal.aborted) { this.currentSession = result.data } }) @@ -1682,12 +1691,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Handle openFile request from the webview — open a file in the VS Code editor. + * Resolves relative paths against the current session's directory (which may be + * a worktree path registered via setSessionDirectory), falling back to workspace root. + * Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is. */ private handleOpenFile(filePath: string, line?: number, column?: number): void { - const absolute = /^(?:\/|[a-zA-Z]:[\\/])/.test(filePath) - const uri = absolute + const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) - : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory()), filePath) + : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath) vscode.workspace.openTextDocument(uri).then( (doc) => { const options: vscode.TextDocumentShowOptions = { preview: true } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index de8337262fa..7c368ab63c5 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -4,6 +4,7 @@ import * as path from "path" import type { KiloClient, Session, FileDiff } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" +import { isAbsolutePath } from "../path-utils" import { KiloProvider } from "../KiloProvider" import { buildWebviewHtml } from "../utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" @@ -52,6 +53,10 @@ export class AgentManagerProvider implements vscode.Disposable { private cachedWorktreeStats: Record | undefined private cachedLocalStats: Record | undefined private applyingWorktreeId: string | undefined + /** Session ID most recently loaded via a `loadMessages` message from the webview. + * Updated synchronously — unlike KiloProvider.currentSession which depends on + * an async `session.get` round-trip and can be stale during rapid tab switches. */ + private activeSessionId: string | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -327,17 +332,37 @@ export class AgentManagerProvider implements vscode.Disposable { if (type === "agentManager.openFile" && typeof msg.sessionId === "string" && typeof msg.filePath === "string") { const line = typeof msg.line === "number" ? msg.line : undefined - this.openWorktreeFile(msg.sessionId, msg.filePath, line) + const column = typeof msg.column === "number" ? msg.column : undefined + this.openWorktreeFile(msg.sessionId, msg.filePath, line, column) return null } - // When switching sessions, show existing terminal if one is open + // Intercept generic "openFile" from DataBridge (markdown links, tool subtitle clicks) + // and route through worktree-aware resolution — but only for worktree sessions. + // Local sessions fall through to KiloProvider which resolves against workspace root. + // Uses activeSessionId (set synchronously by loadMessages) rather than + // KiloProvider.currentSession which can be stale during rapid tab switches. + if (type === "openFile" && typeof msg.filePath === "string") { + const sessionId = this.activeSessionId + const state = this.getStateManager() + if (sessionId && state?.directoryFor(sessionId)) { + const line = typeof msg.line === "number" ? msg.line : undefined + const column = typeof msg.column === "number" ? msg.column : undefined + this.openWorktreeFile(sessionId, msg.filePath, line, column) + return null + } + } + + // Track the active session synchronously so worktree-aware file resolution + // uses the correct session even before KiloProvider's async session.get completes. if (type === "loadMessages" && typeof msg.sessionID === "string") { + this.activeSessionId = msg.sessionID this.terminalManager.syncOnSessionSwitch(msg.sessionID) } - // After clearSession, re-register worktree sessions so SSE events keep flowing + // After clearSession, clear active tracking and re-register worktree sessions if (type === "clearSession") { + this.activeSessionId = undefined void Promise.resolve().then(() => { if (!this.provider || !this.state) return for (const id of this.state.worktreeSessionIds()) { @@ -1548,8 +1573,24 @@ export class AgentManagerProvider implements vscode.Disposable { void vscode.commands.executeCommand("vscode.openFolder", uri, true) } - /** Open a file from a worktree or local session in the VS Code editor. */ - private openWorktreeFile(sessionId: string, relativePath: string, line?: number): void { + /** Open a file from a worktree or local session in the VS Code editor. + * Absolute paths (Unix `/…` or Windows `C:\…`) are opened directly. + * Relative paths are resolved against the session's worktree directory + * (or workspace root for local sessions) with symlink-traversal protection. */ + private openWorktreeFile(sessionId: string, filePath: string, line?: number, column?: number): void { + if (isAbsolutePath(filePath)) { + const uri = vscode.Uri.file(filePath) + const options: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const col = column !== undefined && column > 0 ? column - 1 : 0 + options.selection = new vscode.Range(new vscode.Position(line - 1, col), new vscode.Position(line - 1, col)) + } + vscode.workspace.openTextDocument(uri).then( + (doc) => vscode.window.showTextDocument(doc, options), + (err) => console.error("[Kilo New] AgentManagerProvider: Failed to open file:", uri.fsPath, err), + ) + return + } const state = this.getStateManager() if (!state) return const session = state.getSession(sessionId) @@ -1560,7 +1601,7 @@ export class AgentManagerProvider implements vscode.Disposable { let resolved: string try { const root = fs.realpathSync(base) - resolved = fs.realpathSync(path.resolve(base, relativePath)) + resolved = fs.realpathSync(path.resolve(base, filePath)) // Directory-boundary check: append path.sep so "/foo/bar" won't match "/foo/bar2/..." if (resolved !== root && !resolved.startsWith(root + path.sep)) return } catch (err) { @@ -1568,11 +1609,13 @@ export class AgentManagerProvider implements vscode.Disposable { return } const uri = vscode.Uri.file(resolved) + const options: vscode.TextDocumentShowOptions = { preview: true } const target = Math.max(1, Math.floor(line ?? 1)) - const pos = new vscode.Position(target - 1, 0) - const selection = new vscode.Range(pos, pos) + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(target - 1, col) + options.selection = new vscode.Range(pos, pos) vscode.workspace.openTextDocument(uri).then( - (doc) => vscode.window.showTextDocument(doc, { preview: true, selection }), + (doc) => vscode.window.showTextDocument(doc, options), (err) => console.error("[Kilo New] AgentManagerProvider: Failed to open file:", uri.fsPath, err), ) } diff --git a/packages/kilo-vscode/src/path-utils.ts b/packages/kilo-vscode/src/path-utils.ts new file mode 100644 index 00000000000..856a76038a2 --- /dev/null +++ b/packages/kilo-vscode/src/path-utils.ts @@ -0,0 +1,27 @@ +/** + * Check whether a file path is absolute. + * + * Handles both Unix (`/foo/bar`) and Windows (`C:\foo`, `D:/bar`) conventions. + * UNC paths (`\\server\share`) are also treated as absolute. + * + * Returns false for relative paths, bare filenames, empty strings, and + * protocol-prefixed strings like `https://…`. + */ +export function isAbsolutePath(filePath: string): boolean { + if (!filePath) return false + // Unix absolute + if (filePath.charCodeAt(0) === 47 /* / */) return true + // Windows drive letter: C:\ or C:/ + if ( + filePath.length >= 3 && + filePath.charCodeAt(1) === 58 /* : */ && + (filePath.charCodeAt(2) === 92 /* \ */ || filePath.charCodeAt(2) === 47) /* / */ && + ((filePath.charCodeAt(0) >= 65 && filePath.charCodeAt(0) <= 90) /* A-Z */ || + (filePath.charCodeAt(0) >= 97 && filePath.charCodeAt(0) <= 122)) /* a-z */ + ) + return true + // Windows UNC path: \\server\share + if (filePath.length >= 2 && filePath.charCodeAt(0) === 92 /* \ */ && filePath.charCodeAt(1) === 92 /* \ */) + return true + return false +} diff --git a/packages/kilo-vscode/tests/unit/path-utils.test.ts b/packages/kilo-vscode/tests/unit/path-utils.test.ts new file mode 100644 index 00000000000..517e89eded9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/path-utils.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "bun:test" +import { isAbsolutePath } from "../../src/path-utils" + +describe("isAbsolutePath", () => { + // ── Unix absolute paths ────────────────────────────────────────────── + describe("Unix absolute paths", () => { + it("detects root /", () => { + expect(isAbsolutePath("/")).toBe(true) + }) + + it("detects simple path", () => { + expect(isAbsolutePath("/foo/bar")).toBe(true) + }) + + it("detects deep path", () => { + expect(isAbsolutePath("/Users/marius/Documents/project/src/index.ts")).toBe(true) + }) + + it("detects path with spaces", () => { + expect(isAbsolutePath("/home/user/my project/file.ts")).toBe(true) + }) + + it("detects path with dots", () => { + expect(isAbsolutePath("/home/user/../other/./file.ts")).toBe(true) + }) + + it("detects path with special chars", () => { + expect(isAbsolutePath("/tmp/@scope/pkg/index.js")).toBe(true) + }) + }) + + // ── Windows drive-letter paths ─────────────────────────────────────── + describe("Windows drive-letter paths", () => { + it("detects uppercase drive with backslash", () => { + expect(isAbsolutePath("C:\\Users\\marius\\file.ts")).toBe(true) + }) + + it("detects uppercase drive with forward slash", () => { + expect(isAbsolutePath("C:/Users/marius/file.ts")).toBe(true) + }) + + it("detects lowercase drive letter", () => { + expect(isAbsolutePath("c:\\users\\file.ts")).toBe(true) + }) + + it("detects various drive letters", () => { + expect(isAbsolutePath("D:\\data")).toBe(true) + expect(isAbsolutePath("Z:/files")).toBe(true) + expect(isAbsolutePath("e:\\temp")).toBe(true) + }) + + it("detects drive root with backslash", () => { + expect(isAbsolutePath("C:\\")).toBe(true) + }) + + it("detects drive root with forward slash", () => { + expect(isAbsolutePath("C:/")).toBe(true) + }) + + it("detects mixed separators", () => { + expect(isAbsolutePath("C:\\Users/marius\\project/file.ts")).toBe(true) + }) + }) + + // ── Windows UNC paths ──────────────────────────────────────────────── + describe("Windows UNC paths", () => { + it("detects simple UNC path", () => { + expect(isAbsolutePath("\\\\server\\share")).toBe(true) + }) + + it("detects deeply nested UNC path", () => { + expect(isAbsolutePath("\\\\server\\share\\folder\\file.ts")).toBe(true) + }) + + it("detects minimal UNC path", () => { + expect(isAbsolutePath("\\\\ab")).toBe(true) + }) + }) + + // ── Relative paths (should return false) ───────────────────────────── + describe("relative paths", () => { + it("rejects bare filename with extension", () => { + expect(isAbsolutePath("file.ts")).toBe(false) + }) + + it("rejects relative path with directory", () => { + expect(isAbsolutePath("src/index.ts")).toBe(false) + }) + + it("rejects dot-relative path", () => { + expect(isAbsolutePath("./foo/bar.ts")).toBe(false) + }) + + it("rejects parent-relative path", () => { + expect(isAbsolutePath("../foo/bar.ts")).toBe(false) + }) + + it("rejects bare filename without extension", () => { + expect(isAbsolutePath("Makefile")).toBe(false) + }) + + it("rejects package-style path", () => { + expect(isAbsolutePath("@scope/package/index.js")).toBe(false) + }) + }) + + // ── Edge cases ─────────────────────────────────────────────────────── + describe("edge cases", () => { + it("rejects empty string", () => { + expect(isAbsolutePath("")).toBe(false) + }) + + it("rejects URL with protocol", () => { + expect(isAbsolutePath("https://example.com/path")).toBe(false) + expect(isAbsolutePath("http://localhost:3000")).toBe(false) + }) + + it("rejects file:// URL", () => { + expect(isAbsolutePath("file:///foo/bar")).toBe(false) + }) + + it("rejects drive-relative path (C:file without separator)", () => { + // C:file is a Windows drive-relative path, not absolute + expect(isAbsolutePath("C:file.ts")).toBe(false) + }) + + it("rejects bare drive letter with colon only", () => { + expect(isAbsolutePath("C:")).toBe(false) + }) + + it("rejects single backslash (not UNC)", () => { + expect(isAbsolutePath("\\foo")).toBe(false) + }) + + it("rejects number-prefixed colon paths", () => { + expect(isAbsolutePath("1:\\foo")).toBe(false) + }) + + it("rejects symbol-prefixed colon paths", () => { + expect(isAbsolutePath("@:\\foo")).toBe(false) + }) + + it("rejects tilde home path", () => { + expect(isAbsolutePath("~/Documents/file.ts")).toBe(false) + }) + + it("treats /C:/foo as absolute (Unix-style prefix)", () => { + // Starts with / so it's Unix-absolute regardless of Windows-like suffix + expect(isAbsolutePath("/C:/Users/foo")).toBe(true) + }) + + it("rejects whitespace-only strings", () => { + expect(isAbsolutePath(" ")).toBe(false) + expect(isAbsolutePath(" ")).toBe(false) + }) + + it("rejects path starting with dot-dot alone", () => { + expect(isAbsolutePath("..")).toBe(false) + }) + + it("rejects path starting with single dot", () => { + expect(isAbsolutePath(".")).toBe(false) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7f253ff1706..b83f918284e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1328,6 +1328,7 @@ export interface AgentManagerOpenFileRequest { sessionId: string filePath: string line?: number + column?: number } /** diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a56362d1825..62438b2a2c3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -43,6 +43,7 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" +import { extractFilePathFromHref } from "../file-path" // kilocode_change import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" @@ -976,53 +977,51 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
- - {i18n.t("ui.tool.questions")} dismissed - + {i18n.t("ui.tool.questions")} dismissed
-
- - - {(error) => { - const cleaned = error().replace("Error: ", "") - const [title, ...rest] = cleaned.split(": ") - return ( - -
- - - -
-
{title}
- {rest.join(": ")} -
-
- - {cleaned} - -
-
-
- ) - }} -
- - - -
-
+
+ + + {(error) => { + const cleaned = error().replace("Error: ", "") + const [title, ...rest] = cleaned.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ + {cleaned} + +
+
+
+ ) + }} +
+ + + +
+
) @@ -1103,15 +1102,29 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (!data.openFile) return const target = e.target if (!(target instanceof HTMLElement)) return - const link = target.closest(".file-link[data-file-path]") - if (!link) return - const path = link.getAttribute("data-file-path") - if (!path) return - const lineAttr = link.getAttribute("data-file-line") - const colAttr = link.getAttribute("data-file-col") - const line = lineAttr ? parseInt(lineAttr, 10) : undefined - const column = colAttr ? parseInt(colAttr, 10) : undefined - data.openFile(path, line, column) + // Handle .file-link code spans (e.g. `src/foo.ts:42`) + const fileLink = target.closest(".file-link[data-file-path]") + if (fileLink) { + const path = fileLink.getAttribute("data-file-path") + if (!path) return + const lineAttr = fileLink.getAttribute("data-file-line") + const colAttr = fileLink.getAttribute("data-file-col") + const line = lineAttr ? parseInt(lineAttr, 10) : undefined + const column = colAttr ? parseInt(colAttr, 10) : undefined + data.openFile(path, line, column) + return + } + // Handle markdown links whose href looks like a relative file path + // (e.g. [AGENTS.md](AGENTS.md) or [src/foo.ts](src/foo.ts)) + const anchor = target.closest("a.external-link") as HTMLAnchorElement | null + if (anchor) { + const href = anchor.getAttribute("href") + if (!href) return + const filePath = extractFilePathFromHref(href) + if (!filePath) return + e.preventDefault() + data.openFile(filePath) + } } // kilocode_change end diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 315cf55aab0..726f48c8e53 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -3,6 +3,7 @@ import markedKatex from "marked-katex-extension" import markedShiki from "marked-shiki" import katex from "katex" import { bundledLanguages, type BundledLanguage } from "shiki" +import { parseFilePath } from "../file-path" // kilocode_change import { createSimpleContext } from "./helper" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" @@ -461,25 +462,7 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise -// kilocode_change start -// Matches text that looks like a file path: contains "/" and ends with a file extension, -// or starts with "./" or "../" or "/". Supports optional :line or :line:col suffix. -const FILE_PATH_RE = - /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ - -function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined { - if (text.includes("://")) return undefined - if (text.includes(" ")) return undefined - const match = FILE_PATH_RE.exec(text) - if (!match) return undefined - if (!match[1].includes("/")) return undefined - return { - path: match[1], - line: match[2] ? parseInt(match[2], 10) : undefined, - column: match[3] ? parseInt(match[3], 10) : undefined, - } -} -// kilocode_change end +// kilocode_change: parseFilePath imported from ../file-path export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", diff --git a/packages/ui/src/file-path.test.ts b/packages/ui/src/file-path.test.ts new file mode 100644 index 00000000000..16e8dc47627 --- /dev/null +++ b/packages/ui/src/file-path.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "bun:test" +import { parseFilePath, extractFilePathFromHref } from "./file-path" + +describe("parseFilePath", () => { + describe("Unix paths", () => { + it("bare filename", () => { + expect(parseFilePath("foo.ts")).toEqual({ path: "foo.ts", line: undefined, column: undefined }) + }) + + it("relative path", () => { + expect(parseFilePath("src/index.ts")).toEqual({ path: "src/index.ts", line: undefined, column: undefined }) + }) + + it("dot-relative path", () => { + expect(parseFilePath("./src/foo.ts")).toEqual({ path: "./src/foo.ts", line: undefined, column: undefined }) + }) + + it("parent-relative path", () => { + expect(parseFilePath("../lib/bar.ts")).toEqual({ path: "../lib/bar.ts", line: undefined, column: undefined }) + }) + + it("absolute path", () => { + expect(parseFilePath("/Users/dev/project/main.ts")).toEqual({ + path: "/Users/dev/project/main.ts", + line: undefined, + column: undefined, + }) + }) + + it("with line number", () => { + expect(parseFilePath("src/foo.ts:42")).toEqual({ path: "src/foo.ts", line: 42, column: undefined }) + }) + + it("with line and column", () => { + expect(parseFilePath("src/foo.ts:42:10")).toEqual({ path: "src/foo.ts", line: 42, column: 10 }) + }) + + it("deeply nested path", () => { + expect(parseFilePath("packages/ui/src/context/marked.tsx")).toEqual({ + path: "packages/ui/src/context/marked.tsx", + line: undefined, + column: undefined, + }) + }) + + it("path with @ scope", () => { + expect(parseFilePath("@scope/pkg/index.js")).toEqual({ + path: "@scope/pkg/index.js", + line: undefined, + column: undefined, + }) + }) + + it("dotfile with path", () => { + expect(parseFilePath("src/.eslintrc.json")).toEqual({ + path: "src/.eslintrc.json", + line: undefined, + column: undefined, + }) + }) + }) + + describe("Windows paths", () => { + it("drive letter with backslash", () => { + expect(parseFilePath("C:\\Users\\dev\\file.ts")).toEqual({ + path: "C:\\Users\\dev\\file.ts", + line: undefined, + column: undefined, + }) + }) + + it("drive letter with forward slash", () => { + expect(parseFilePath("C:/Users/dev/file.ts")).toEqual({ + path: "C:/Users/dev/file.ts", + line: undefined, + column: undefined, + }) + }) + + it("lowercase drive", () => { + expect(parseFilePath("d:\\projects\\app.tsx")).toEqual({ + path: "d:\\projects\\app.tsx", + line: undefined, + column: undefined, + }) + }) + + it("UNC path", () => { + expect(parseFilePath("\\\\server\\share\\file.ts")).toEqual({ + path: "\\\\server\\share\\file.ts", + line: undefined, + column: undefined, + }) + }) + + it("Windows path with line number", () => { + expect(parseFilePath("C:\\src\\file.ts:12")).toEqual({ path: "C:\\src\\file.ts", line: 12, column: undefined }) + }) + + it("Windows path with line and column", () => { + expect(parseFilePath("C:\\src\\file.ts:12:5")).toEqual({ path: "C:\\src\\file.ts", line: 12, column: 5 }) + }) + }) + + describe("rejects non-paths", () => { + it("URL with protocol", () => { + expect(parseFilePath("https://example.com/path.html")).toBeUndefined() + }) + + it("text with spaces", () => { + expect(parseFilePath("not a path.ts")).toBeUndefined() + }) + + it("bare word without extension", () => { + expect(parseFilePath("README")).toBeUndefined() + }) + + it("empty string", () => { + expect(parseFilePath("")).toBeUndefined() + }) + + it("file:// URL", () => { + expect(parseFilePath("file:///foo/bar.ts")).toBeUndefined() + }) + + it("just a number", () => { + expect(parseFilePath("42")).toBeUndefined() + }) + + it("path without extension", () => { + expect(parseFilePath("src/Makefile")).toBeUndefined() + }) + }) +}) + +describe("extractFilePathFromHref", () => { + describe("accepts file-like hrefs", () => { + it("bare filename", () => { + expect(extractFilePathFromHref("AGENTS.md")).toBe("AGENTS.md") + }) + + it("relative path", () => { + expect(extractFilePathFromHref("src/foo.ts")).toBe("src/foo.ts") + }) + + it("dot-relative path", () => { + expect(extractFilePathFromHref("./README.md")).toBe("./README.md") + }) + + it("parent-relative path", () => { + expect(extractFilePathFromHref("../docs/guide.md")).toBe("../docs/guide.md") + }) + + it("path with multiple extensions", () => { + expect(extractFilePathFromHref("config.test.ts")).toBe("config.test.ts") + }) + }) + + describe("strips fragments and queries", () => { + it("strips #fragment", () => { + expect(extractFilePathFromHref("AGENTS.md#worktrees")).toBe("AGENTS.md") + }) + + it("strips ?query", () => { + expect(extractFilePathFromHref("README.md?plain=1")).toBe("README.md") + }) + + it("strips both fragment and query", () => { + expect(extractFilePathFromHref("docs/guide.md?v=2#section")).toBe("docs/guide.md") + }) + + it("strips fragment from path with directory", () => { + expect(extractFilePathFromHref("src/foo.ts#L42")).toBe("src/foo.ts") + }) + }) + + describe("rejects URLs and schemes", () => { + it("https URL", () => { + expect(extractFilePathFromHref("https://example.com/path.html")).toBeUndefined() + }) + + it("http URL", () => { + expect(extractFilePathFromHref("http://localhost:3000/index.html")).toBeUndefined() + }) + + it("mailto scheme", () => { + expect(extractFilePathFromHref("mailto:user@example.com")).toBeUndefined() + }) + + it("tel scheme", () => { + expect(extractFilePathFromHref("tel:+1234567890")).toBeUndefined() + }) + + it("javascript scheme", () => { + expect(extractFilePathFromHref("javascript:void(0)")).toBeUndefined() + }) + + it("file:// URL", () => { + expect(extractFilePathFromHref("file:///foo/bar.ts")).toBeUndefined() + }) + + it("ftp URL", () => { + expect(extractFilePathFromHref("ftp://server/file.txt")).toBeUndefined() + }) + + it("custom scheme", () => { + expect(extractFilePathFromHref("vscode://extension/id")).toBeUndefined() + }) + }) + + describe("rejects anchors and non-file values", () => { + it("pure anchor", () => { + expect(extractFilePathFromHref("#section")).toBeUndefined() + }) + + it("anchor with nested path", () => { + expect(extractFilePathFromHref("#/some/path")).toBeUndefined() + }) + + it("empty string", () => { + expect(extractFilePathFromHref("")).toBeUndefined() + }) + + it("no extension (bare word)", () => { + expect(extractFilePathFromHref("README")).toBeUndefined() + }) + + it("directory-only path (no extension)", () => { + expect(extractFilePathFromHref("src/components/")).toBeUndefined() + }) + + it("fragment-only after stripping resolves to empty", () => { + // href is "#foo" — starts with # so rejected + expect(extractFilePathFromHref("#foo.md")).toBeUndefined() + }) + }) +}) diff --git a/packages/ui/src/file-path.ts b/packages/ui/src/file-path.ts new file mode 100644 index 00000000000..24b331f8855 --- /dev/null +++ b/packages/ui/src/file-path.ts @@ -0,0 +1,51 @@ +// kilocode_change - new file + +// Matches text that looks like a file path: +// - Unix: /foo/bar.ts, ./foo.ts, ../foo.ts, foo.ts +// - Windows drive: C:\foo\bar.ts, C:/foo/bar.ts +// - Windows UNC: \\server\share\file.ts +// Supports optional :line or :line:col suffix. +const FILE_PATH_UNIX_RE = + /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ +const FILE_PATH_WIN_RE = /^((?:[a-zA-Z]:[/\\]|\\\\)(?:[^\\/]+[/\\])*[^\\/]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ + +/** + * Parse an inline code span into a file path with optional line/column. + * Returns undefined when the text does not look like a file reference. + * + * Handles Unix paths (`/foo/bar.ts`, `./foo.ts`, `foo.ts`), + * Windows drive paths (`C:\foo\bar.ts`), and UNC paths (`\\server\share\file.ts`). + */ +export function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined { + if (text.includes("://")) return undefined + if (text.includes(" ")) return undefined + const match = FILE_PATH_UNIX_RE.exec(text) ?? FILE_PATH_WIN_RE.exec(text) + if (!match) return undefined + return { + path: match[1], + line: match[2] ? parseInt(match[2], 10) : undefined, + column: match[3] ? parseInt(match[3], 10) : undefined, + } +} + +const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ + +/** + * Extract a file path from a markdown link href, or return undefined + * when the href is a URL, anchor, scheme, or otherwise not a file reference. + * + * Strips `#fragment` and `?query` suffixes before returning the path. + */ +export function extractFilePathFromHref(href: string): string | undefined { + if (!href) return undefined + // Skip actual URLs and non-file schemes (mailto:, tel:, etc.) + if (href.includes("://") || SCHEME_RE.test(href)) return undefined + // Skip pure anchors + if (href.startsWith("#")) return undefined + // Strip fragment and query before treating as file path + const cleaned = href.replace(/[#?].*$/, "") + if (!cleaned) return undefined + // Must look like a file path (has a dot for extension) + if (!cleaned.includes(".")) return undefined + return cleaned +}