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 9d5be568f..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 @@ -16,7 +17,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 @@ -41,6 +45,7 @@ type Props = { } caret_position_to_set?: number on_caret_position_set?: () => void + is_focused?: Date } const format_token_count = (count?: number) => { @@ -81,15 +86,33 @@ 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} } - const regex = - /(@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 (SYMBOLS.File.exactMatch(part)) { + return ( + + {part} + + ) + } if (part == '@Selection') { return ( = (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) => { @@ -243,6 +266,35 @@ 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 = new RegExp( + `${SYMBOLS.File.regexp.source}\\s$`, + 'g' + ) + const fileSymbolMatch = textBeforeCursor.match(fileSymbolRegex) + + if (fileSymbolMatch) { + e.preventDefault() + + const symbolToRemove = fileSymbolMatch[0] + const startOfSymbol = start - symbolToRemove.length + const textAfterCursor = textarea.value.slice(start) + + props.on_change( + textBeforeCursor.slice(0, startOfSymbol) + textAfterCursor + ) + props.on_caret_position_change(startOfSymbol, false) + } } else if (props.value) { set_is_history_enabled(false) } 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..b7c01b0fd 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(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 new file mode 100644 index 000000000..b6819e2dc --- /dev/null +++ b/packages/vscode/src/services/recent-files-manager.ts @@ -0,0 +1,183 @@ +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 +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 + + constructor(context: vscode.ExtensionContext) { + this.context = context + } + + 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' && !isIgnored(document.uri.path)) { + 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 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 async showFilePicker(workspace_root: string) { + return new Promise((resolve) => { + const quickPick = vscode.window.createQuickPick() + quickPick.placeholder = 'Search for a file by name' + + let debounceTimeout: NodeJS.Timeout + + 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 + + quickPick.onDidChangeValue((value) => { + clearTimeout(debounceTimeout) + + // When user clears the input, show recent files again + if (!isSearching(value)) { + 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, + this.getRecentFiles().includes(uri.fsPath) + ) + ) + } + quickPick.busy = false + }, DEBOUNCE_DELAY) + }) + + quickPick.onDidTriggerItemButton((e) => { + 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(() => { + 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 897244f39..c4664cc10 100644 --- a/packages/vscode/src/utils/at-sign-quick-pick.ts +++ b/packages/vscode/src/utils/at-sign-quick-pick.ts @@ -4,12 +4,18 @@ 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' +import { SYMBOLS } from '@shared/constants/symbols' export async function at_sign_quick_pick( context: vscode.ExtensionContext, is_code_completions_mode = false ): Promise { let items = [ + { + label: SYMBOLS.File.label, + description: SYMBOLS.File.description + }, { label: '@Selection', description: 'Text selection of the active editor' @@ -38,6 +44,22 @@ export async function at_sign_quick_pick( if (!selected) { return } + 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') + return + } + const workspace_root = workspace_folders[0].uri.fsPath + const recentFileManager = new RecentFileManager(context) + const selectedFile = await recentFileManager.showFilePicker(workspace_root) + + if (selectedFile) { + return SYMBOLS.File.markedValue(selectedFile) + } else { + return await at_sign_quick_pick(context) + } + } if (selected.label == '@Selection') { return 'Selection ' 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..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,8 +1,19 @@ +import { captureParts, SYMBOLS } from '@shared/constants/symbols' + export const extract_file_paths_from_instruction = ( instruction: string ): string[] => { - const matches = instruction.match(/`([^`]+)`/g) + const backticks = /`[^`]+`/ + const regex = captureParts(SYMBOLS.File.regexp, backticks) + + const matches = instruction.match(regex) if (!matches) return [] - return matches.map((match) => match.slice(1, -1)) // Remove backticks + return matches.map((part) => { + 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-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/backend/message-handlers/handle-send-prompt.ts b/packages/vscode/src/view/backend/message-handlers/handle-send-prompt.ts index ed2fba319..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 @@ -14,6 +14,8 @@ 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' +import { SYMBOLS } from '@shared/constants/symbols' /** * When preset_names is an emtpy stirng - show quick pick, @@ -121,6 +123,10 @@ export const handle_send_prompt = async (params: { params.provider.get_presets_config_key() ) + if (instructions.includes(SYMBOLS.File.mark)) { + 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.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 new file mode 100644 index 000000000..23baf2f9d --- /dev/null +++ b/packages/vscode/src/view/backend/utils/replace-file-placeholder.ts @@ -0,0 +1,18 @@ +import { captureParts, SYMBOLS } from '@shared/constants/symbols' + +export const replace_file_placeholder = (instruction: string): string => { + if (!instruction.includes(SYMBOLS.File.mark)) { + return instruction + } + + const regex = captureParts(SYMBOLS.File.regexp) + const parts = instruction.split(regex) + return parts + .map((part) => { + if (SYMBOLS.File.exactMatch(part)) { + return `\`${SYMBOLS.File.getValue(part)}\`` + } + return part + }) + .join('') +} diff --git a/packages/vscode/src/view/frontend/home/Home.tsx b/packages/vscode/src/view/frontend/home/Home.tsx index 4e56c830f..cc5cc13c9 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 } } @@ -292,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 @@ -516,6 +527,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..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 @@ -62,6 +65,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 +293,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