From a2d3cb2ff5968ae8f1b9da62de641c87e6ea2d74 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Mon, 18 May 2026 01:14:33 +0200 Subject: [PATCH] feat(ui): validate inline code spans against filesystem to enable clickable file links Make file references in agent responses clickable by validating inline code spans against the filesystem. Code spans that match real files in the workspace become clickable links that open the file at the referenced line. Non-existent paths remain as plain code. The implementation introduces a two-phase approach: markdown rendering marks all code spans as file-link candidates, then post-render validation checks each candidate against the workspace filesystem. Confirmed files are promoted to clickable links with cached results to avoid redundant checks during re-renders. Changes include: - New `extractSuffix()` and `normalizeCandidatePath()` utilities for parsing and normalizing candidate paths - Updated `extractFilePathFromHref()` to return structured path/line/column data - Post-render validation in message-part component with caching - VS Code extension filesystem stat checks and fallback workspace search for dead links - Message protocol extensions for validateFiles/validateFilesResult round-trips - CSS styling for file-path links with hover effects - Comprehensive test updates for new path parsing logic --- .changeset/clickable-file-links.md | 5 + .../kilo-ui/src/components/message-part.tsx | 67 ++++- packages/kilo-ui/src/file-path.ts | 73 ++--- packages/kilo-vscode/src/KiloProvider.ts | 37 ++- .../src/kilo-provider/file-links.ts | 60 ++++ packages/kilo-vscode/webview-ui/src/App.tsx | 30 ++ .../src/types/messages/extension-messages.ts | 7 + .../src/types/messages/webview-messages.ts | 7 + packages/ui/src/components/markdown.css | 18 ++ packages/ui/src/context/data.tsx | 4 + packages/ui/src/context/marked.tsx | 27 +- packages/ui/src/file-path.test.ts | 268 ++++++++---------- packages/ui/src/file-path.ts | 73 ++--- 13 files changed, 428 insertions(+), 248 deletions(-) create mode 100644 .changeset/clickable-file-links.md create mode 100644 packages/kilo-vscode/src/kilo-provider/file-links.ts diff --git a/.changeset/clickable-file-links.md b/.changeset/clickable-file-links.md new file mode 100644 index 00000000000..10b371ecb3a --- /dev/null +++ b/.changeset/clickable-file-links.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Make file references in agent responses clickable by validating inline code spans against the filesystem. Code spans that match real files in the workspace become clickable links that open the file at the referenced line. Non-existent paths stay as plain code. Also adds fallback workspace search and "File not found" warning when clicking dead links. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index aa9bbbe086a..7bb61f412bc 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1307,11 +1307,68 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { setTimeout(() => setCopied(false), 2000) } + // Post-render: validate file-link candidates against the filesystem. + // Candidates that exist as files get promoted to .file-link; others stay plain code. + // Results are cached (capped at 500 entries) so morphdom re-renders resolve immediately. + let bodyRef: HTMLDivElement | undefined + const cache = new Map() + const MAX_CACHE = 500 + + const promote = (el: HTMLElement, path: string, exists: boolean) => { + if (exists) { + // Strip ./ prefix for the click handler — VS Code resolves relative + // paths against the workspace root, so "./LICENSE" → "LICENSE". + const clean = path.startsWith("./") ? path.slice(2) : path + el.classList.remove("file-link-candidate") + el.classList.add("file-link") + el.setAttribute("data-file-path", clean) + } else { + el.classList.remove("file-link-candidate") + el.removeAttribute("data-file-candidate") + el.removeAttribute("data-file-line") + el.removeAttribute("data-file-col") + } + } + + createEffect(() => { + throttledText() + if (!bodyRef) return + const pending: string[] = [] + const elements = new Map() + for (const el of bodyRef.querySelectorAll("code.file-link-candidate")) { + const p = el.getAttribute("data-file-candidate") ?? "" + if (!p) continue + // If already cached, apply immediately (no round-trip) + if (cache.has(p)) { + promote(el, p, cache.get(p)!) + continue + } + if (!elements.has(p)) { + elements.set(p, []) + pending.push(p) + } + elements.get(p)!.push(el) + } + if (!pending.length || !data.validateFiles) return + data.validateFiles(pending) + .then((existing) => { + const set = new Set(existing) + for (const p of pending) { + if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value!) + cache.set(p, set.has(p)) + } + for (const [p, els] of elements) { + for (const el of els) promote(el, p, set.has(p)) + } + }) + .catch(() => { /* validateFiles timed out or failed — candidates stay as plain code */ }) + }) + const handleMarkdownClick = (e: MouseEvent) => { if (!data.openFile) return const target = e.target if (!(target instanceof HTMLElement)) return - // Handle .file-link code spans (e.g. `src/foo.ts:42`) + // Handle .file-link code spans (confirmed by filesystem validation) const fileLink = target.closest(".file-link[data-file-path]") if (fileLink) { const path = fileLink.getAttribute("data-file-path") @@ -1328,17 +1385,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (anchor) { const href = anchor.getAttribute("href") if (!href) return - const filePath = extractFilePathFromHref(href) - if (!filePath) return + const result = extractFilePathFromHref(href) + if (!result) return e.preventDefault() - data.openFile(filePath) + data.openFile(result.path, result.line, result.column) } } return (
-
+
diff --git a/packages/kilo-ui/src/file-path.ts b/packages/kilo-ui/src/file-path.ts index e612dfd75ad..849659333d9 100644 --- a/packages/kilo-ui/src/file-path.ts +++ b/packages/kilo-ui/src/file-path.ts @@ -1,40 +1,46 @@ -// 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+))?)?$/ +/** + * Strip an optional :line[-endline][:col] suffix from a code span. + * Returns the candidate file path and optional line/column numbers. + */ +export function extractSuffix(text: string): { candidate: string; line?: number; column?: number } { + // Try :line:col first, then :line (with optional -endline range) + const m3 = /^(.+):(\d+)(?:-\d+)?:(\d+)$/.exec(text) + if (m3) return { candidate: m3[1], line: +m3[2], column: +m3[3] } + const m2 = /^(.+):(\d+)(?:-\d+)?$/.exec(text) + if (m2) return { candidate: m2[1], line: +m2[2] } + return { candidate: text } +} /** - * 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`). + * Normalize a candidate path for filesystem validation. + * Ensures the path has a ./ prefix if it's a bare relative path, + * so the extension can stat-check it against the workspace root. */ -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, - } +export function normalizeCandidatePath(path: string): string { + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return path + // Windows absolute paths (C:\...) — leave as-is + if (/^[a-zA-Z]:[/\\]/.test(path)) return path + // Windows UNC paths (\\server\...) — leave as-is + if (path.startsWith("\\\\")) return path + // Strip a/b diff prefixes + const stripped = path.replace(/^[ab]\//, "") + return `./${stripped}` } -const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ +// Matches a URI scheme but NOT a Windows drive letter (single char followed by colon). +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. + * Extract a file path (with optional line/column) 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. + * Strips `#fragment` and `?query` suffixes, then parses an optional + * `:line` or `:line:column` suffix from the remaining path. */ -export function extractFilePathFromHref(href: string): string | undefined { +export function extractFilePathFromHref( + href: string, +): { path: string; line?: number; column?: number } | undefined { if (!href) return undefined // Handle file:// URLs — extract the path component and decode it if (href.startsWith("file://")) { @@ -42,15 +48,13 @@ export function extractFilePathFromHref(href: string): string | undefined { const url = new URL(href) const decoded = decodeURIComponent(url.pathname) if (!decoded) return undefined - // On Windows, file:///C:/foo gives pathname=/C:/foo — strip the leading slash - // so the result is a valid Windows absolute path (C:/foo). const c1 = decoded.charCodeAt(1) const isWindowsDrive = decoded.length >= 4 && decoded.charCodeAt(0) === 47 /* / */ && decoded.charCodeAt(2) === 58 /* : */ && ((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */ - return isWindowsDrive ? decoded.slice(1) : decoded + return { path: isWindowsDrive ? decoded.slice(1) : decoded } } catch { return undefined } @@ -62,7 +66,8 @@ export function extractFilePathFromHref(href: string): string | 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 + // Strip a/b diff prefixes, parse :line[:col] suffix + const stripped = cleaned.replace(/^[ab]\//, "") + const { candidate, line, column } = extractSuffix(stripped) + return { path: candidate, line, column } } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4556b4fcbc6..cb5ac197c5f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,7 +1,6 @@ import * as path from "path" import * as vscode from "vscode" import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview" -import { isAbsolutePath } from "./path-utils" import type { KiloClient, Session, @@ -81,6 +80,7 @@ import { openConfig } from "./kilo-provider/open-config" import * as McpOAuth from "./kilo-provider/mcp-oauth" import { retryable, backoff, MAX_RETRIES } from "./util/retry" import { hasGit } from "./kilo-provider/git-status" +import * as fileLinks from "./kilo-provider/file-links" // legacy-migration start import { checkAndShowMigrationWizard, @@ -2973,9 +2973,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (message.content) this.handleOpenContent(message.content, message.language) return true } + if (message.type === "validateFiles") { + const msg = message as { id: string; paths: string[] } + this.handleValidateFiles(msg.id, msg.paths) + return true + } return false } + /** Stat-check candidate paths and respond with which ones are real files. */ + private handleValidateFiles(id: string, paths: string[]): void { + const root = this.getWorkspaceDirectory(this.currentSession?.id) + fileLinks + .validateFiles(root, paths) + .then((existing) => this.postMessage({ type: "validateFilesResult", id, existing })) + .catch((err) => console.error("[Kilo New] handleValidateFiles failed:", err)) + } + /** * Handle openContent request - open arbitrary text in an untitled VS Code editor tab. */ @@ -2988,26 +3002,11 @@ 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. + * Delegates to file-links.ts which resolves paths, falls back to workspace search, + * and shows a warning when the file is not found. */ private handleOpenFile(filePath: string, line?: number, column?: number): void { - const uri = isAbsolutePath(filePath) - ? vscode.Uri.file(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 } - if (line !== undefined && line > 0) { - const col = column !== undefined && column > 0 ? column - 1 : 0 - const pos = new vscode.Position(line - 1, col) - options.selection = new vscode.Range(pos, pos) - } - vscode.window.showTextDocument(doc, options) - }, - (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), - ) + fileLinks.openFile(this.getWorkspaceDirectory(this.currentSession?.id), filePath, line, column) } /** diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts new file mode 100644 index 00000000000..3f019621007 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -0,0 +1,60 @@ +import * as vscode from "vscode" +import { isAbsolutePath } from "../path-utils" + +/** + * Stat-check candidate paths and return which ones are actual files (not directories). + */ +export function validateFiles(root: string, paths: string[]): Promise { + const resolve = (p: string) => + isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p) + return Promise.all( + paths.map((p) => + vscode.workspace.fs.stat(resolve(p)).then( + (s) => (s.type & vscode.FileType.File ? p : null), + () => null, + ), + ), + ).then((r) => r.filter((x): x is string => x !== null)) +} + +/** + * Open a file in the editor with optional line/column positioning. + * Falls back to a workspace-wide filename search if the exact path doesn't exist. + */ +export function openFile(root: string, filePath: string, line?: number, column?: number): void { + const uri = isAbsolutePath(filePath) + ? vscode.Uri.file(filePath) + : vscode.Uri.joinPath(vscode.Uri.file(root), filePath) + const opts: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const pos = new vscode.Position(line - 1, column !== undefined && column > 0 ? column - 1 : 0) + opts.selection = new vscode.Range(pos, pos) + } + const show = (target: vscode.Uri) => + vscode.workspace.openTextDocument(target).then( + (doc) => vscode.window.showTextDocument(doc, opts), + (err) => console.error("[Kilo New] openFile show failed:", err), + ) + vscode.workspace.fs.stat(uri).then( + () => show(uri), + () => { + const name = filePath.split(/[\\/]/).pop() || filePath + Promise.resolve(vscode.workspace.findFiles(`**/${name}`, "**/node_modules/**", 5)) + .then((matches) => { + if (matches.length === 1) { + show(matches[0]) + return + } + if (matches.length > 1) { + const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m })) + vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then((p) => { + if (p) show(p.uri) + }) + return + } + vscode.window.showWarningMessage(`File not found: ${filePath}`) + }) + .catch((err: unknown) => console.error("[Kilo New] findFiles failed:", err)) + }, + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 9843ad7d7d9..fb2c05928ea 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -148,6 +148,35 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openContent", content, language }) } + // File existence validation for code span candidates + const pending = new Map void>() + const counter = { n: 0 } + const validateFiles = (paths: string[]): Promise => { + const id = `vf-${++counter.n}` + return new Promise((resolve) => { + pending.set(id, resolve) + vscode.postMessage({ type: "validateFiles", id, paths }) + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id) + resolve([]) + } + }, 3000) + }) + } + const handler = (event: MessageEvent) => { + const msg = event.data + if (msg?.type === "validateFilesResult" && msg.id) { + const cb = pending.get(msg.id) + if (cb) { + pending.delete(msg.id) + cb(msg.existing ?? []) + } + } + } + onMount(() => window.addEventListener("message", handler)) + onCleanup(() => window.removeEventListener("message", handler)) + const directory = () => { const dir = server.workspaceDirectory() if (!dir) return "" @@ -166,6 +195,7 @@ export const DataBridge: Component<{ children: any }> = (props) => { onOpenDiff={openDiff} onOpenUrl={openUrl} onOpenContent={openContent} + onValidateFiles={validateFiles} > {props.children} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 76671975e38..25efccd5916 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -920,6 +920,12 @@ export interface RemoteStatusMessage { connected: boolean } +export interface ValidateFilesResultMessage { + type: "validateFilesResult" + id: string + existing: string[] +} + export type ExtensionMessage = | ReadyMessage | FontSizeChangedMessage @@ -1061,3 +1067,4 @@ export type ExtensionMessage = | ExtensionDataReadyMessage | TelemetryStateMessage | RemoteStatusMessage + | ValidateFilesResultMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 9c70feeb759..e9997dbabb5 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -138,6 +138,12 @@ export interface OpenContentRequest { language?: string } +export interface ValidateFilesRequest { + type: "validateFiles" + id: string + paths: string[] +} + export interface CancelLoginRequest { type: "cancelLogin" } @@ -1081,6 +1087,7 @@ export type WebviewMessage = | OpenAgentManagerRequest | OpenAdvancedWorktreeRequest | OpenFileRequest + | ValidateFilesRequest | CancelLoginRequest | SetOrganizationRequest | WebviewReadyRequest diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1190222956f..b3bc8052b3b 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -55,6 +55,24 @@ text-underline-offset: 2px; } + /* kilocode_change start — file-path links from markdown [text](path) */ + a.file-path-link { + color: var(--syntax-string); + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-weight: var(--font-weight-medium); + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + transition: color 0.15s ease; + } + + a.file-path-link:hover { + color: var(--text-interactive-base); + text-decoration-style: solid; + } + /* kilocode_change end */ + /* Lists */ ul, ol { diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index c5d913c2ee6..912ada465d6 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -45,6 +45,8 @@ export type OpenDiffFn = (diff: { export type OpenUrlFn = (url: string) => void export type OpenContentFn = (content: string, language?: string) => void // kilocode_change + +export type ValidateFilesFn = (paths: string[]) => Promise // kilocode_change // kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -58,6 +60,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onOpenDiff?: OpenDiffFn // kilocode_change onOpenUrl?: OpenUrlFn // kilocode_change onOpenContent?: OpenContentFn // kilocode_change + onValidateFiles?: ValidateFilesFn // kilocode_change }) => { return { get store() { @@ -72,6 +75,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ openDiff: props.onOpenDiff, // kilocode_change openUrl: props.onOpenUrl, // kilocode_change openContent: props.onOpenContent, // kilocode_change + validateFiles: props.onValidateFiles, // kilocode_change } }, }) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 3b332802bf0..90b649ed932 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -10,7 +10,7 @@ import katex from "katex" import type { MarkedExtension, TokenizerAndRendererExtension } from "marked" // kilocode_change end import { bundledLanguages, type BundledLanguage } from "shiki" -import { parseFilePath } from "../file-path" // kilocode_change +import { extractSuffix, normalizeCandidatePath, extractFilePathFromHref } from "../file-path" // kilocode_change import { createSimpleContext } from "./helper" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" @@ -471,8 +471,6 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise -// kilocode_change: parseFilePath imported from ../file-path - // kilocode_change start: highlight cache for deferred highlighting /** FNV-1a hash — lightweight alternative to storing full source code in DOM attributes. */ @@ -636,23 +634,32 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( renderer: { link({ href, title, text }) { const titleAttr = title ? ` title="${title}"` : "" + // kilocode_change: file-path links get a distinct class for styling + const isFile = href ? extractFilePathFromHref(href) : undefined + if (isFile) { + return `${text}` + } return `${text}` }, - // kilocode_change start + // kilocode_change start — every code span is a file-link candidate. + // Post-render validation (via filesystem stat) will strip the class + // from candidates that don't correspond to real files. codespan({ text }) { - const file = parseFilePath(text) const escaped = text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") - if (file) { - const lineAttr = file.line ? ` data-file-line="${file.line}"` : "" - const colAttr = file.column ? ` data-file-col="${file.column}"` : "" - return `${escaped}` + // Skip obvious non-paths: contains spaces, URLs, or is empty + if (!text || text.includes(" ") || text.includes("://")) { + return `${escaped}` } - return `${escaped}` + const { candidate, line, column } = extractSuffix(text) + const normalized = normalizeCandidatePath(candidate) + const lineAttr = line ? ` data-file-line="${line}"` : "" + const colAttr = column ? ` data-file-col="${column}"` : "" + return `${escaped}` }, code({ text, lang }) { const escaped = text diff --git a/packages/ui/src/file-path.test.ts b/packages/ui/src/file-path.test.ts index 16e8dc47627..d1b4b08e5cf 100644 --- a/packages/ui/src/file-path.test.ts +++ b/packages/ui/src/file-path.test.ts @@ -1,176 +1,156 @@ +// kilocode_change - new file import { describe, expect, it } from "bun:test" -import { parseFilePath, extractFilePathFromHref } from "./file-path" +import { extractSuffix, normalizeCandidatePath, 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 }) - }) +describe("extractSuffix", () => { + it("no suffix", () => { + expect(extractSuffix("src/foo.ts")).toEqual({ candidate: "src/foo.ts" }) + }) - it("dot-relative path", () => { - expect(parseFilePath("./src/foo.ts")).toEqual({ path: "./src/foo.ts", line: undefined, column: undefined }) - }) + it("line only", () => { + expect(extractSuffix("src/foo.ts:42")).toEqual({ candidate: "src/foo.ts", line: 42 }) + }) - it("parent-relative path", () => { - expect(parseFilePath("../lib/bar.ts")).toEqual({ path: "../lib/bar.ts", line: undefined, column: undefined }) - }) + it("line and column", () => { + expect(extractSuffix("src/foo.ts:42:10")).toEqual({ candidate: "src/foo.ts", line: 42, column: 10 }) + }) - it("absolute path", () => { - expect(parseFilePath("/Users/dev/project/main.ts")).toEqual({ - path: "/Users/dev/project/main.ts", - line: undefined, - column: undefined, - }) - }) + it("line range (extracts start line)", () => { + expect(extractSuffix("src/index.ts:1-30")).toEqual({ candidate: "src/index.ts", line: 1 }) + }) - it("with line number", () => { - expect(parseFilePath("src/foo.ts:42")).toEqual({ path: "src/foo.ts", line: 42, column: undefined }) - }) + it("line range with column", () => { + expect(extractSuffix("src/index.ts:10-21:5")).toEqual({ candidate: "src/index.ts", line: 10, column: 5 }) + }) - it("with line and column", () => { - expect(parseFilePath("src/foo.ts:42:10")).toEqual({ path: "src/foo.ts", line: 42, column: 10 }) - }) + it("bare word", () => { + expect(extractSuffix("LICENSE")).toEqual({ candidate: "LICENSE" }) + }) - 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("dotfile", () => { + expect(extractSuffix(".gitignore")).toEqual({ candidate: ".gitignore" }) + }) - it("path with @ scope", () => { - expect(parseFilePath("@scope/pkg/index.js")).toEqual({ - path: "@scope/pkg/index.js", - line: undefined, - column: undefined, - }) - }) + it("Windows drive path with line", () => { + expect(extractSuffix("C:\\src\\file.ts:12")).toEqual({ candidate: "C:\\src\\file.ts", line: 12 }) + }) - it("dotfile with path", () => { - expect(parseFilePath("src/.eslintrc.json")).toEqual({ - path: "src/.eslintrc.json", - line: undefined, - column: undefined, - }) - }) + it("path with @ scope", () => { + expect(extractSuffix("@scope/pkg/index.js:5")).toEqual({ candidate: "@scope/pkg/index.js", line: 5 }) }) - 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("empty string", () => { + expect(extractSuffix("")).toEqual({ candidate: "" }) + }) +}) - it("drive letter with forward slash", () => { - expect(parseFilePath("C:/Users/dev/file.ts")).toEqual({ - path: "C:/Users/dev/file.ts", - line: undefined, - column: undefined, - }) - }) +describe("normalizeCandidatePath", () => { + it("bare filename gets ./ prefix", () => { + expect(normalizeCandidatePath("LICENSE")).toBe("./LICENSE") + }) - it("lowercase drive", () => { - expect(parseFilePath("d:\\projects\\app.tsx")).toEqual({ - path: "d:\\projects\\app.tsx", - line: undefined, - column: undefined, - }) - }) + it("bare relative path gets ./ prefix", () => { + expect(normalizeCandidatePath("src/foo.ts")).toBe("./src/foo.ts") + }) - it("UNC path", () => { - expect(parseFilePath("\\\\server\\share\\file.ts")).toEqual({ - path: "\\\\server\\share\\file.ts", - line: undefined, - column: undefined, - }) - }) + it("dotfile gets ./ prefix", () => { + expect(normalizeCandidatePath(".gitignore")).toBe("./.gitignore") + }) - it("Windows path with line number", () => { - expect(parseFilePath("C:\\src\\file.ts:12")).toEqual({ path: "C:\\src\\file.ts", line: 12, column: undefined }) - }) + it("./ prefix preserved", () => { + expect(normalizeCandidatePath("./LICENSE")).toBe("./LICENSE") + }) - 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 }) - }) + it("../ prefix preserved", () => { + expect(normalizeCandidatePath("../lib/bar.ts")).toBe("../lib/bar.ts") }) - describe("rejects non-paths", () => { - it("URL with protocol", () => { - expect(parseFilePath("https://example.com/path.html")).toBeUndefined() - }) + it("absolute unix path preserved", () => { + expect(normalizeCandidatePath("/usr/bin/env")).toBe("/usr/bin/env") + }) - it("text with spaces", () => { - expect(parseFilePath("not a path.ts")).toBeUndefined() - }) + it("Windows drive path preserved", () => { + expect(normalizeCandidatePath("C:\\src\\file.ts")).toBe("C:\\src\\file.ts") + }) - it("bare word without extension", () => { - expect(parseFilePath("README")).toBeUndefined() - }) + it("UNC path preserved", () => { + expect(normalizeCandidatePath("\\\\server\\share")).toBe("\\\\server\\share") + }) - it("empty string", () => { - expect(parseFilePath("")).toBeUndefined() - }) + it("strips a/ diff prefix", () => { + expect(normalizeCandidatePath("a/src/app.ts")).toBe("./src/app.ts") + }) - it("file:// URL", () => { - expect(parseFilePath("file:///foo/bar.ts")).toBeUndefined() - }) + it("strips b/ diff prefix", () => { + expect(normalizeCandidatePath("b/src/app.ts")).toBe("./src/app.ts") + }) - it("just a number", () => { - expect(parseFilePath("42")).toBeUndefined() - }) + it("Windows forward-slash drive path preserved", () => { + expect(normalizeCandidatePath("C:/src/file.ts")).toBe("C:/src/file.ts") + }) - it("path without extension", () => { - expect(parseFilePath("src/Makefile")).toBeUndefined() - }) + it("single letter not treated as diff prefix", () => { + // "c/foo" should NOT strip "c/" — only "a/" and "b/" are diff prefixes + expect(normalizeCandidatePath("c/foo.ts")).toBe("./c/foo.ts") }) }) describe("extractFilePathFromHref", () => { describe("accepts file-like hrefs", () => { it("bare filename", () => { - expect(extractFilePathFromHref("AGENTS.md")).toBe("AGENTS.md") + expect(extractFilePathFromHref("AGENTS.md")).toEqual({ path: "AGENTS.md" }) }) it("relative path", () => { - expect(extractFilePathFromHref("src/foo.ts")).toBe("src/foo.ts") + expect(extractFilePathFromHref("src/foo.ts")).toEqual({ path: "src/foo.ts" }) }) it("dot-relative path", () => { - expect(extractFilePathFromHref("./README.md")).toBe("./README.md") + expect(extractFilePathFromHref("./README.md")).toEqual({ path: "./README.md" }) }) it("parent-relative path", () => { - expect(extractFilePathFromHref("../docs/guide.md")).toBe("../docs/guide.md") + expect(extractFilePathFromHref("../docs/guide.md")).toEqual({ path: "../docs/guide.md" }) }) + }) - it("path with multiple extensions", () => { - expect(extractFilePathFromHref("config.test.ts")).toBe("config.test.ts") + describe("extracts line and column", () => { + it("path with line", () => { + expect(extractFilePathFromHref("src/foo.ts:42")).toEqual({ path: "src/foo.ts", line: 42 }) + }) + + it("path with line and column", () => { + expect(extractFilePathFromHref("src/foo.ts:42:10")).toEqual({ path: "src/foo.ts", line: 42, column: 10 }) + }) + + it("path with line range", () => { + expect(extractFilePathFromHref("src/index.ts:1-30")).toEqual({ path: "src/index.ts", line: 1 }) + }) + + it("Windows path with line and column", () => { + expect(extractFilePathFromHref("C:\\src\\file.ts:12:5")).toEqual({ + path: "C:\\src\\file.ts", + line: 12, + column: 5, + }) }) }) - describe("strips fragments and queries", () => { + describe("strips fragments, queries, and diff prefixes", () => { it("strips #fragment", () => { - expect(extractFilePathFromHref("AGENTS.md#worktrees")).toBe("AGENTS.md") + expect(extractFilePathFromHref("AGENTS.md#worktrees")).toEqual({ path: "AGENTS.md" }) }) it("strips ?query", () => { - expect(extractFilePathFromHref("README.md?plain=1")).toBe("README.md") + expect(extractFilePathFromHref("README.md?plain=1")).toEqual({ path: "README.md" }) }) - it("strips both fragment and query", () => { - expect(extractFilePathFromHref("docs/guide.md?v=2#section")).toBe("docs/guide.md") + it("strips a/ prefix", () => { + expect(extractFilePathFromHref("a/src/app.ts")).toEqual({ path: "src/app.ts" }) }) - it("strips fragment from path with directory", () => { - expect(extractFilePathFromHref("src/foo.ts#L42")).toBe("src/foo.ts") + it("strips b/ prefix with line", () => { + expect(extractFilePathFromHref("b/src/app.ts:42")).toEqual({ path: "src/app.ts", line: 42 }) }) }) @@ -179,24 +159,12 @@ describe("extractFilePathFromHref", () => { 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("file:// URL returns path", () => { + expect(extractFilePathFromHref("file:///foo/bar.ts")).toEqual({ path: "/foo/bar.ts" }) }) it("ftp URL", () => { @@ -208,30 +176,38 @@ describe("extractFilePathFromHref", () => { }) }) - describe("rejects anchors and non-file values", () => { + describe("rejects anchors and empty", () => { 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("http URL", () => { + expect(extractFilePathFromHref("http://localhost:3000/index.html")).toBeUndefined() }) - it("directory-only path (no extension)", () => { - expect(extractFilePathFromHref("src/components/")).toBeUndefined() + it("javascript scheme", () => { + expect(extractFilePathFromHref("javascript:void(0)")).toBeUndefined() }) - it("fragment-only after stripping resolves to empty", () => { - // href is "#foo" — starts with # so rejected - expect(extractFilePathFromHref("#foo.md")).toBeUndefined() + it("tel scheme", () => { + expect(extractFilePathFromHref("tel:+1234567890")).toBeUndefined() + }) + }) + + describe("Windows drive letter not treated as scheme", () => { + it("C: drive path accepted", () => { + expect(extractFilePathFromHref("C:\\Users\\dev\\file.ts")).toEqual({ path: "C:\\Users\\dev\\file.ts" }) + }) + + it("D: drive path with line", () => { + expect(extractFilePathFromHref("D:\\projects\\app.tsx:10")).toEqual({ + path: "D:\\projects\\app.tsx", + line: 10, + }) }) }) }) diff --git a/packages/ui/src/file-path.ts b/packages/ui/src/file-path.ts index a3f7b9ae635..206d8f9992d 100644 --- a/packages/ui/src/file-path.ts +++ b/packages/ui/src/file-path.ts @@ -1,42 +1,48 @@ // 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+))?)?$/ +/** + * Strip an optional :line[-endline][:col] suffix from a code span. + * Returns the candidate file path and optional line/column numbers. + */ +export function extractSuffix(text: string): { candidate: string; line?: number; column?: number } { + // Try :line:col first, then :line (with optional -endline range) + const m3 = /^(.+):(\d+)(?:-\d+)?:(\d+)$/.exec(text) + if (m3) return { candidate: m3[1], line: +m3[2], column: +m3[3] } + const m2 = /^(.+):(\d+)(?:-\d+)?$/.exec(text) + if (m2) return { candidate: m2[1], line: +m2[2] } + return { candidate: text } +} /** - * 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`). + * Normalize a candidate path for filesystem validation. + * Ensures the path has a ./ prefix if it's a bare relative path, + * so the extension can stat-check it against the workspace root. */ -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, - } +export function normalizeCandidatePath(path: string): string { + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return path + // Windows absolute paths (C:\...) — leave as-is + if (/^[a-zA-Z]:[/\\]/.test(path)) return path + // Windows UNC paths (\\server\...) — leave as-is + if (path.startsWith("\\\\")) return path + // Strip a/b diff prefixes + const stripped = path.replace(/^[ab]\//, "") + return `./${stripped}` } -const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ +// Matches a URI scheme but NOT a Windows drive letter (single char followed by colon). +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. + * Extract a file path (with optional line/column) 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. + * Strips `#fragment` and `?query` suffixes, then parses an optional + * `:line` or `:line:column` suffix from the remaining path. */ -export function extractFilePathFromHref(href: string): string | undefined { +export function extractFilePathFromHref( + href: string, +): { path: string; line?: number; column?: number } | undefined { if (!href) return undefined // Handle file:// URLs — extract the path component and decode it if (href.startsWith("file://")) { @@ -44,15 +50,13 @@ export function extractFilePathFromHref(href: string): string | undefined { const url = new URL(href) const decoded = decodeURIComponent(url.pathname) if (!decoded) return undefined - // On Windows, file:///C:/foo gives pathname=/C:/foo — strip the leading slash - // so the result is a valid Windows absolute path (C:/foo). const c1 = decoded.charCodeAt(1) const isWindowsDrive = decoded.length >= 4 && decoded.charCodeAt(0) === 47 /* / */ && decoded.charCodeAt(2) === 58 /* : */ && ((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */ - return isWindowsDrive ? decoded.slice(1) : decoded + return { path: isWindowsDrive ? decoded.slice(1) : decoded } } catch { return undefined } @@ -64,7 +68,8 @@ export function extractFilePathFromHref(href: string): string | 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 + // Strip a/b diff prefixes, parse :line[:col] suffix + const stripped = cleaned.replace(/^[ab]\//, "") + const { candidate, line, column } = extractSuffix(stripped) + return { path: candidate, line, column } }