mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
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:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user