From a691a37d57b7aaf82e0767f102b3777271c488f5 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 14:00:28 +0800 Subject: [PATCH 01/15] @ quick pick file autocomplete --- .../vscode/src/utils/at-sign-quick-pick.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index 897244f39..ba7e4fdaa 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -10,6 +10,10 @@ export async function at_sign_quick_pick( is_code_completions_mode = false ): Promise { let items = [ + { + label: '@File', + description: 'Reference a file' + }, { label: '@Selection', description: 'Text selection of the active editor' @@ -39,6 +43,42 @@ export async function at_sign_quick_pick( return } + if (selected.label == '@File') { + const workspace_folders = vscode.workspace.workspaceFolders + if (!workspace_folders || workspace_folders.length == 0) { + vscode.window.showErrorMessage('No workspace folders found') + return + } + const workspace_root = workspace_folders[0].uri.fsPath + + // Find all files in the workspace, respecting .gitignore and other exclude settings + const files = await vscode.workspace.findFiles('**/*') + + if (files.length == 0) { + vscode.window.showInformationMessage('No files found in the workspace.') + return + } + + const file_items = files.map((file_uri) => { + const relative_path = path.relative(workspace_root, file_uri.fsPath) + return { + label: path.basename(file_uri.fsPath), + description: path.dirname(relative_path), + // Store the relative path for easy access upon selection + path: relative_path + } + }) + + const selected_file = await vscode.window.showQuickPick(file_items, { + placeHolder: 'Search for a file to reference', + matchOnDescription: true + }) + + if (selected_file) { + return `\`${selected_file.path}\` ` + } + } + if (selected.label == '@Selection') { return 'Selection ' } From 7a7f59348c20061b59179d702bce2317c4226338 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 14:25:20 +0800 Subject: [PATCH 02/15] fully utilize the @File symbol --- packages/vscode/src/utils/at-sign-quick-pick.ts | 2 +- .../extract-file-paths-from-instruction.spec.ts | 15 +++++++++++++++ .../utils/extract-file-paths-from-instruction.ts | 12 ++++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/vscode/src/utils/extract-file-paths-from-instruction.spec.ts diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index ba7e4fdaa..613cce38a 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -75,7 +75,7 @@ export async function at_sign_quick_pick( }) if (selected_file) { - return `\`${selected_file.path}\` ` + return `File:${selected_file.path} ` } } diff --git a/packages/vscode/src/utils/extract-file-paths-from-instruction.spec.ts b/packages/vscode/src/utils/extract-file-paths-from-instruction.spec.ts new file mode 100644 index 000000000..5f46d6f09 --- /dev/null +++ b/packages/vscode/src/utils/extract-file-paths-from-instruction.spec.ts @@ -0,0 +1,15 @@ +import { extract_file_paths_from_instruction } from './extract-file-paths-from-instruction' + +describe('extract_file_paths_from_instruction', () => { + it('should extract path from @File: keyword', () => { + expect(extract_file_paths_from_instruction('@File:path/to/file.ts')).toEqual( + ['path/to/file.ts'] + ) + }) + + it('should extract path from backtick', () => { + expect(extract_file_paths_from_instruction('`path/to/file.ts`')).toEqual( + ['path/to/file.ts'] + ) + }) +}) \ No newline at end of file diff --git a/packages/vscode/src/utils/extract-file-paths-from-instruction.ts b/packages/vscode/src/utils/extract-file-paths-from-instruction.ts index 15a9687da..66bcfd36b 100644 --- a/packages/vscode/src/utils/extract-file-paths-from-instruction.ts +++ b/packages/vscode/src/utils/extract-file-paths-from-instruction.ts @@ -1,8 +1,16 @@ export const extract_file_paths_from_instruction = ( instruction: string ): string[] => { - const matches = instruction.match(/`([^`]+)`/g) + const regex = /(@File:[^\s]+|`[^`]+`)/g + + const matches = instruction.match(regex) if (!matches) return [] - return matches.map((match) => match.slice(1, -1)) // Remove backticks + return matches.map((part) => { + if (part && /^@File:[^\s]+$/.test(part)) { + return part.slice(6) + } + + return part.slice(1, -1) + }) } From 614f9c538cecedab6622d117313464b5035d0e25 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 15:16:16 +0800 Subject: [PATCH 03/15] highlight the @File: in chat input --- .../ui/src/components/editor/ChatInput/ChatInput.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index 9d5be568f..65080b9d3 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -87,9 +87,16 @@ export const ChatInput: React.FC = (props) => { } const regex = - /(@Selection|@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?|@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"|`[^`]+`)/g + /(@File:[^\s]+|@Selection|@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?|@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"|`[^`]+`)/g const parts = text.split(regex) return parts.map((part, index) => { + if (part && /^@File:[^\s]+$/.test(part)) { + return ( + + {part} + + ) + } if (part == '@Selection') { return ( Date: Sun, 3 Aug 2025 15:49:56 +0800 Subject: [PATCH 04/15] replace @file in the instructions --- .../backend/message-handlers/handle-send-prompt.ts | 5 +++++ .../view/backend/utils/replace-file-placeholder.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/vscode/src/view/backend/utils/replace-file-placeholder.ts diff --git a/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts b/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts index ed2fba319..55a339a9d 100644 --- a/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts +++ b/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts @@ -14,6 +14,7 @@ import { chat_code_completion_instructions } from '@/constants/instructions' import { ConfigPresetFormat } from '../utils/preset-format-converters' import { extract_file_paths_from_instruction } from '@/utils/extract-file-paths-from-instruction' import { CHATBOTS } from '@shared/constants/chatbots' +import { replace_file_placeholder } from '../utils/replace-file-placeholder' /** * When preset_names is an emtpy stirng - show quick pick, @@ -121,6 +122,10 @@ export const handle_send_prompt = async (params: { params.provider.get_presets_config_key() ) + if (instructions.includes('@File:')) { + instructions = replace_file_placeholder(instructions) + } + if (editor && !editor.selection.isEmpty) { if (instructions.includes('@Selection')) { instructions = replace_selection_placeholder(instructions) diff --git a/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts b/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts new file mode 100644 index 000000000..35c1d481e --- /dev/null +++ b/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts @@ -0,0 +1,14 @@ +export const replace_file_placeholder = (instruction: string): string => { + if (!instruction.includes('@File:')) { + return instruction + } + + const regex = /(@File:[^\s]+)/g + const parts = instruction.split(regex) + return parts.map((part) => { + if (part && /^@File:[^\s]+$/.test(part)) { + return `\`${part.slice(6)}\`` + } + return part + }).join('') +} From b40697d7fa77c512a7de23a15f8144489232ed1d Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 16:37:10 +0800 Subject: [PATCH 05/15] show only opened files then search --- .../vscode/src/utils/at-sign-quick-pick.ts | 82 ++++++++++++++----- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index 613cce38a..939d35e47 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -42,7 +42,6 @@ export async function at_sign_quick_pick( if (!selected) { return } - if (selected.label == '@File') { const workspace_folders = vscode.workspace.workspaceFolders if (!workspace_folders || workspace_folders.length == 0) { @@ -51,32 +50,71 @@ export async function at_sign_quick_pick( } const workspace_root = workspace_folders[0].uri.fsPath - // Find all files in the workspace, respecting .gitignore and other exclude settings - const files = await vscode.workspace.findFiles('**/*') + return new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string }>(); + quickPick.placeholder = 'Search for a file by name'; - if (files.length == 0) { - vscode.window.showInformationMessage('No files found in the workspace.') - return - } + let debounceTimeout: NodeJS.Timeout; - const file_items = files.map((file_uri) => { - const relative_path = path.relative(workspace_root, file_uri.fsPath) - return { - label: path.basename(file_uri.fsPath), - description: path.dirname(relative_path), - // Store the relative path for easy access upon selection - path: relative_path + // Helper function to create a QuickPickItem for a file + function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string } { + const relativePath = path.relative(workspaceRoot, fileUri.fsPath); + return { + label: path.basename(fileUri.fsPath), + description: path.dirname(relativePath), + path: relativePath, + }; } - }) - const selected_file = await vscode.window.showQuickPick(file_items, { - placeHolder: 'Search for a file to reference', - matchOnDescription: true - }) + // Get recently opened files from active tabs + const recentFiles = vscode.window.tabGroups.all + .flatMap(group => group.tabs) + .filter(tab => tab.input instanceof vscode.TabInputText) + .map(tab => createFileQuickPickItem((tab.input as vscode.TabInputText).uri, workspace_root)); + + // Initially, show only recent files + quickPick.items = recentFiles; + + quickPick.onDidChangeValue(value => { + clearTimeout(debounceTimeout); + + // When user clears the input, show recent files again + if (!value || value.length < 1) { + quickPick.busy = false; + quickPick.items = recentFiles; + return; + } - if (selected_file) { - return `File:${selected_file.path} ` - } + quickPick.busy = true; // Show loading indicator + + // Debounce the search to avoid excessive API calls + debounceTimeout = setTimeout(async () => { + const query = `**/*${value}*`; + const searchResults = await vscode.workspace.findFiles(query, undefined, 100); + + if (searchResults) { + quickPick.items = searchResults.map(uri => createFileQuickPickItem(uri, workspace_root)); + } + quickPick.busy = false; + }, 300); // 300ms debounce delay + }); + + quickPick.onDidAccept(() => { + const selectedFile = quickPick.selectedItems[0]; + if (selectedFile) { + resolve(`File:${selectedFile.path} `); + } + quickPick.hide(); + }); + + quickPick.onDidHide(() => { + clearTimeout(debounceTimeout); + quickPick.dispose(); + resolve(undefined); // Resolve with undefined if the user dismisses the picker + }); + + quickPick.show(); + }); } if (selected.label == '@Selection') { From 3061edcd42a344223d53fdd18a6e639598043183 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 17:50:52 +0800 Subject: [PATCH 06/15] track the recently opened and @Files --- packages/vscode/src/constants/state-keys.ts | 1 + packages/vscode/src/extension.ts | 4 ++ .../src/services/recent-files-manager.ts | 49 +++++++++++++++++++ .../vscode/src/utils/at-sign-quick-pick.ts | 15 +++--- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 packages/vscode/src/services/recent-files-manager.ts diff --git a/packages/vscode/src/constants/state-keys.ts b/packages/vscode/src/constants/state-keys.ts index 2f077e72b..0197cae38 100644 --- a/packages/vscode/src/constants/state-keys.ts +++ b/packages/vscode/src/constants/state-keys.ts @@ -46,6 +46,7 @@ export const PINNED_HISTORY_CODE_COMPLETIONS_STATE_KEY = 'pinned-history-code-completions' export const PINNED_HISTORY_NO_CONTEXT_STATE_KEY = 'pinned-history-no-context' +export const RECENT_FILES_STORAGE_KEY = 'recent-files' export interface HistoryEntry { text: string createdAt: number diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 8e7508957..f00263a97 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -35,6 +35,7 @@ import { feedback_command, apply_context_from_clipboard_command, } from './commands' +import { RecentFileManager } from './services/recent-files-manager' // Store WebSocketServer instance at module level let websocket_server_instance: WebSocketManager | null = null @@ -96,7 +97,10 @@ export async function activate(context: vscode.ExtensionContext) { ) } + const recentFileManager = new RecentFileManager(context); + context.subscriptions.push( + recentFileManager.setupListener(), open_file_from_workspace_command(open_editors_provider), apply_chat_response_command(context), ...code_completion_commands( diff --git a/packages/vscode/src/services/recent-files-manager.ts b/packages/vscode/src/services/recent-files-manager.ts new file mode 100644 index 000000000..a20a5a622 --- /dev/null +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode' +import * as fs from 'fs' +import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys' + +const MAX_RECENT_FILES = 50 + +export class RecentFileManager { + private context: vscode.ExtensionContext + + constructor(context: vscode.ExtensionContext) { + this.context = context + } + + public setupListener(): vscode.Disposable { + return vscode.workspace.onDidOpenTextDocument((document) => { + if (document.uri.scheme === 'file') { + this.addFile(document.uri) + } + }) + } + + public getRecentFiles(): string[] { + return this.context.workspaceState.get( + RECENT_FILES_STORAGE_KEY, + [] + ) + } + + public addFile(uri: vscode.Uri): void { + const filePath = uri.fsPath + let recentFiles = this.getRecentFiles().filter((p) => p !== filePath) + recentFiles.unshift(filePath) + + if (recentFiles.length > MAX_RECENT_FILES) { + recentFiles = recentFiles.slice(0, MAX_RECENT_FILES) + } + this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles) + } + + public getRecentFileUris(): vscode.Uri[] { + const paths = this.context.workspaceState.get( + RECENT_FILES_STORAGE_KEY, + [] + ) + return paths + .map((p) => vscode.Uri.file(p)) + .filter((uri) => fs.existsSync(uri.fsPath)) + } +} diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index 939d35e47..fea90be18 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -4,6 +4,7 @@ import * as path from 'path' import * as fs from 'fs' import { SAVED_CONTEXTS_STATE_KEY } from '../constants/state-keys' import { SavedContext } from '../types/context' +import { RecentFileManager } from '@/services/recent-files-manager' export async function at_sign_quick_pick( context: vscode.ExtensionContext, @@ -51,26 +52,24 @@ export async function at_sign_quick_pick( const workspace_root = workspace_folders[0].uri.fsPath return new Promise((resolve) => { - const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string }>(); + const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string; uri: vscode.Uri }>(); quickPick.placeholder = 'Search for a file by name'; let debounceTimeout: NodeJS.Timeout; // Helper function to create a QuickPickItem for a file - function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string } { + function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { const relativePath = path.relative(workspaceRoot, fileUri.fsPath); return { label: path.basename(fileUri.fsPath), description: path.dirname(relativePath), path: relativePath, + uri: fileUri }; } - // Get recently opened files from active tabs - const recentFiles = vscode.window.tabGroups.all - .flatMap(group => group.tabs) - .filter(tab => tab.input instanceof vscode.TabInputText) - .map(tab => createFileQuickPickItem((tab.input as vscode.TabInputText).uri, workspace_root)); + const recentFileManager = new RecentFileManager(context); + const recentFiles = recentFileManager.getRecentFileUris().map(uri => createFileQuickPickItem(uri, workspace_root)); // Initially, show only recent files quickPick.items = recentFiles; @@ -103,7 +102,9 @@ export async function at_sign_quick_pick( const selectedFile = quickPick.selectedItems[0]; if (selectedFile) { resolve(`File:${selectedFile.path} `); + recentFileManager.addFile(selectedFile.uri) } + quickPick.hide(); }); From 3dd86e023614bb4a2f979d17a19c8a6b78e4610b Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 18:28:24 +0800 Subject: [PATCH 07/15] re-focus the chat input whenever quick pick is closed without a selection --- packages/ui/src/components/editor/ChatInput/ChatInput.tsx | 7 +++++++ .../backend/message-handlers/handle-at-sign-quick-pick.ts | 3 +++ packages/vscode/src/view/frontend/home/Home.tsx | 5 +++++ .../vscode/src/view/frontend/home/HomeView/HomeView.tsx | 2 ++ packages/vscode/src/view/types/messages.ts | 5 +++++ 5 files changed, 22 insertions(+) diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index 65080b9d3..566d63d35 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -41,6 +41,7 @@ type Props = { } caret_position_to_set?: number on_caret_position_set?: () => void + is_focused?: Date } const format_token_count = (count?: number) => { @@ -81,6 +82,12 @@ export const ChatInput: React.FC = (props) => { } }, [props.value]) + useEffect(() => { + if (props.is_focused) { + textarea_ref.current?.focus() + } + }, [props.is_focused]) + const get_highlighted_text = (text: string) => { if (props.is_in_code_completions_mode) { return {text} diff --git a/packages/vscode/src/view/backend/message-handlers/handle-at-sign-quick-pick.ts b/packages/vscode/src/view/backend/message-handlers/handle-at-sign-quick-pick.ts index f8de88100..2e2bb1793 100644 --- a/packages/vscode/src/view/backend/message-handlers/handle-at-sign-quick-pick.ts +++ b/packages/vscode/src/view/backend/message-handlers/handle-at-sign-quick-pick.ts @@ -19,6 +19,9 @@ export const handle_at_sign_quick_pick = async ( ) if (!replacement) { + provider.send_message({ + command: 'FOCUS_CHAT_INPUT' + }) return } diff --git a/packages/vscode/src/view/frontend/home/Home.tsx b/packages/vscode/src/view/frontend/home/Home.tsx index 4e56c830f..f72a266ea 100644 --- a/packages/vscode/src/view/frontend/home/Home.tsx +++ b/packages/vscode/src/view/frontend/home/Home.tsx @@ -55,6 +55,7 @@ export const Home: React.FC = (props) => { const [caret_position_to_set, set_caret_position_to_set] = useState< number | undefined >() + const [is_focused, set_is_focused] = useState(undefined) const is_in_code_completions_mode = (props.home_view_type == HOME_VIEW_TYPES.WEB && @@ -108,6 +109,9 @@ export const Home: React.FC = (props) => { set_chat_edit_format(message.chat_edit_format) set_api_edit_format(message.api_edit_format) break + case 'FOCUS_CHAT_INPUT': + set_is_focused(new Date()) + break } } @@ -516,6 +520,7 @@ export const Home: React.FC = (props) => { } caret_position_to_set={caret_position_to_set} on_caret_position_set={() => set_caret_position_to_set(undefined)} + is_focused={is_focused} /> ) } diff --git a/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx b/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx index 326047d80..f13533444 100644 --- a/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx +++ b/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx @@ -62,6 +62,7 @@ type Props = { on_code_completion_with_quick_pick_click: () => void caret_position_to_set?: number on_caret_position_set?: () => void + is_focused?: Date } const web_mode_labels: Record = { @@ -289,6 +290,7 @@ export const HomeView: React.FC = (props) => { }} caret_position_to_set={props.caret_position_to_set} on_caret_position_set={props.on_caret_position_set} + is_focused={props.is_focused} /> diff --git a/packages/vscode/src/view/types/messages.ts b/packages/vscode/src/view/types/messages.ts index 799583ff9..ef0f3430e 100644 --- a/packages/vscode/src/view/types/messages.ts +++ b/packages/vscode/src/view/types/messages.ts @@ -330,6 +330,10 @@ export interface AtSignQuickPickForPresetAffixResultMessage text_to_insert: string } +export interface FocusChatInputMessage extends BaseMessage { + command: 'FOCUS_CHAT_INPUT' +} + export type BackendMessage = | InstructionsMessage | ConnectionStatusMessage @@ -350,3 +354,4 @@ export type BackendMessage = | ApiModeMessage | VersionMessage | AtSignQuickPickForPresetAffixResultMessage + | FocusChatInputMessage From 8f66e877c5a510a5941add731c8393eadecfc028 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 19:19:31 +0800 Subject: [PATCH 08/15] intercept backspace key press to remove the entire @File symbol --- .../components/editor/ChatInput/ChatInput.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index 566d63d35..7a3d88ecf 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -257,6 +257,32 @@ export const ChatInput: React.FC = (props) => { props.on_change('') } } + } else if (e.key == 'Backspace') { + const textarea = e.currentTarget + const start = textarea.selectionStart + const end = textarea.selectionEnd + + // a text is highlighted + if (start != end) { + return + } + + const textBeforeCursor = textarea.value.slice(0, start) + const fileSymbolRegex = /(@File:[^\s]+)/ + const fileSymbolMatch = textBeforeCursor.match(fileSymbolRegex) + + if (fileSymbolMatch) { + e.preventDefault() + + const symbolToRemove = fileSymbolMatch[0] + const startOfSymbol = start - symbolToRemove.length - 1 + const textAfterCursor = textarea.value.slice(start) + + props.on_change( + textBeforeCursor.slice(0, startOfSymbol) + textAfterCursor + ) + props.on_caret_position_change(startOfSymbol) + } } else if (props.value) { set_is_history_enabled(false) } From ca8f2ed58eb56c84644e30a75e3cc754cf25e2fb Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 19:46:01 +0800 Subject: [PATCH 09/15] go back to the main quick pick symbol if the file picker is dismissed --- .../src/services/recent-files-manager.ts | 84 +++++ .../vscode/src/utils/at-sign-quick-pick.ts | 72 +--- .../src/utils/recent-file-quick-pick.ts | 309 ++++++++++++++++++ 3 files changed, 400 insertions(+), 65 deletions(-) create mode 100644 packages/vscode/src/utils/recent-file-quick-pick.ts diff --git a/packages/vscode/src/services/recent-files-manager.ts b/packages/vscode/src/services/recent-files-manager.ts index a20a5a622..d940f7f16 100644 --- a/packages/vscode/src/services/recent-files-manager.ts +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode' import * as fs from 'fs' +import * as path from 'path' import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys' const MAX_RECENT_FILES = 50 @@ -46,4 +47,87 @@ export class RecentFileManager { .map((p) => vscode.Uri.file(p)) .filter((uri) => fs.existsSync(uri.fsPath)) } + + public async showFilePicker(workspace_root: string) { + return new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick<{ + label: string + description?: string + path: string + uri: vscode.Uri + }>() + quickPick.placeholder = 'Search for a file by name' + + let debounceTimeout: NodeJS.Timeout + + // Helper function to create a QuickPickItem for a file + function createFileQuickPickItem( + fileUri: vscode.Uri, + workspaceRoot: string + ): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { + const relativePath = path.relative(workspaceRoot, fileUri.fsPath) + return { + label: path.basename(fileUri.fsPath), + description: path.dirname(relativePath), + path: relativePath, + uri: fileUri + } + } + + const recentFiles = this.getRecentFileUris().map((uri) => + createFileQuickPickItem(uri, workspace_root) + ) + + // Initially, show only recent files + quickPick.items = recentFiles + + quickPick.onDidChangeValue((value) => { + clearTimeout(debounceTimeout) + + // When user clears the input, show recent files again + if (!value || value.length < 1) { + quickPick.busy = false + quickPick.items = recentFiles + return + } + + quickPick.busy = true // Show loading indicator + + // Debounce the search to avoid excessive API calls + debounceTimeout = setTimeout(async () => { + const query = `**/*${value}*` + const searchResults = await vscode.workspace.findFiles( + query, + undefined, + 100 + ) + + if (searchResults) { + quickPick.items = searchResults.map((uri) => + createFileQuickPickItem(uri, workspace_root) + ) + } + quickPick.busy = false + }, 300) // 300ms debounce delay + }) + + quickPick.onDidAccept(() => { + const selectedFile = quickPick.selectedItems[0] + if (selectedFile) { + resolve(selectedFile.path) + this.addFile(selectedFile.uri) + } + + quickPick.hide() + }) + + quickPick.onDidHide(() => { + clearTimeout(debounceTimeout) + quickPick.dispose() + resolve(undefined) // Resolve with undefined if the user dismisses the picker + }) + + quickPick.show() + }) + } } diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index fea90be18..6913b209a 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -50,72 +50,14 @@ export async function at_sign_quick_pick( return } const workspace_root = workspace_folders[0].uri.fsPath + const recentFileManager = new RecentFileManager(context) + const selectedFile = await recentFileManager.showFilePicker(workspace_root) - return new Promise((resolve) => { - const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string; uri: vscode.Uri }>(); - quickPick.placeholder = 'Search for a file by name'; - - let debounceTimeout: NodeJS.Timeout; - - // Helper function to create a QuickPickItem for a file - function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { - const relativePath = path.relative(workspaceRoot, fileUri.fsPath); - return { - label: path.basename(fileUri.fsPath), - description: path.dirname(relativePath), - path: relativePath, - uri: fileUri - }; - } - - const recentFileManager = new RecentFileManager(context); - const recentFiles = recentFileManager.getRecentFileUris().map(uri => createFileQuickPickItem(uri, workspace_root)); - - // Initially, show only recent files - quickPick.items = recentFiles; - - quickPick.onDidChangeValue(value => { - clearTimeout(debounceTimeout); - - // When user clears the input, show recent files again - if (!value || value.length < 1) { - quickPick.busy = false; - quickPick.items = recentFiles; - return; - } - - quickPick.busy = true; // Show loading indicator - - // Debounce the search to avoid excessive API calls - debounceTimeout = setTimeout(async () => { - const query = `**/*${value}*`; - const searchResults = await vscode.workspace.findFiles(query, undefined, 100); - - if (searchResults) { - quickPick.items = searchResults.map(uri => createFileQuickPickItem(uri, workspace_root)); - } - quickPick.busy = false; - }, 300); // 300ms debounce delay - }); - - quickPick.onDidAccept(() => { - const selectedFile = quickPick.selectedItems[0]; - if (selectedFile) { - resolve(`File:${selectedFile.path} `); - recentFileManager.addFile(selectedFile.uri) - } - - quickPick.hide(); - }); - - quickPick.onDidHide(() => { - clearTimeout(debounceTimeout); - quickPick.dispose(); - resolve(undefined); // Resolve with undefined if the user dismisses the picker - }); - - quickPick.show(); - }); + if (selectedFile) { + return `File:${selectedFile} ` + } else { + return await at_sign_quick_pick(context) + } } if (selected.label == '@Selection') { diff --git a/packages/vscode/src/utils/recent-file-quick-pick.ts b/packages/vscode/src/utils/recent-file-quick-pick.ts new file mode 100644 index 000000000..9e9349f39 --- /dev/null +++ b/packages/vscode/src/utils/recent-file-quick-pick.ts @@ -0,0 +1,309 @@ +import * as vscode from 'vscode' +import { execSync } from 'child_process' +import * as path from 'path' +import * as fs from 'fs' +import { SAVED_CONTEXTS_STATE_KEY } from '../constants/state-keys' +import { SavedContext } from '../types/context' +import { RecentFileManager } from '@/services/recent-files-manager' + +export async function at_sign_quick_pick( + context: vscode.ExtensionContext, + is_code_completions_mode = false +): Promise { + let items = [ + { + label: '@File', + description: 'Reference a file' + }, + { + label: '@Selection', + description: 'Text selection of the active editor' + }, + { + label: '@Changes', + description: 'Diff between the current branch and the selected branch' + }, + { + label: '@SavedContext', + description: 'Files from a saved context' + } + ] + + if (is_code_completions_mode) { + items = items.filter( + (item) => item.label != '@Selection' && item.label != '@Changes' + ) + } + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select symbol to insert', + matchOnDescription: true + }) + + if (!selected) { + return + } + if (selected.label == '@File') { + const workspace_folders = vscode.workspace.workspaceFolders + if (!workspace_folders || workspace_folders.length == 0) { + vscode.window.showErrorMessage('No workspace folders found') + return + } + const workspace_root = workspace_folders[0].uri.fsPath + const recentFileManager = new RecentFileManager(context); + + async function showFilePicker() { + return new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string; uri: vscode.Uri }>(); + quickPick.placeholder = 'Search for a file by name'; + + let debounceTimeout: NodeJS.Timeout; + + // Helper function to create a QuickPickItem for a file + function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { + const relativePath = path.relative(workspaceRoot, fileUri.fsPath); + return { + label: path.basename(fileUri.fsPath), + description: path.dirname(relativePath), + path: relativePath, + uri: fileUri + }; + } + + const recentFiles = recentFileManager.getRecentFileUris().map(uri => createFileQuickPickItem(uri, workspace_root)); + + // Initially, show only recent files + quickPick.items = recentFiles; + + quickPick.onDidChangeValue(value => { + clearTimeout(debounceTimeout); + + // When user clears the input, show recent files again + if (!value || value.length < 1) { + quickPick.busy = false; + quickPick.items = recentFiles; + return; + } + + quickPick.busy = true; // Show loading indicator + + // Debounce the search to avoid excessive API calls + debounceTimeout = setTimeout(async () => { + const query = `**/*${value}*`; + const searchResults = await vscode.workspace.findFiles(query, undefined, 100); + + if (searchResults) { + quickPick.items = searchResults.map(uri => createFileQuickPickItem(uri, workspace_root)); + } + quickPick.busy = false; + }, 300); // 300ms debounce delay + }); + + quickPick.onDidAccept(() => { + const selectedFile = quickPick.selectedItems[0]; + if (selectedFile) { + resolve(`File:${selectedFile.path} `); + recentFileManager.addFile(selectedFile.uri) + } + + quickPick.hide(); + }); + + quickPick.onDidHide(() => { + clearTimeout(debounceTimeout); + quickPick.dispose(); + resolve(undefined); // Resolve with undefined if the user dismisses the picker + }); + + quickPick.show(); + }); + } + + const selectedFile = await showFilePicker() + + if (selectedFile) { + return selectedFile + } else { + return await at_sign_quick_pick(context) + } + } + + if (selected.label == '@Selection') { + return 'Selection ' + } + + if (selected.label == '@Changes') { + try { + const workspace_folders = vscode.workspace.workspaceFolders + if (!workspace_folders || workspace_folders.length == 0) { + vscode.window.showErrorMessage('No workspace folders found') + return + } + + const all_branches = new Set() + const workspace_with_branches: Array<{ + folder: vscode.WorkspaceFolder + branches: string[] + }> = [] + + for (const folder of workspace_folders) { + try { + const branches = execSync('git branch --sort=-committerdate', { + encoding: 'utf-8', + cwd: folder.uri.fsPath + }) + .split('\n') + .map((b) => b.trim().replace(/^\* /, '')) + .filter((b) => b.length > 0) + + if (branches.length > 0) { + workspace_with_branches.push({ folder, branches }) + branches.forEach((branch) => all_branches.add(branch)) + } + } catch (error) { + console.log(`Skipping ${folder.name}: not a Git repository`) + } + } + + if (all_branches.size == 0) { + vscode.window.showErrorMessage( + 'No Git branches found in any workspace folder' + ) + return + } + + const branch_items: vscode.QuickPickItem[] = [] + + if (workspace_with_branches.length === 1) { + const { branches } = workspace_with_branches[0] + branch_items.push( + ...branches.map((branch) => ({ + label: branch + })) + ) + } else { + // Multi-root workspace: include folder name with branch + for (const { folder, branches } of workspace_with_branches) { + branch_items.push( + ...branches.map((branch) => ({ + label: `${folder.name}/${branch}`, + description: folder.name + })) + ) + } + } + + const selected_branch = await vscode.window.showQuickPick(branch_items, { + placeHolder: 'Select branch to compare with' + }) + + if (selected_branch) { + // For single root workspace, keep existing format + if (workspace_with_branches.length === 1) { + return `Changes:${selected_branch.label} ` + } else { + // For multi-root workspace, return format: changes:[folder name]/[branch name] + return `Changes:${selected_branch.label} ` + } + } + } catch (error) { + vscode.window.showErrorMessage( + 'Failed to get Git branches. Make sure you are in a Git repository.' + ) + } + } + + if (selected.label == '@SavedContext') { + const workspace_root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspace_root) { + vscode.window.showErrorMessage('No workspace root found.') + return + } + + const internal_contexts: SavedContext[] = + context.workspaceState.get(SAVED_CONTEXTS_STATE_KEY, []) || [] + + const contexts_file_path = path.join( + workspace_root, + '.vscode', + 'contexts.json' + ) + let file_contexts: SavedContext[] = [] + if (fs.existsSync(contexts_file_path)) { + try { + const content = fs.readFileSync(contexts_file_path, 'utf8') + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) { + file_contexts = parsed.filter( + (item: any): item is SavedContext => + typeof item == 'object' && + item !== null && + typeof item.name == 'string' && + Array.isArray(item.paths) && + item.paths.every((p: any) => typeof p == 'string') + ) + } + } catch (e) { + /* ignore */ + } + } + + const source_options: (vscode.QuickPickItem & { + value: 'WorkspaceState' | 'JSON' + })[] = [] + if (internal_contexts.length > 0) { + source_options.push({ + label: 'Workspace State', + description: `${internal_contexts.length} context${ + internal_contexts.length === 1 ? '' : 's' + }`, + value: 'WorkspaceState' + }) + } + if (file_contexts.length > 0) { + source_options.push({ + label: 'JSON File (.vscode/contexts.json)', + description: `${file_contexts.length} context${ + file_contexts.length === 1 ? '' : 's' + }`, + value: 'JSON' + }) + } + + if (source_options.length === 0) { + vscode.window.showInformationMessage('No saved contexts found.') + return + } + + const source = + source_options.length > 1 + ? ( + await vscode.window.showQuickPick(source_options, { + placeHolder: 'Select context source' + }) + )?.value + : source_options[0].value + + if (!source) return + + const contexts_to_use = + source === 'WorkspaceState' ? internal_contexts : file_contexts + + const context_items = contexts_to_use.map((ctx) => ({ + label: ctx.name, + description: `${ctx.paths.length} path${ + ctx.paths.length === 1 ? '' : 's' + }` + })) + + const selected_context = await vscode.window.showQuickPick(context_items, { + placeHolder: 'Select a saved context' + }) + + if (selected_context) { + return `SavedContext:${source} "${selected_context.label}" ` + } + } + + return undefined +} From d3757f1a631feb035b2f9fb105530aa5bc6a4468 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 22:35:19 +0800 Subject: [PATCH 10/15] fix 8f66e87 removing incorrect parts --- packages/ui/src/components/editor/ChatInput/ChatInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index 7a3d88ecf..7813fbaaf 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -268,14 +268,14 @@ export const ChatInput: React.FC = (props) => { } const textBeforeCursor = textarea.value.slice(0, start) - const fileSymbolRegex = /(@File:[^\s]+)/ + const fileSymbolRegex = /@File:[^\s]+\s$/g const fileSymbolMatch = textBeforeCursor.match(fileSymbolRegex) if (fileSymbolMatch) { e.preventDefault() const symbolToRemove = fileSymbolMatch[0] - const startOfSymbol = start - symbolToRemove.length - 1 + const startOfSymbol = start - symbolToRemove.length const textAfterCursor = textarea.value.slice(start) props.on_change( From ef667c3751b4fefef88e8859d36d5678c41583d8 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 23:07:35 +0800 Subject: [PATCH 11/15] add a button to remove a file from the recent list --- .../src/services/recent-files-manager.ts | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/vscode/src/services/recent-files-manager.ts b/packages/vscode/src/services/recent-files-manager.ts index d940f7f16..e0b107145 100644 --- a/packages/vscode/src/services/recent-files-manager.ts +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -4,6 +4,7 @@ import * as path from 'path' import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys' const MAX_RECENT_FILES = 50 +const REMOVE_BUTTON_TOOLTIP = 'Remove from Recent Files' export class RecentFileManager { private context: vscode.ExtensionContext @@ -38,6 +39,13 @@ export class RecentFileManager { this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles) } + public removeFile(uri: vscode.Uri): void { + const filePath = uri.fsPath + let recentFiles = this.getRecentFiles().filter((p) => p !== filePath) + + this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles) + } + public getRecentFileUris(): vscode.Uri[] { const paths = this.context.workspaceState.get( RECENT_FILES_STORAGE_KEY, @@ -63,19 +71,30 @@ export class RecentFileManager { // Helper function to create a QuickPickItem for a file function createFileQuickPickItem( fileUri: vscode.Uri, - workspaceRoot: string + withRemoveButton: boolean = true ): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { - const relativePath = path.relative(workspaceRoot, fileUri.fsPath) + const relativePath = path.relative(workspace_root, fileUri.fsPath) + const removeButton: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon('close'), + tooltip: REMOVE_BUTTON_TOOLTIP + } + const buttons = [] + + if (withRemoveButton) { + buttons.push(removeButton) + } + return { label: path.basename(fileUri.fsPath), description: path.dirname(relativePath), path: relativePath, - uri: fileUri + uri: fileUri, + buttons } } const recentFiles = this.getRecentFileUris().map((uri) => - createFileQuickPickItem(uri, workspace_root) + createFileQuickPickItem(uri) ) // Initially, show only recent files @@ -104,13 +123,34 @@ export class RecentFileManager { if (searchResults) { quickPick.items = searchResults.map((uri) => - createFileQuickPickItem(uri, workspace_root) + createFileQuickPickItem( + uri, + this.getRecentFiles().includes(uri.fsPath) + ) ) } quickPick.busy = false }, 300) // 300ms debounce delay }) + quickPick.onDidTriggerItemButton((e) => { + const fileUriToRemove = e.item.uri + const currentListItems = quickPick.items.filter( + (item) => item.uri.fsPath !== fileUriToRemove.fsPath + ) + + this.removeFile(fileUriToRemove) + + const recentListItems = this.getRecentFiles() + + quickPick.items = currentListItems.map((item) => + createFileQuickPickItem( + item.uri, + recentListItems.includes(item.uri.fsPath) + ) + ) + }) + quickPick.onDidAccept(() => { const selectedFile = quickPick.selectedItems[0] if (selectedFile) { From d90c62041726b0d75eb4408b98a69c833024b1c0 Mon Sep 17 00:00:00 2001 From: kermage Date: Sun, 3 Aug 2025 23:29:44 +0800 Subject: [PATCH 12/15] correctly place caret after the @File removal --- .../ui/src/components/editor/ChatInput/ChatInput.tsx | 9 ++++++--- packages/vscode/src/view/frontend/home/Home.tsx | 9 ++++++++- .../vscode/src/view/frontend/home/HomeView/HomeView.tsx | 5 ++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index 7813fbaaf..aada4dc14 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -16,7 +16,10 @@ type Props = { is_in_code_completions_mode: boolean has_active_selection: boolean has_active_editor: boolean - on_caret_position_change: (caret_position: number) => void + on_caret_position_change: ( + caret_position: number, + from_selection: boolean + ) => void is_web_mode: boolean on_search_click: () => void on_at_sign_click: () => void @@ -147,7 +150,7 @@ export const ChatInput: React.FC = (props) => { const handle_select = (e: React.SyntheticEvent) => { const textarea = e.currentTarget const caret_position = textarea.selectionStart - props.on_caret_position_change(caret_position) + props.on_caret_position_change(caret_position, true) } const handle_input_change = (e: React.ChangeEvent) => { @@ -281,7 +284,7 @@ export const ChatInput: React.FC = (props) => { props.on_change( textBeforeCursor.slice(0, startOfSymbol) + textAfterCursor ) - props.on_caret_position_change(startOfSymbol) + props.on_caret_position_change(startOfSymbol, false) } } else if (props.value) { set_is_history_enabled(false) diff --git a/packages/vscode/src/view/frontend/home/Home.tsx b/packages/vscode/src/view/frontend/home/Home.tsx index f72a266ea..cc5cc13c9 100644 --- a/packages/vscode/src/view/frontend/home/Home.tsx +++ b/packages/vscode/src/view/frontend/home/Home.tsx @@ -296,7 +296,14 @@ export const Home: React.FC = (props) => { }) } - const handle_caret_position_change = (caret_position: number) => { + const handle_caret_position_change = ( + caret_position: number, + from_selection: boolean + ) => { + if (!from_selection) { + set_caret_position_to_set(caret_position) + } + post_message(props.vscode, { command: 'CARET_POSITION_CHANGED', caret_position diff --git a/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx b/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx index f13533444..d9e4e5fc1 100644 --- a/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx +++ b/packages/vscode/src/view/frontend/home/HomeView/HomeView.tsx @@ -53,7 +53,10 @@ type Props = { on_toggle_default_preset: (name: string) => void instructions: string set_instructions: (value: string) => void - on_caret_position_change: (caret_position: number) => void + on_caret_position_change: ( + caret_position: number, + from_selection: boolean + ) => void home_view_type: HomeViewType on_home_view_type_change: (value: HomeViewType) => void on_edit_context_click: () => void From 2f602d3250f9ef68e00346ee3f911b8efd413e0d Mon Sep 17 00:00:00 2001 From: kermage Date: Mon, 4 Aug 2025 00:18:56 +0800 Subject: [PATCH 13/15] only remove item from picker if currently searching --- .../src/services/recent-files-manager.ts | 116 ++++--- .../src/utils/recent-file-quick-pick.ts | 309 ------------------ 2 files changed, 56 insertions(+), 369 deletions(-) delete mode 100644 packages/vscode/src/utils/recent-file-quick-pick.ts diff --git a/packages/vscode/src/services/recent-files-manager.ts b/packages/vscode/src/services/recent-files-manager.ts index e0b107145..ce986f279 100644 --- a/packages/vscode/src/services/recent-files-manager.ts +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -4,8 +4,43 @@ import * as path from 'path' import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys' const MAX_RECENT_FILES = 50 +const DEBOUNCE_DELAY = 300 const REMOVE_BUTTON_TOOLTIP = 'Remove from Recent Files' +type QuickPickItemFile = vscode.QuickPickItem & { + path: string + uri: vscode.Uri +} + +// Helper function to create a QuickPickItem for a file +function createFileQuickPickItem( + fileUri: vscode.Uri, + workspaceRoot: string, + withRemoveButton: boolean = true +): QuickPickItemFile { + const relativePath = path.relative(workspaceRoot, fileUri.fsPath) + const buttons: vscode.QuickInputButton[] = [] + + if (withRemoveButton) { + buttons.push({ + iconPath: new vscode.ThemeIcon('close'), + tooltip: REMOVE_BUTTON_TOOLTIP + }) + } + + return { + label: path.basename(fileUri.fsPath), + description: path.dirname(relativePath), + path: relativePath, + uri: fileUri, + buttons + } +} + +function isSearching(value: string): boolean { + return value.length > 0 +} + export class RecentFileManager { private context: vscode.ExtensionContext @@ -46,56 +81,16 @@ export class RecentFileManager { this.context.workspaceState.update(RECENT_FILES_STORAGE_KEY, recentFiles) } - public getRecentFileUris(): vscode.Uri[] { - const paths = this.context.workspaceState.get( - RECENT_FILES_STORAGE_KEY, - [] - ) - return paths - .map((p) => vscode.Uri.file(p)) - .filter((uri) => fs.existsSync(uri.fsPath)) - } - public async showFilePicker(workspace_root: string) { return new Promise((resolve) => { - const quickPick = vscode.window.createQuickPick<{ - label: string - description?: string - path: string - uri: vscode.Uri - }>() + const quickPick = vscode.window.createQuickPick() quickPick.placeholder = 'Search for a file by name' let debounceTimeout: NodeJS.Timeout - // Helper function to create a QuickPickItem for a file - function createFileQuickPickItem( - fileUri: vscode.Uri, - withRemoveButton: boolean = true - ): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { - const relativePath = path.relative(workspace_root, fileUri.fsPath) - const removeButton: vscode.QuickInputButton = { - iconPath: new vscode.ThemeIcon('close'), - tooltip: REMOVE_BUTTON_TOOLTIP - } - const buttons = [] - - if (withRemoveButton) { - buttons.push(removeButton) - } - - return { - label: path.basename(fileUri.fsPath), - description: path.dirname(relativePath), - path: relativePath, - uri: fileUri, - buttons - } - } - - const recentFiles = this.getRecentFileUris().map((uri) => - createFileQuickPickItem(uri) - ) + const recentFiles = this.getRecentFiles() + .map((p) => createFileQuickPickItem(vscode.Uri.file(p), workspace_root)) + .filter((item) => fs.existsSync(item.uri.fsPath)) // Initially, show only recent files quickPick.items = recentFiles @@ -104,7 +99,7 @@ export class RecentFileManager { clearTimeout(debounceTimeout) // When user clears the input, show recent files again - if (!value || value.length < 1) { + if (!isSearching(value)) { quickPick.busy = false quickPick.items = recentFiles return @@ -125,30 +120,31 @@ export class RecentFileManager { quickPick.items = searchResults.map((uri) => createFileQuickPickItem( uri, + workspace_root, this.getRecentFiles().includes(uri.fsPath) ) ) } quickPick.busy = false - }, 300) // 300ms debounce delay + }, DEBOUNCE_DELAY) }) quickPick.onDidTriggerItemButton((e) => { - const fileUriToRemove = e.item.uri - const currentListItems = quickPick.items.filter( - (item) => item.uri.fsPath !== fileUriToRemove.fsPath - ) - - this.removeFile(fileUriToRemove) - - const recentListItems = this.getRecentFiles() - - quickPick.items = currentListItems.map((item) => - createFileQuickPickItem( - item.uri, - recentListItems.includes(item.uri.fsPath) + this.removeFile(e.item.uri) + + if (isSearching(quickPick.value)) { + quickPick.items = quickPick.items.map((item) => { + if (item.path === e.item.path) { + item.buttons = [] + } + + return item + }) + } else { + quickPick.items = quickPick.items.filter( + (item) => item.path !== e.item.path ) - ) + } }) quickPick.onDidAccept(() => { diff --git a/packages/vscode/src/utils/recent-file-quick-pick.ts b/packages/vscode/src/utils/recent-file-quick-pick.ts deleted file mode 100644 index 9e9349f39..000000000 --- a/packages/vscode/src/utils/recent-file-quick-pick.ts +++ /dev/null @@ -1,309 +0,0 @@ -import * as vscode from 'vscode' -import { execSync } from 'child_process' -import * as path from 'path' -import * as fs from 'fs' -import { SAVED_CONTEXTS_STATE_KEY } from '../constants/state-keys' -import { SavedContext } from '../types/context' -import { RecentFileManager } from '@/services/recent-files-manager' - -export async function at_sign_quick_pick( - context: vscode.ExtensionContext, - is_code_completions_mode = false -): Promise { - let items = [ - { - label: '@File', - description: 'Reference a file' - }, - { - label: '@Selection', - description: 'Text selection of the active editor' - }, - { - label: '@Changes', - description: 'Diff between the current branch and the selected branch' - }, - { - label: '@SavedContext', - description: 'Files from a saved context' - } - ] - - if (is_code_completions_mode) { - items = items.filter( - (item) => item.label != '@Selection' && item.label != '@Changes' - ) - } - - const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select symbol to insert', - matchOnDescription: true - }) - - if (!selected) { - return - } - if (selected.label == '@File') { - const workspace_folders = vscode.workspace.workspaceFolders - if (!workspace_folders || workspace_folders.length == 0) { - vscode.window.showErrorMessage('No workspace folders found') - return - } - const workspace_root = workspace_folders[0].uri.fsPath - const recentFileManager = new RecentFileManager(context); - - async function showFilePicker() { - return new Promise((resolve) => { - const quickPick = vscode.window.createQuickPick<{ label: string; description?: string; path: string; uri: vscode.Uri }>(); - quickPick.placeholder = 'Search for a file by name'; - - let debounceTimeout: NodeJS.Timeout; - - // Helper function to create a QuickPickItem for a file - function createFileQuickPickItem(fileUri: vscode.Uri, workspaceRoot: string): vscode.QuickPickItem & { path: string; uri: vscode.Uri } { - const relativePath = path.relative(workspaceRoot, fileUri.fsPath); - return { - label: path.basename(fileUri.fsPath), - description: path.dirname(relativePath), - path: relativePath, - uri: fileUri - }; - } - - const recentFiles = recentFileManager.getRecentFileUris().map(uri => createFileQuickPickItem(uri, workspace_root)); - - // Initially, show only recent files - quickPick.items = recentFiles; - - quickPick.onDidChangeValue(value => { - clearTimeout(debounceTimeout); - - // When user clears the input, show recent files again - if (!value || value.length < 1) { - quickPick.busy = false; - quickPick.items = recentFiles; - return; - } - - quickPick.busy = true; // Show loading indicator - - // Debounce the search to avoid excessive API calls - debounceTimeout = setTimeout(async () => { - const query = `**/*${value}*`; - const searchResults = await vscode.workspace.findFiles(query, undefined, 100); - - if (searchResults) { - quickPick.items = searchResults.map(uri => createFileQuickPickItem(uri, workspace_root)); - } - quickPick.busy = false; - }, 300); // 300ms debounce delay - }); - - quickPick.onDidAccept(() => { - const selectedFile = quickPick.selectedItems[0]; - if (selectedFile) { - resolve(`File:${selectedFile.path} `); - recentFileManager.addFile(selectedFile.uri) - } - - quickPick.hide(); - }); - - quickPick.onDidHide(() => { - clearTimeout(debounceTimeout); - quickPick.dispose(); - resolve(undefined); // Resolve with undefined if the user dismisses the picker - }); - - quickPick.show(); - }); - } - - const selectedFile = await showFilePicker() - - if (selectedFile) { - return selectedFile - } else { - return await at_sign_quick_pick(context) - } - } - - if (selected.label == '@Selection') { - return 'Selection ' - } - - if (selected.label == '@Changes') { - try { - const workspace_folders = vscode.workspace.workspaceFolders - if (!workspace_folders || workspace_folders.length == 0) { - vscode.window.showErrorMessage('No workspace folders found') - return - } - - const all_branches = new Set() - const workspace_with_branches: Array<{ - folder: vscode.WorkspaceFolder - branches: string[] - }> = [] - - for (const folder of workspace_folders) { - try { - const branches = execSync('git branch --sort=-committerdate', { - encoding: 'utf-8', - cwd: folder.uri.fsPath - }) - .split('\n') - .map((b) => b.trim().replace(/^\* /, '')) - .filter((b) => b.length > 0) - - if (branches.length > 0) { - workspace_with_branches.push({ folder, branches }) - branches.forEach((branch) => all_branches.add(branch)) - } - } catch (error) { - console.log(`Skipping ${folder.name}: not a Git repository`) - } - } - - if (all_branches.size == 0) { - vscode.window.showErrorMessage( - 'No Git branches found in any workspace folder' - ) - return - } - - const branch_items: vscode.QuickPickItem[] = [] - - if (workspace_with_branches.length === 1) { - const { branches } = workspace_with_branches[0] - branch_items.push( - ...branches.map((branch) => ({ - label: branch - })) - ) - } else { - // Multi-root workspace: include folder name with branch - for (const { folder, branches } of workspace_with_branches) { - branch_items.push( - ...branches.map((branch) => ({ - label: `${folder.name}/${branch}`, - description: folder.name - })) - ) - } - } - - const selected_branch = await vscode.window.showQuickPick(branch_items, { - placeHolder: 'Select branch to compare with' - }) - - if (selected_branch) { - // For single root workspace, keep existing format - if (workspace_with_branches.length === 1) { - return `Changes:${selected_branch.label} ` - } else { - // For multi-root workspace, return format: changes:[folder name]/[branch name] - return `Changes:${selected_branch.label} ` - } - } - } catch (error) { - vscode.window.showErrorMessage( - 'Failed to get Git branches. Make sure you are in a Git repository.' - ) - } - } - - if (selected.label == '@SavedContext') { - const workspace_root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspace_root) { - vscode.window.showErrorMessage('No workspace root found.') - return - } - - const internal_contexts: SavedContext[] = - context.workspaceState.get(SAVED_CONTEXTS_STATE_KEY, []) || [] - - const contexts_file_path = path.join( - workspace_root, - '.vscode', - 'contexts.json' - ) - let file_contexts: SavedContext[] = [] - if (fs.existsSync(contexts_file_path)) { - try { - const content = fs.readFileSync(contexts_file_path, 'utf8') - const parsed = JSON.parse(content) - if (Array.isArray(parsed)) { - file_contexts = parsed.filter( - (item: any): item is SavedContext => - typeof item == 'object' && - item !== null && - typeof item.name == 'string' && - Array.isArray(item.paths) && - item.paths.every((p: any) => typeof p == 'string') - ) - } - } catch (e) { - /* ignore */ - } - } - - const source_options: (vscode.QuickPickItem & { - value: 'WorkspaceState' | 'JSON' - })[] = [] - if (internal_contexts.length > 0) { - source_options.push({ - label: 'Workspace State', - description: `${internal_contexts.length} context${ - internal_contexts.length === 1 ? '' : 's' - }`, - value: 'WorkspaceState' - }) - } - if (file_contexts.length > 0) { - source_options.push({ - label: 'JSON File (.vscode/contexts.json)', - description: `${file_contexts.length} context${ - file_contexts.length === 1 ? '' : 's' - }`, - value: 'JSON' - }) - } - - if (source_options.length === 0) { - vscode.window.showInformationMessage('No saved contexts found.') - return - } - - const source = - source_options.length > 1 - ? ( - await vscode.window.showQuickPick(source_options, { - placeHolder: 'Select context source' - }) - )?.value - : source_options[0].value - - if (!source) return - - const contexts_to_use = - source === 'WorkspaceState' ? internal_contexts : file_contexts - - const context_items = contexts_to_use.map((ctx) => ({ - label: ctx.name, - description: `${ctx.paths.length} path${ - ctx.paths.length === 1 ? '' : 's' - }` - })) - - const selected_context = await vscode.window.showQuickPick(context_items, { - placeHolder: 'Select a saved context' - }) - - if (selected_context) { - return `SavedContext:${source} "${selected_context.label}" ` - } - } - - return undefined -} From c7a6bb7ad565d3452a7ada0bc7ed62ce7d6116e4 Mon Sep 17 00:00:00 2001 From: kermage Date: Mon, 4 Aug 2025 09:34:04 +0800 Subject: [PATCH 14/15] skip tracking the ignored files --- packages/vscode/src/extension.ts | 4 ++-- .../src/services/recent-files-manager.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index f00263a97..b7c01b0fd 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -97,10 +97,10 @@ export async function activate(context: vscode.ExtensionContext) { ) } - const recentFileManager = new RecentFileManager(context); + const recentFileManager = new RecentFileManager(context) context.subscriptions.push( - recentFileManager.setupListener(), + recentFileManager.setupListener(workspace_provider), open_file_from_workspace_command(open_editors_provider), apply_chat_response_command(context), ...code_completion_commands( diff --git a/packages/vscode/src/services/recent-files-manager.ts b/packages/vscode/src/services/recent-files-manager.ts index ce986f279..b6819e2dc 100644 --- a/packages/vscode/src/services/recent-files-manager.ts +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode' import * as fs from 'fs' import * as path from 'path' import { RECENT_FILES_STORAGE_KEY } from '@/constants/state-keys' +import { WorkspaceProvider } from '@/context/providers/workspace-provider' const MAX_RECENT_FILES = 50 const DEBOUNCE_DELAY = 300 @@ -48,9 +49,22 @@ export class RecentFileManager { this.context = context } - public setupListener(): vscode.Disposable { + public setupListener( + workspace_provider: WorkspaceProvider + ): vscode.Disposable { + function isIgnored(file_path: string): boolean { + const workspace_root = + workspace_provider.get_workspace_root_for_file(file_path) + + return workspace_root + ? workspace_provider.is_excluded( + path.relative(workspace_root, file_path) + ) + : true + } + return vscode.workspace.onDidOpenTextDocument((document) => { - if (document.uri.scheme === 'file') { + if (document.uri.scheme === 'file' && !isIgnored(document.uri.path)) { this.addFile(document.uri) } }) From 5a4dd6fccb6bfb35a3f44a466e1170ac02ac24f9 Mon Sep 17 00:00:00 2001 From: kermage Date: Mon, 4 Aug 2025 10:56:30 +0800 Subject: [PATCH 15/15] start the centralized symbols definition --- packages/shared/src/constants/symbols.ts | 33 +++++++++++++++++++ .../components/editor/ChatInput/ChatInput.tsx | 17 +++++++--- .../vscode/src/utils/at-sign-quick-pick.ts | 9 ++--- .../extract-file-paths-from-instruction.ts | 9 +++-- .../message-handlers/handle-send-prompt.ts | 3 +- .../utils/replace-file-placeholder.spec.ts | 9 +++++ .../backend/utils/replace-file-placeholder.ts | 20 ++++++----- 7 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 packages/shared/src/constants/symbols.ts create mode 100644 packages/vscode/src/view/backend/utils/replace-file-placeholder.spec.ts diff --git a/packages/shared/src/constants/symbols.ts b/packages/shared/src/constants/symbols.ts new file mode 100644 index 000000000..d290210fa --- /dev/null +++ b/packages/shared/src/constants/symbols.ts @@ -0,0 +1,33 @@ +type Symbol = { + mark: string + label: string + description: string + regexp: RegExp + exactMatch: (text: string) => boolean + getValue: (text: string) => string + markedValue: (text: string) => string +} + +export function captureParts(...patterns: RegExp[]): RegExp { + const sources = patterns.map((pattern) => pattern.source) + + return new RegExp(`(${sources.join('|')})`, 'g') +} + +export const SYMBOLS = { + File: { + mark: '@File:', + label: '@File', + description: 'Reference a file', + regexp: /@File:[^\s]+/, + exactMatch: (text: string): boolean => { + return new RegExp(`^${SYMBOLS.File.regexp.source}$`).test(text) + }, + getValue: (text: string): string => { + return text.slice(SYMBOLS.File.mark.length) + }, + markedValue: (text: string): string => { + return `${SYMBOLS.File.mark.slice(1)}${text} ` + } + } +} as Record diff --git a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx index aada4dc14..ade478e06 100644 --- a/packages/ui/src/components/editor/ChatInput/ChatInput.tsx +++ b/packages/ui/src/components/editor/ChatInput/ChatInput.tsx @@ -3,6 +3,7 @@ import styles from './ChatInput.module.scss' import TextareaAutosize from 'react-textarea-autosize' import cn from 'classnames' import { Icon } from '../Icon' +import { captureParts, SYMBOLS } from '@shared/constants/symbols' type Props = { value: string @@ -96,11 +97,16 @@ export const ChatInput: React.FC = (props) => { return {text} } - const regex = - /(@File:[^\s]+|@Selection|@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?|@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"|`[^`]+`)/g + const regex = captureParts( + SYMBOLS.File.regexp, + /@Selection/, + /@Changes:[^\s,;:.!?]+(?:\/[^\s,;:.!?]+)?/, + /@SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"/, + /`[^`]+`/ + ) const parts = text.split(regex) return parts.map((part, index) => { - if (part && /^@File:[^\s]+$/.test(part)) { + if (SYMBOLS.File.exactMatch(part)) { return ( {part} @@ -271,7 +277,10 @@ export const ChatInput: React.FC = (props) => { } const textBeforeCursor = textarea.value.slice(0, start) - const fileSymbolRegex = /@File:[^\s]+\s$/g + const fileSymbolRegex = new RegExp( + `${SYMBOLS.File.regexp.source}\\s$`, + 'g' + ) const fileSymbolMatch = textBeforeCursor.match(fileSymbolRegex) if (fileSymbolMatch) { diff --git a/packages/vscode/src/utils/at-sign-quick-pick.ts b/packages/vscode/src/utils/at-sign-quick-pick.ts index 6913b209a..c4664cc10 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -5,6 +5,7 @@ import * as fs from 'fs' import { SAVED_CONTEXTS_STATE_KEY } from '../constants/state-keys' import { SavedContext } from '../types/context' import { RecentFileManager } from '@/services/recent-files-manager' +import { SYMBOLS } from '@shared/constants/symbols' export async function at_sign_quick_pick( context: vscode.ExtensionContext, @@ -12,8 +13,8 @@ export async function at_sign_quick_pick( ): Promise { let items = [ { - label: '@File', - description: 'Reference a file' + label: SYMBOLS.File.label, + description: SYMBOLS.File.description }, { label: '@Selection', @@ -43,7 +44,7 @@ export async function at_sign_quick_pick( if (!selected) { return } - if (selected.label == '@File') { + if (selected.label == SYMBOLS.File.label) { const workspace_folders = vscode.workspace.workspaceFolders if (!workspace_folders || workspace_folders.length == 0) { vscode.window.showErrorMessage('No workspace folders found') @@ -54,7 +55,7 @@ export async function at_sign_quick_pick( const selectedFile = await recentFileManager.showFilePicker(workspace_root) if (selectedFile) { - return `File:${selectedFile} ` + return SYMBOLS.File.markedValue(selectedFile) } else { return await at_sign_quick_pick(context) } diff --git a/packages/vscode/src/utils/extract-file-paths-from-instruction.ts b/packages/vscode/src/utils/extract-file-paths-from-instruction.ts index 66bcfd36b..c06777dff 100644 --- a/packages/vscode/src/utils/extract-file-paths-from-instruction.ts +++ b/packages/vscode/src/utils/extract-file-paths-from-instruction.ts @@ -1,14 +1,17 @@ +import { captureParts, SYMBOLS } from '@shared/constants/symbols' + export const extract_file_paths_from_instruction = ( instruction: string ): string[] => { - const regex = /(@File:[^\s]+|`[^`]+`)/g + const backticks = /`[^`]+`/ + const regex = captureParts(SYMBOLS.File.regexp, backticks) const matches = instruction.match(regex) if (!matches) return [] return matches.map((part) => { - if (part && /^@File:[^\s]+$/.test(part)) { - return part.slice(6) + if (SYMBOLS.File.exactMatch(part)) { + return SYMBOLS.File.getValue(part) } return part.slice(1, -1) diff --git a/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts b/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts index 55a339a9d..b96cada25 100644 --- a/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts +++ b/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts @@ -15,6 +15,7 @@ import { ConfigPresetFormat } from '../utils/preset-format-converters' import { extract_file_paths_from_instruction } from '@/utils/extract-file-paths-from-instruction' import { CHATBOTS } from '@shared/constants/chatbots' import { replace_file_placeholder } from '../utils/replace-file-placeholder' +import { SYMBOLS } from '@shared/constants/symbols' /** * When preset_names is an emtpy stirng - show quick pick, @@ -122,7 +123,7 @@ export const handle_send_prompt = async (params: { params.provider.get_presets_config_key() ) - if (instructions.includes('@File:')) { + if (instructions.includes(SYMBOLS.File.mark)) { instructions = replace_file_placeholder(instructions) } diff --git a/packages/vscode/src/view/backend/utils/replace-file-placeholder.spec.ts b/packages/vscode/src/view/backend/utils/replace-file-placeholder.spec.ts new file mode 100644 index 000000000..de7c2fe0b --- /dev/null +++ b/packages/vscode/src/view/backend/utils/replace-file-placeholder.spec.ts @@ -0,0 +1,9 @@ +import { replace_file_placeholder } from './replace-file-placeholder' + +describe('replace_file_placeholder', () => { + it('should replace @File: keyword with backtick', () => { + expect(replace_file_placeholder('@File:path/to/file.ts')).toEqual( + '`path/to/file.ts`' + ) + }) +}) diff --git a/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts b/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts index 35c1d481e..23baf2f9d 100644 --- a/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts +++ b/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts @@ -1,14 +1,18 @@ +import { captureParts, SYMBOLS } from '@shared/constants/symbols' + export const replace_file_placeholder = (instruction: string): string => { - if (!instruction.includes('@File:')) { + if (!instruction.includes(SYMBOLS.File.mark)) { return instruction } - const regex = /(@File:[^\s]+)/g + const regex = captureParts(SYMBOLS.File.regexp) const parts = instruction.split(regex) - return parts.map((part) => { - if (part && /^@File:[^\s]+$/.test(part)) { - return `\`${part.slice(6)}\`` - } - return part - }).join('') + return parts + .map((part) => { + if (SYMBOLS.File.exactMatch(part)) { + return `\`${SYMBOLS.File.getValue(part)}\`` + } + return part + }) + .join('') }