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.
This commit is contained in:
Marius
2026-03-06 13:22:49 +01:00
committed by GitHub
parent a01adf57d4
commit ee9fb09673
9 changed files with 617 additions and 86 deletions
+16 -5
View File
@@ -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 }
@@ -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<string, unknown> | undefined
private cachedLocalStats: Record<string, unknown> | 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),
)
}
+27
View File
@@ -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
}
@@ -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)
})
})
})
@@ -1328,6 +1328,7 @@ export interface AgentManagerOpenFileRequest {
sessionId: string
filePath: string
line?: number
column?: number
}
/**
+66 -53
View File
@@ -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) {
<Show when={!hideQuestion()}>
<Show when={dismissed()}>
<div style="width: 100%; display: flex; justify-content: flex-end; padding: 4px 8px 4px 0;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.tool.questions")} dismissed
</span>
<span class="text-13-regular text-text-weak cursor-default">{i18n.t("ui.tool.questions")} dismissed</span>
</div>
</Show>
<Show when={!dismissed()}>
<div data-component="tool-part-wrapper">
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={partMetadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
</div>
<div data-component="tool-part-wrapper">
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
<div data-component="tool-error">
<Icon name="circle-ban-sign" size="small" />
<Switch>
<Match when={title && title.length < 30}>
<div data-slot="message-part-tool-error-content">
<div data-slot="message-part-tool-error-title">{title}</div>
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
</div>
</Match>
<Match when={true}>
<span data-slot="message-part-tool-error-message">{cleaned}</span>
</Match>
</Switch>
</div>
</Card>
)
}}
</Match>
<Match when={true}>
<Dynamic
component={render}
input={input()}
tool={part.tool}
metadata={partMetadata()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
</div>
</Show>
</Show>
)
@@ -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
+2 -19
View File
@@ -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<string> {
export type NativeMarkdownParser = (markdown: string) => Promise<string>
// 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",
+237
View File
@@ -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()
})
})
})
+51
View File
@@ -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
}