feat(vscode): add filesystem validation protocol for file links

Add a validateFiles request/response round-trip between the webview and
the extension so the webview can confirm which inline code-span
candidates are real files before promoting them to clickable links.

The extension stat-checks candidate paths (new file-links.ts) and
replies with the subset that exist. Routing lives in editor-actions
alongside the other editor open actions, and openFile now falls back to
a workspace filename search (single match opens, multiple prompts) with
a "File not found" warning when a clicked path cannot be resolved.
This commit is contained in:
Sylwester Liljegren
2026-06-14 15:08:01 +02:00
parent fcb8802aa5
commit ed5fc5a45f
7 changed files with 126 additions and 13 deletions
+1
View File
@@ -1233,6 +1233,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
diff: this.diffVirtualProvider,
storage: this.extensionContext?.globalStorageUri,
post: (msg) => this.postMessage(msg),
})
}
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview"
import { isAbsolutePath } from "../path-utils"
import { validateFiles } from "./file-links"
import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider"
type EditorOpenMessage = {
@@ -73,6 +74,7 @@ export function handleEditorAction(
dir: () => string
diff?: DiffVirtualProvider
storage?: vscode.Uri
post?: (msg: unknown) => void
},
): boolean {
if (message.type === "openFile") {
@@ -83,6 +85,17 @@ export function handleEditorAction(
if (message.content) openContent(message.content, message.language)
return true
}
if (message.type === "validateFiles") {
const id = (message as { id?: string }).id
const paths = (message as { paths?: string[] }).paths
if (id && paths && opts.post) {
validateFiles(opts.dir(), paths).then(
(existing) => opts.post!({ type: "validateFilesResult", id, existing }),
(err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err),
)
}
return true
}
if (message.type === "openExternal") {
openExternal(message.url)
return true
@@ -105,6 +118,46 @@ function openContent(content: string, language?: string): void {
)
}
function show(uri: vscode.Uri, line?: number, column?: number): void {
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),
)
}
/**
* Fallback when the exact path does not exist: search the workspace by filename.
* Opens the file directly on a single match, prompts on multiple, warns on none.
*/
function findFallback(filePath: string, line?: number, column?: number): void {
const name = filePath.split(/[\\/]/).pop() || filePath
Promise.resolve(vscode.workspace.findFiles(`**/${name}`, "**/node_modules/**", 5)).then(
(matches) => {
if (matches.length === 1) {
show(matches[0], line, column)
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((pick) => {
if (pick) show(pick.uri, line, column)
})
return
}
vscode.window.showWarningMessage(`File not found: ${filePath}`)
},
(err: unknown) => console.error("[Kilo New] KiloProvider: findFiles failed:", err),
)
}
function openFile(dir: string, filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath)
vscode.workspace.fs.stat(uri).then(
@@ -113,19 +166,8 @@ function openFile(dir: string, filePath: string, line?: number, column?: number)
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
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),
)
show(uri, line, column)
},
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
() => findFallback(filePath, line, column),
)
}
@@ -0,0 +1,22 @@
import * as vscode from "vscode"
import { isAbsolutePath } from "../path-utils"
/**
* Stat-check candidate paths and return which ones are actual files (not directories).
*
* The webview marks every inline code span as a file-link candidate; this confirms
* which of those candidates resolve to a real file so the webview can promote them
* to clickable links and leave the rest as plain code.
*/
export function validateFiles(root: string, paths: string[]): Promise<string[]> {
const resolve = (p: string) =>
isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p)
return Promise.all(
paths.map((p) =>
Promise.resolve(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))
}
@@ -149,6 +149,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<string, (existing: string[]) => void>()
const counter = { n: 0 }
const validateFiles = (paths: string[]): Promise<string[]> => {
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 ""
@@ -167,6 +196,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
onValidateFiles={validateFiles}
>
{props.children}
</DataProvider>
@@ -962,6 +962,12 @@ export interface RemoteStatusMessage {
connected: boolean
}
export interface ValidateFilesResultMessage {
type: "validateFilesResult"
id: string
existing: string[]
}
export type ExtensionMessage =
| ReadyMessage
| FontSizeChangedMessage
@@ -1108,3 +1114,4 @@ export type ExtensionMessage =
| ExtensionDataReadyMessage
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
@@ -142,6 +142,12 @@ export interface OpenContentRequest {
language?: string
}
export interface ValidateFilesRequest {
type: "validateFiles"
id: string
paths: string[]
}
export interface CancelLoginRequest {
type: "cancelLogin"
}
@@ -1107,6 +1113,7 @@ export type WebviewMessage =
| OpenAgentManagerRequest
| OpenAdvancedWorktreeRequest
| OpenFileRequest
| ValidateFilesRequest
| CancelLoginRequest
| SetOrganizationRequest
| WebviewReadyRequest
+4
View File
@@ -48,6 +48,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<string[]> // kilocode_change
// kilocode_change end
export const { use: useData, provider: DataProvider } = createSimpleContext({
@@ -61,6 +63,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() {
@@ -75,6 +78,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
}
},
})