mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #12028 from sylwester-liljegren/feat/vscode-file-picker-mention
feat(vscode): add file picker to @ mention dropdown
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a "Browse files..." option to the @ mention dropdown in the VS Code extension prompt input. Selecting it opens a native file picker and mentions the chosen file, so you can point Kilo Code at files outside the current workspace. Files outside the workspace are not auto-attached; Kilo Code reads them on request through the normal Read tool, respecting your file access permissions.
|
||||
@@ -65,6 +65,7 @@ import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
|
||||
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
|
||||
import { renameSession } from "./kilo-provider/rename-session"
|
||||
import { handleFileSearch } from "./kilo-provider/file-search"
|
||||
import { handleFilePicker } from "./kilo-provider/file-picker"
|
||||
import { watchFontSizeConfig } from "./kilo-provider/font-size"
|
||||
import { getTerminalContents } from "./services/terminal/context"
|
||||
import { disposeGitChangesTarget } from "./kilo-provider/git-changes-target"
|
||||
@@ -110,7 +111,9 @@ import {
|
||||
handleSkipLegacyMigration,
|
||||
handleClearLegacyData,
|
||||
type MigrationContext,
|
||||
type MigrationSource,
|
||||
} from "./kilo-provider/handlers/migration"
|
||||
import type { MigrationSelections } from "./legacy-migration/legacy-types"
|
||||
// legacy-migration end
|
||||
import {
|
||||
handleLogin,
|
||||
@@ -944,6 +947,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (await this.handleModelSelectorExpandedMessage(message)) return
|
||||
this.visibleTaskStreams.handle(message)
|
||||
if (await this.handleMemoryMessage(message)) return
|
||||
if (this.handleLegacyMigrationMessage(message)) return
|
||||
switch (message.type) {
|
||||
case "webviewReady":
|
||||
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
|
||||
@@ -1277,6 +1281,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
post: (msg) => this.postMessage(msg),
|
||||
})
|
||||
break
|
||||
case "requestFilePicker":
|
||||
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
|
||||
break
|
||||
case "requestTerminalContext":
|
||||
void this.handleTerminalContext(message.requestId)
|
||||
break
|
||||
@@ -1391,23 +1398,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({ type: "favoritesLoaded", favorites })
|
||||
break
|
||||
}
|
||||
// legacy-migration start
|
||||
case "requestMigrationData":
|
||||
void handleRequestMigrationData(this.migrationCtx, message.source, message.operationId)
|
||||
break
|
||||
case "startMigration":
|
||||
void handleStartMigration(this.migrationCtx, message.source, message.operationId, message.selections)
|
||||
break
|
||||
case "skipLegacyMigration":
|
||||
void handleSkipLegacyMigration(this.migrationCtx)
|
||||
break
|
||||
case "clearLegacyData":
|
||||
void handleClearLegacyData(this.migrationCtx)
|
||||
break
|
||||
case "finalizeLegacyMigration":
|
||||
void handleFinalizeLegacyMigration(this.migrationCtx)
|
||||
break
|
||||
// legacy-migration end
|
||||
case "enhancePrompt": {
|
||||
const sdkClient = this.client
|
||||
if (!sdkClient) {
|
||||
@@ -1466,6 +1456,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return false
|
||||
}
|
||||
|
||||
// legacy-migration start
|
||||
private handleLegacyMigrationMessage(message: { type: string }): boolean {
|
||||
switch (message.type) {
|
||||
case "requestMigrationData": {
|
||||
const msg = message as unknown as { source: MigrationSource; operationId: string }
|
||||
void handleRequestMigrationData(this.migrationCtx, msg.source, msg.operationId)
|
||||
break
|
||||
}
|
||||
case "startMigration": {
|
||||
const msg = message as unknown as {
|
||||
source: MigrationSource
|
||||
operationId: string
|
||||
selections: MigrationSelections
|
||||
}
|
||||
void handleStartMigration(this.migrationCtx, msg.source, msg.operationId, msg.selections)
|
||||
break
|
||||
}
|
||||
case "skipLegacyMigration":
|
||||
void handleSkipLegacyMigration(this.migrationCtx)
|
||||
break
|
||||
case "clearLegacyData":
|
||||
void handleClearLegacyData(this.migrationCtx)
|
||||
break
|
||||
case "finalizeLegacyMigration":
|
||||
void handleFinalizeLegacyMigration(this.migrationCtx)
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// legacy-migration end
|
||||
|
||||
private async toggleFavorite(message: {
|
||||
action: "add" | "remove"
|
||||
providerID: string
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as vscode from "vscode"
|
||||
|
||||
type Input = {
|
||||
requestId: string
|
||||
post: (message: unknown) => void
|
||||
}
|
||||
|
||||
export async function handleFilePicker(input: Input): Promise<void> {
|
||||
const uri = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: false,
|
||||
openLabel: "Select file",
|
||||
})
|
||||
input.post({
|
||||
type: "filePickerResult",
|
||||
path: uri && uri[0] ? uri[0].fsPath : "",
|
||||
requestId: input.requestId,
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
getMentionRemovalRange,
|
||||
isCursorAtMentionEnd,
|
||||
findMentionRange,
|
||||
FILE_PICKER_RESULT,
|
||||
TERMINAL_RESULT,
|
||||
GIT_CHANGES_RESULT,
|
||||
} from "../../webview-ui/src/hooks/file-mention-utils"
|
||||
|
||||
describe("AT_PATTERN", () => {
|
||||
@@ -53,32 +56,42 @@ describe("buildMentionResults", () => {
|
||||
|
||||
it("includes terminal for matching prefix", () => {
|
||||
const result = buildMentionResults("term", ["src/terminal.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["terminal", "file"])
|
||||
expect(result.map((item) => item.type)).toEqual(["terminal", "file-picker", "file"])
|
||||
})
|
||||
|
||||
it("includes git changes for matching prefix", () => {
|
||||
const result = buildMentionResults("git", ["src/git.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["git-changes", "file"])
|
||||
expect(result.map((item) => item.type)).toEqual(["git-changes", "file-picker", "file"])
|
||||
})
|
||||
|
||||
it("omits special mentions for unrelated query", () => {
|
||||
const result = buildMentionResults("src", ["src/index.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file-picker", "file"])
|
||||
})
|
||||
|
||||
it("omits git changes when git is unavailable", () => {
|
||||
const result = buildMentionResults("git", ["src/git.ts"], false)
|
||||
expect(result.map((item) => item.type)).toEqual(["file"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file-picker", "file"])
|
||||
})
|
||||
|
||||
it("includes folder results", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
|
||||
expect(result).toEqual([{ type: "folder", value: "src" }])
|
||||
expect(result).toEqual([FILE_PICKER_RESULT, { type: "folder", value: "src" }])
|
||||
})
|
||||
|
||||
it("preserves opened file result type", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }])
|
||||
expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }])
|
||||
expect(result).toEqual([FILE_PICKER_RESULT, { type: "opened-file", value: "src/index.ts" }])
|
||||
})
|
||||
|
||||
it("always includes file picker result, placed after terminal/git-changes and before file results", () => {
|
||||
const result = buildMentionResults("", ["src/index.ts"])
|
||||
expect(result).toEqual([
|
||||
TERMINAL_RESULT,
|
||||
GIT_CHANGES_RESULT,
|
||||
FILE_PICKER_RESULT,
|
||||
{ type: "file", value: "src/index.ts" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,8 +100,14 @@ describe("filterMentionResults", () => {
|
||||
const result = filterMentionResults("gi", [
|
||||
{ type: "file", value: "README.md" },
|
||||
{ type: "file", value: "src/git.ts" },
|
||||
FILE_PICKER_RESULT,
|
||||
])
|
||||
expect(result).toEqual([{ type: "file", value: "src/git.ts" }])
|
||||
expect(result).toEqual([{ type: "file", value: "src/git.ts" }, FILE_PICKER_RESULT])
|
||||
})
|
||||
|
||||
it("always preserves file picker result regardless of query", () => {
|
||||
const result = filterMentionResults("zz", [FILE_PICKER_RESULT])
|
||||
expect(result).toEqual([FILE_PICKER_RESULT])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -230,11 +249,50 @@ describe("buildFileAttachments", () => {
|
||||
expect(result[0]!.url).toContain("foo.ts")
|
||||
})
|
||||
|
||||
it("handles absolute paths directly", () => {
|
||||
it("attaches an absolute path that lives inside the workspace", () => {
|
||||
const paths = new Set(["/workspace/src/file.ts"])
|
||||
const result = buildFileAttachments("@/workspace/src/file.ts", paths, "/workspace")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.url).toContain("/workspace/src/file.ts")
|
||||
})
|
||||
|
||||
it("does not attach an absolute Unix path outside the workspace", () => {
|
||||
const paths = new Set(["/abs/path/file.ts"])
|
||||
const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("does not attach an absolute Windows path outside the workspace", () => {
|
||||
const paths = new Set(["C:/Users/file.ts"])
|
||||
const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("does not attach a UNC path outside the workspace", () => {
|
||||
const paths = new Set(["\\\\server\\share\\file.ts"])
|
||||
const result = buildFileAttachments("@\\\\server\\share\\file.ts", paths, "/workspace")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("does not attach an absolute path that escapes the workspace via ../ segments", () => {
|
||||
const paths = new Set(["/workspace/../../etc/passwd"])
|
||||
const result = buildFileAttachments("@/workspace/../../etc/passwd", paths, "/workspace")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("does not attach a relative-looking mention that escapes the workspace via ../ segments", () => {
|
||||
// Simulates a path seeded from raw text (e.g. seedFromText) rather than the
|
||||
// file picker or file search, which never produce a leading "../".
|
||||
const paths = new Set(["../../etc/passwd"])
|
||||
const result = buildFileAttachments("@../../etc/passwd", paths, "/workspace")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("attaches a relative mention with ../ segments that still resolves inside the workspace", () => {
|
||||
const paths = new Set(["sub/../foo.ts"])
|
||||
const result = buildFileAttachments("@sub/../foo.ts", paths, "/workspace")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.url).toContain("/abs/path/file.ts")
|
||||
expect(result[0]!.url).toContain("/workspace/foo.ts")
|
||||
})
|
||||
|
||||
it("normalizes Windows backslashes in workspaceDir", () => {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
|
||||
import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var document: { execCommand: (commandId: string, showUI?: boolean, value?: string) => boolean }
|
||||
}
|
||||
|
||||
const hadDoc = "document" in globalThis
|
||||
const originalDoc = hadDoc ? globalThis.document : undefined
|
||||
|
||||
function mockDocument() {
|
||||
globalThis.document = { execCommand: () => true }
|
||||
}
|
||||
|
||||
function restoreDocument() {
|
||||
if (hadDoc && originalDoc) {
|
||||
globalThis.document = originalDoc
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (globalThis as any).document
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function textarea(
|
||||
@@ -78,11 +100,17 @@ describe("useFileMention", () => {
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
|
||||
expect(mention.mentionResults()).toEqual([
|
||||
FILE_PICKER_RESULT,
|
||||
{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" },
|
||||
])
|
||||
|
||||
mention.onInput("@ex", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
|
||||
expect(mention.mentionResults()).toEqual([
|
||||
FILE_PICKER_RESULT,
|
||||
{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" },
|
||||
])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
@@ -119,7 +147,7 @@ describe("useFileMention", () => {
|
||||
|
||||
mention.onInput("@zz", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([])
|
||||
expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
@@ -220,7 +248,7 @@ describe("useFileMention", () => {
|
||||
|
||||
mention.onInput("@gi", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }])
|
||||
expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT, { type: "file", value: "src/git.ts" }])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
@@ -395,4 +423,245 @@ describe("useFileMention", () => {
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("selecting file picker sends requestFilePicker and stores state", async () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: () => () => {},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
const state = { value: "hello @b", cursor: 8 }
|
||||
const input = {
|
||||
value: state.value,
|
||||
get selectionStart() {
|
||||
return state.cursor
|
||||
},
|
||||
get selectionEnd() {
|
||||
return state.cursor
|
||||
},
|
||||
isConnected: true,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
state.cursor = end
|
||||
},
|
||||
focus: () => {},
|
||||
} as unknown as HTMLTextAreaElement
|
||||
|
||||
let execCalled = false
|
||||
mockDocument()
|
||||
globalThis.document.execCommand = () => {
|
||||
execCalled = true
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
mention.selectMention(
|
||||
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
|
||||
input,
|
||||
() => {},
|
||||
)
|
||||
} finally {
|
||||
restoreDocument()
|
||||
}
|
||||
|
||||
expect(posted).toEqual([{ type: "requestFilePicker", requestId: expect.any(String) }])
|
||||
expect(execCalled).toBe(false)
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("insertFilePickerResult inserts the path at the stored position", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: () => () => {},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
const state = { value: "hello @b", cursor: 8, textSet: "" }
|
||||
const input = {
|
||||
get value() {
|
||||
return state.value
|
||||
},
|
||||
get selectionStart() {
|
||||
return state.cursor
|
||||
},
|
||||
get selectionEnd() {
|
||||
return state.cursor
|
||||
},
|
||||
isConnected: true,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
state.value = state.value.slice(0, start) + state.value.slice(end)
|
||||
state.cursor = start
|
||||
},
|
||||
focus: () => {},
|
||||
} as unknown as HTMLTextAreaElement
|
||||
|
||||
mockDocument()
|
||||
globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => {
|
||||
state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor)
|
||||
state.cursor = state.cursor + val.length
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
mention.selectMention(
|
||||
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
|
||||
input,
|
||||
(text: string) => {
|
||||
state.textSet = text
|
||||
},
|
||||
)
|
||||
const requestId = (posted.at(-1) as { requestId: string }).requestId
|
||||
mention.insertFilePickerResult("/outside/file.ts", requestId)
|
||||
} finally {
|
||||
restoreDocument()
|
||||
}
|
||||
|
||||
expect(state.value).toBe("hello @/outside/file.ts ")
|
||||
expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(true)
|
||||
expect(state.textSet).toBe("hello @/outside/file.ts ")
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("insertFilePickerResult normalizes Windows backslashes to forward slashes", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: () => () => {},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
const state = { value: "hello @b", cursor: 8, textSet: "" }
|
||||
const input = {
|
||||
get value() {
|
||||
return state.value
|
||||
},
|
||||
get selectionStart() {
|
||||
return state.cursor
|
||||
},
|
||||
get selectionEnd() {
|
||||
return state.cursor
|
||||
},
|
||||
isConnected: true,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
state.value = state.value.slice(0, start) + state.value.slice(end)
|
||||
state.cursor = start
|
||||
},
|
||||
focus: () => {},
|
||||
} as unknown as HTMLTextAreaElement
|
||||
|
||||
mockDocument()
|
||||
globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => {
|
||||
state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor)
|
||||
state.cursor = state.cursor + val.length
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
mention.selectMention(
|
||||
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
|
||||
input,
|
||||
(text: string) => {
|
||||
state.textSet = text
|
||||
},
|
||||
)
|
||||
const requestId = (posted.at(-1) as { requestId: string }).requestId
|
||||
mention.insertFilePickerResult("C:\\Users\\file.ts", requestId)
|
||||
} finally {
|
||||
restoreDocument()
|
||||
}
|
||||
|
||||
expect(state.value).toBe("hello @C:/Users/file.ts ")
|
||||
expect(mention.mentionedPaths().has("C:/Users/file.ts")).toBe(true)
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("insertFilePickerResult with empty path cleans up state", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: () => () => {},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
const input = {
|
||||
value: "hello @b",
|
||||
selectionStart: 8,
|
||||
selectionEnd: 8,
|
||||
isConnected: true,
|
||||
setSelectionRange: () => {},
|
||||
focus: () => {},
|
||||
} as unknown as HTMLTextAreaElement
|
||||
|
||||
mention.selectMention(
|
||||
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
|
||||
input,
|
||||
() => {},
|
||||
)
|
||||
const requestId = (posted.at(-1) as { requestId: string }).requestId
|
||||
mention.insertFilePickerResult("", requestId)
|
||||
|
||||
expect(input.value).toBe("hello @b")
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("insertFilePickerResult ignores a result whose requestId doesn't match the pending request", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: () => () => {},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
const input = {
|
||||
value: "hello @b",
|
||||
selectionStart: 8,
|
||||
selectionEnd: 8,
|
||||
isConnected: true,
|
||||
setSelectionRange: () => {},
|
||||
focus: () => {},
|
||||
} as unknown as HTMLTextAreaElement
|
||||
|
||||
mention.selectMention(
|
||||
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
|
||||
input,
|
||||
() => {},
|
||||
)
|
||||
mention.insertFilePickerResult("/outside/file.ts", "stale-request-id")
|
||||
|
||||
expect(input.value).toBe("hello @b")
|
||||
expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(false)
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -701,6 +701,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setEnhancing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "filePickerResult") {
|
||||
mention.insertFilePickerResult(message.path, message.requestId)
|
||||
}
|
||||
})
|
||||
vscode.postMessage({ type: "requestAutoApproveState" })
|
||||
|
||||
@@ -1116,40 +1120,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<For each={mention.mentionResults()}>
|
||||
{(item, index) => (
|
||||
<div
|
||||
class="file-mention-item"
|
||||
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight)
|
||||
}}
|
||||
onMouseEnter={() => mention.setMentionIndex(index())}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<>
|
||||
<Icon name="console" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
) : item.type === "git-changes" ? (
|
||||
<>
|
||||
<Icon name="branch" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileIcon
|
||||
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
|
||||
class="file-mention-icon"
|
||||
/>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
|
||||
</span>
|
||||
<span class="file-mention-dir">{dirName(item.value)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
class="file-mention-item"
|
||||
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight)
|
||||
}}
|
||||
onMouseEnter={() => mention.setMentionIndex(index())}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<>
|
||||
<Icon name="console" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
) : item.type === "git-changes" ? (
|
||||
<>
|
||||
<Icon name="branch" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
) : item.type === "file-picker" ? (
|
||||
<>
|
||||
<Icon name="folder" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileIcon
|
||||
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
|
||||
class="file-mention-icon"
|
||||
/>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
|
||||
</span>
|
||||
<span class="file-mention-dir">{dirName(item.value)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Show when={item.type === "file-picker" && index() < mention.mentionResults().length - 1}>
|
||||
<div class="file-mention-separator" />
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type MentionResult =
|
||||
| { type: "file"; value: string }
|
||||
| { type: "opened-file"; value: string }
|
||||
| { type: "folder"; value: string }
|
||||
| { type: "file-picker"; value: "file-picker"; label: string; description: string }
|
||||
|
||||
export const TERMINAL_RESULT: MentionResult = {
|
||||
type: "terminal",
|
||||
@@ -25,6 +26,13 @@ export const GIT_CHANGES_RESULT: MentionResult = {
|
||||
description: "Current session/worktree changes",
|
||||
}
|
||||
|
||||
export const FILE_PICKER_RESULT: MentionResult = {
|
||||
type: "file-picker",
|
||||
value: "file-picker",
|
||||
label: "Browse files...",
|
||||
description: "Select a file outside the workspace",
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string so it can be used in a RegExp.
|
||||
*/
|
||||
@@ -51,7 +59,12 @@ export function buildMentionResults(query: string, items: Array<FileSearchItem |
|
||||
if (item.type === "opened-file") return { type: "opened-file", value: item.path }
|
||||
return { type: "file", value: item.path }
|
||||
})
|
||||
return [...getTerminalMentionResult(query), ...(git ? getGitChangesMentionResult(query) : []), ...results]
|
||||
return [
|
||||
...getTerminalMentionResult(query),
|
||||
...(git ? getGitChangesMentionResult(query) : []),
|
||||
FILE_PICKER_RESULT,
|
||||
...results,
|
||||
]
|
||||
}
|
||||
|
||||
export function filterMentionResults(query: string, items: MentionResult[]): MentionResult[] {
|
||||
@@ -60,6 +73,7 @@ export function filterMentionResults(query: string, items: MentionResult[]): Men
|
||||
return items.filter((item) => {
|
||||
if (item.type === "terminal") return TERMINAL_MENTION.startsWith(value)
|
||||
if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value)
|
||||
if (item.type === "file-picker") return true
|
||||
return item.value.toLowerCase().includes(value)
|
||||
})
|
||||
}
|
||||
@@ -166,8 +180,54 @@ export function findMentionRange(
|
||||
return null
|
||||
}
|
||||
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) || path.startsWith("\\\\")
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse "." and ".." segments in a forward-slash path so a traversal like
|
||||
* "/workspace/../../etc/passwd" resolves to its real location ("/etc/passwd")
|
||||
* before any workspace-containment check runs. Preserves a leading drive
|
||||
* letter (`C:`) and distinguishes a UNC root ("//server") from a plain root
|
||||
* ("/"). ".." segments that would go above the root are dropped rather than
|
||||
* kept, matching filesystem semantics for an absolute path.
|
||||
*/
|
||||
function normalizeAbsolutePath(input: string): string {
|
||||
const drive = input.match(/^[A-Za-z]:/)?.[0] ?? ""
|
||||
const rest = drive ? input.slice(drive.length) : input
|
||||
const root = rest.startsWith("//") ? "//" : rest.startsWith("/") ? "/" : ""
|
||||
const segments = rest
|
||||
.slice(root.length)
|
||||
.split("/")
|
||||
.filter((s) => s.length > 0 && s !== ".")
|
||||
const stack: string[] = []
|
||||
for (const seg of segments) {
|
||||
if (seg === "..") {
|
||||
if (stack.length > 0) stack.pop()
|
||||
continue
|
||||
}
|
||||
stack.push(seg)
|
||||
}
|
||||
return `${drive}${root}${stack.join("/")}`
|
||||
}
|
||||
|
||||
/** Whether `abs` is the workspace root or lives under it (both already normalized). */
|
||||
function isInsideWorkspace(abs: string, dir: string): boolean {
|
||||
return abs === dir || abs.startsWith(`${dir}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FileAttachment objects from currently mentioned paths in the text.
|
||||
*
|
||||
* Paths outside the workspace (e.g. picked via the file picker, or seeded from
|
||||
* raw draft text via a "../.." traversal) are deliberately excluded: attaching a
|
||||
* file reads its content on the backend through a path that bypasses the
|
||||
* permission system, including any prior "deny" decision for that file. Such
|
||||
* paths remain visible and clickable as a styled mention in the UI, but are not
|
||||
* auto-attached — if the model needs their contents it must call the Read tool,
|
||||
* which enforces the normal external-directory permission checks. Every
|
||||
* resolved path (relative or absolute) is normalized before the containment
|
||||
* check so a "../" sequence can't slip past a literal string-prefix match.
|
||||
*/
|
||||
export function buildFileAttachments(
|
||||
text: string,
|
||||
@@ -175,10 +235,12 @@ export function buildFileAttachments(
|
||||
workspaceDir: string,
|
||||
): FileAttachment[] {
|
||||
const result: FileAttachment[] = []
|
||||
const dir = workspaceDir.replaceAll("\\", "/")
|
||||
const dir = normalizeAbsolutePath(workspaceDir.replaceAll("\\", "/")).replace(/\/+$/, "")
|
||||
for (const path of mentionedPaths) {
|
||||
if (text.includes(`@${path}`)) {
|
||||
const abs = path.startsWith("/") ? path : `${dir}/${path}`
|
||||
const raw = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}`
|
||||
const abs = normalizeAbsolutePath(raw)
|
||||
if (!isInsideWorkspace(abs, dir)) continue
|
||||
const url = new URL("file://")
|
||||
url.pathname = abs.startsWith("/") ? abs : `/${abs}`
|
||||
result.push({ mime: "text/plain", url: url.href })
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isCursorAtMentionEnd,
|
||||
getMentionRemovalRange,
|
||||
findMentionRange,
|
||||
FILE_PICKER_RESULT,
|
||||
type MentionResult,
|
||||
} from "./file-mention-utils"
|
||||
|
||||
@@ -65,6 +66,8 @@ export interface FileMention {
|
||||
snapSelection: (textarea: HTMLTextAreaElement) => void
|
||||
/** Seed known paths from existing text (e.g. after undo restores a draft). */
|
||||
seedFromText: (text: string) => void
|
||||
/** Insert a file-picker result at the stored cursor position. Ignored unless requestId matches the pending request. */
|
||||
insertFilePickerResult: (path: string, requestId: string) => void
|
||||
}
|
||||
|
||||
export function useFileMention(
|
||||
@@ -83,6 +86,15 @@ export function useFileMention(
|
||||
|
||||
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let fileSearchCounter = 0
|
||||
let filePickerCounter = 0
|
||||
let pickerState: {
|
||||
requestId: string
|
||||
textarea: HTMLTextAreaElement
|
||||
atStart: number
|
||||
atEnd: number
|
||||
setText: (text: string) => void
|
||||
onSelect?: () => void
|
||||
} | null = null
|
||||
let pendingArrowSnap: { timer: ReturnType<typeof setTimeout>; prevValue: string; prevPosition: number } | undefined
|
||||
|
||||
const showMention = () => mentionQuery() !== null
|
||||
@@ -141,6 +153,18 @@ export function useFileMention(
|
||||
const before = val.substring(0, cursor)
|
||||
const after = val.substring(cursor)
|
||||
|
||||
if (result.type === "file-picker") {
|
||||
const match = before.match(AT_PATTERN)!
|
||||
const prefix = /^\s/.test(match[0]) ? 1 : 0
|
||||
const atPos = match.index! + prefix
|
||||
filePickerCounter++
|
||||
const requestId = `file-picker-${filePickerCounter}`
|
||||
pickerState = { requestId, textarea, atStart: atPos, atEnd: cursor, setText: _setText, onSelect }
|
||||
closeMention()
|
||||
vscode.postMessage({ type: "requestFilePicker", requestId })
|
||||
return
|
||||
}
|
||||
|
||||
// Add to knownPaths BEFORE execCommand so syncMentionedPaths (triggered
|
||||
// by the input event) can discover the new path.
|
||||
if (result.type === "file" || result.type === "folder" || result.type === "opened-file")
|
||||
@@ -357,7 +381,10 @@ export function useFileMention(
|
||||
}
|
||||
|
||||
const seedFromText = (text: string) => {
|
||||
const re = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g
|
||||
// The optional drive-letter prefix is scoped to a single letter directly after
|
||||
// @ (e.g. "C:") so a colon elsewhere in the match (as in "@https://example.com")
|
||||
// doesn't get mistaken for a Windows path.
|
||||
const re = /@((?:[A-Za-z]:)?(?:[\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+))/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text))) {
|
||||
knownPaths.add(m[1])
|
||||
@@ -365,6 +392,44 @@ export function useFileMention(
|
||||
syncMentionedPaths(text)
|
||||
}
|
||||
|
||||
const insertFilePickerResult = (path: string, requestId: string) => {
|
||||
const state = pickerState
|
||||
if (!state || state.requestId !== requestId) return
|
||||
if (!path) {
|
||||
pickerState = null
|
||||
return
|
||||
}
|
||||
const norm = path.replaceAll("\\", "/")
|
||||
pickerState = null
|
||||
const textarea = state.textarea
|
||||
if (!textarea.isConnected) return
|
||||
const after = textarea.value.substring(state.atEnd)
|
||||
const suffix = /^\s/.test(after) ? "" : " "
|
||||
// Insert as a styled @mention so it renders like any other file reference and
|
||||
// is clickable to preview (openFile is a plain editor action on the user's own
|
||||
// disk, unrelated to the AI permission system). The actual security boundary
|
||||
// lives in buildFileAttachments: paths outside the workspace are never turned
|
||||
// into an auto-read FileAttachment, regardless of how they were mentioned, so
|
||||
// a prior "deny" decision can't be bypassed by picking/attaching this way. If
|
||||
// the model wants the file's contents it must call the Read tool, which
|
||||
// enforces the normal external-directory permission checks.
|
||||
// Restore focus before execCommand: after the native dialog closes the textarea
|
||||
// is no longer the active element, so execCommand would otherwise silently no-op.
|
||||
textarea.focus()
|
||||
suppress = true
|
||||
try {
|
||||
textarea.setSelectionRange(state.atStart, state.atEnd)
|
||||
document.execCommand("insertText", false, `@${norm}${suffix}`)
|
||||
} finally {
|
||||
suppress = false
|
||||
}
|
||||
knownPaths.add(norm)
|
||||
setMentionedPaths((prev) => new Set([...prev, norm]))
|
||||
syncMentionedPaths(textarea.value)
|
||||
state.setText(textarea.value)
|
||||
state.onSelect?.()
|
||||
}
|
||||
|
||||
return {
|
||||
mentionedPaths,
|
||||
mentionResults,
|
||||
@@ -381,5 +446,6 @@ export function useFileMention(
|
||||
handleArrowKey,
|
||||
snapSelection,
|
||||
seedFromText,
|
||||
insertFilePickerResult,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-mention-separator {
|
||||
height: 1px;
|
||||
margin: 4px 10px;
|
||||
background: var(--vscode-editorWidget-border, var(--vscode-input-border));
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Slash Command Dropdown
|
||||
============================================ */
|
||||
|
||||
@@ -437,6 +437,12 @@ export interface FileSearchResultMessage {
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface FilePickerResultMessage {
|
||||
type: "filePickerResult"
|
||||
path: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface TerminalContextResultMessage {
|
||||
type: "terminalContextResult"
|
||||
requestId: string
|
||||
@@ -1117,6 +1123,7 @@ export type ExtensionMessage =
|
||||
| SpeechToTextResultMessage
|
||||
| SpeechToTextErrorMessage
|
||||
| FileSearchResultMessage
|
||||
| FilePickerResultMessage
|
||||
| TerminalContextResultMessage
|
||||
| TerminalContextErrorMessage
|
||||
| GitChangesContextResultMessage
|
||||
|
||||
@@ -401,6 +401,11 @@ export interface RequestFileSearchMessage {
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
export interface RequestFilePickerMessage {
|
||||
type: "requestFilePicker"
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface RequestTerminalContextMessage {
|
||||
type: "requestTerminalContext"
|
||||
requestId: string
|
||||
@@ -1250,6 +1255,7 @@ export type WebviewMessage =
|
||||
| SpeechToTextStopMessage
|
||||
| SpeechToTextCancelMessage
|
||||
| RequestFileSearchMessage
|
||||
| RequestFilePickerMessage
|
||||
| RequestTerminalContextMessage
|
||||
| RequestGitChangesContextMessage
|
||||
| ChatCompletionAcceptedMessage
|
||||
|
||||
Reference in New Issue
Block a user