From 99f93b457c55c399d9249f805eced71f818b5d50 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 21 Nov 2025 01:53:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(vscode-ide-companion):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=BB=E5=BA=94=E7=94=A8=E7=95=8C=E9=9D=A2=E5=92=8C?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 App.tsx,集成新增的 UI 组件 - 增强 MessageHandler,支持更多消息类型处理 - 优化 FileOperations,改进文件操作逻辑 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../vscode-ide-companion/src/webview/App.tsx | 479 +++++++++++++++++- .../src/webview/FileOperations.ts | 16 +- .../src/webview/MessageHandler.ts | 281 +++++++++- 3 files changed, 754 insertions(+), 22 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index d102c665..17366e9e 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useVSCode } from './hooks/useVSCode.js'; import type { Conversation } from '../storage/conversationStore.js'; import { @@ -13,9 +13,15 @@ import { } from './components/PermissionRequest.js'; import { PermissionDrawer } from './components/PermissionDrawer.js'; import { ToolCall, type ToolCallData } from './components/ToolCall.js'; +import { hasToolCallOutput } from './components/toolcalls/shared/utils.js'; import { EmptyState } from './components/EmptyState.js'; import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js'; import { MessageContent } from './components/MessageContent.js'; +import { + CompletionMenu, + type CompletionItem, +} from './components/CompletionMenu.js'; +import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; interface ToolCallUpdate { type: 'tool_call' | 'tool_call_update'; @@ -222,6 +228,121 @@ export const App: React.FC = () => { const [activeFileName, setActiveFileName] = useState(null); const [isComposing, setIsComposing] = useState(false); + // Workspace files cache + const [workspaceFiles, setWorkspaceFiles] = useState< + Array<{ + id: string; + label: string; + description: string; + path: string; + }> + >([]); + + // File reference map: @filename -> full path + const fileReferenceMap = useRef>(new Map()); + + // Request workspace files on mount or when @ is first triggered + const hasRequestedFilesRef = useRef(false); + + // Debounce timer for search requests + const searchTimerRef = useRef(null); + + // Get completion items based on trigger character + const getCompletionItems = useCallback( + async (trigger: '@' | '/', query: string): Promise => { + if (trigger === '@') { + // Request workspace files on first @ trigger + if (!hasRequestedFilesRef.current) { + hasRequestedFilesRef.current = true; + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: {}, + }); + } + + // Convert workspace files to completion items + const fileIcon = ( + + + + ); + + // Convert all files to items + const allItems: CompletionItem[] = workspaceFiles.map((file) => ({ + id: file.id, + label: file.label, + description: file.description, + type: 'file' as const, + icon: fileIcon, + value: file.path, + })); + + // If query provided, filter locally AND request from backend (debounced) + if (query && query.length >= 1) { + // Clear previous search timer + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + // Debounce backend search request (300ms) + searchTimerRef.current = setTimeout(() => { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: { query }, + }); + }, 300); + + // Filter locally for immediate feedback + const lowerQuery = query.toLowerCase(); + const filtered = allItems.filter( + (item) => + item.label.toLowerCase().includes(lowerQuery) || + (item.description && + item.description.toLowerCase().includes(lowerQuery)), + ); + + return filtered; + } + + return allItems; + } else { + // Slash commands - only /login for now + const commands: CompletionItem[] = [ + { + id: 'login', + label: '/login', + description: 'Login to Qwen Code', + type: 'command', + icon: ( + + + + ), + }, + ]; + + return commands.filter((cmd) => + cmd.label.toLowerCase().includes(query.toLowerCase()), + ); + } + }, + [vscode, workspaceFiles], + ); + + // Use completion trigger hook + const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + + // Don't auto-refresh completion menu when workspace files update + // This was causing flickering. User can re-type to get fresh results. + const handlePermissionRequest = React.useCallback( (request: { options: PermissionOption[]; @@ -243,6 +364,181 @@ export const App: React.FC = () => { [vscode], ); + // Handle completion item selection + const handleCompletionSelect = useCallback( + (item: CompletionItem) => { + if (!inputFieldRef.current) { + return; + } + + const inputElement = inputFieldRef.current; + const currentText = inputElement.textContent || ''; + + if (item.type === 'file') { + // Store file reference mapping + const filePath = (item.value as string) || item.label; + fileReferenceMap.current.set(item.label, filePath); + + console.log('[handleCompletionSelect] Current text:', currentText); + console.log('[handleCompletionSelect] Selected file:', item.label); + + // Find the @ position in current text + const atPos = currentText.lastIndexOf('@'); + + if (atPos !== -1) { + // Find the end of the query (could be at cursor or at next space/end) + const textAfterAt = currentText.substring(atPos + 1); + const spaceIndex = textAfterAt.search(/[\s\n]/); + const queryEnd = + spaceIndex === -1 ? currentText.length : atPos + 1 + spaceIndex; + + // Replace from @ to end of query with @filename + const textBefore = currentText.substring(0, atPos); + const textAfter = currentText.substring(queryEnd); + const newText = `${textBefore}@${item.label} ${textAfter}`; + + console.log('[handleCompletionSelect] New text:', newText); + + // Update the input + inputElement.textContent = newText; + setInputText(newText); + + // Set cursor after the inserted filename (after the space) + const newCursorPos = atPos + item.label.length + 2; // +1 for @, +1 for space + + // Wait for DOM to update, then set cursor + setTimeout(() => { + const textNode = inputElement.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + try { + range.setStart( + textNode, + Math.min(newCursorPos, newText.length), + ); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } catch (e) { + console.error( + '[handleCompletionSelect] Error setting cursor:', + e, + ); + // Fallback: move cursor to end + range.selectNodeContents(inputElement); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + inputElement.focus(); + }, 10); + } + } else if (item.type === 'command') { + // Replace entire input with command + inputElement.textContent = item.label + ' '; + setInputText(item.label + ' '); + + // Move cursor to end + setTimeout(() => { + const range = document.createRange(); + const sel = window.getSelection(); + if (inputElement.firstChild) { + range.setStart(inputElement.firstChild, (item.label + ' ').length); + range.collapse(true); + } else { + range.selectNodeContents(inputElement); + range.collapse(false); + } + sel?.removeAllRanges(); + sel?.addRange(range); + inputElement.focus(); + }, 10); + } + + // Close completion + completion.closeCompletion(); + }, + [completion], + ); + + // Handle attach context button click (Cmd/Ctrl + /) + const handleAttachContextClick = useCallback(async () => { + if (inputFieldRef.current) { + // Focus the input first + inputFieldRef.current.focus(); + + // Insert @ at the end of current text + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText ? `${currentText} @` : '@'; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + // Move cursor to end + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + + // Wait for DOM to update before getting position and opening menu + requestAnimationFrame(async () => { + if (!inputFieldRef.current) { + return; + } + + // Get cursor position for menu placement + let position = { top: 0, left: 0 }; + const selection = window.getSelection(); + + if (selection && selection.rangeCount > 0) { + try { + const currentRange = selection.getRangeAt(0); + const rangeRect = currentRange.getBoundingClientRect(); + if (rangeRect.top > 0 && rangeRect.left > 0) { + position = { + top: rangeRect.top, + left: rangeRect.left, + }; + } else { + const inputRect = inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } catch (error) { + console.error('[App] Error getting cursor position:', error); + const inputRect = inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } else { + const inputRect = inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + + // Open completion menu with @ trigger + await completion.openCompletion('@', '', position); + }); + } + }, [completion]); + + // Handle keyboard shortcut for attach context (Cmd/Ctrl + /) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl + / for attach context + if ((e.metaKey || e.ctrlKey) && e.key === '/') { + e.preventDefault(); + handleAttachContextClick(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleAttachContextClick]); + + // Handle removing context attachment const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => { setToolCalls((prev) => { const newMap = new Map(prev); @@ -467,6 +763,18 @@ export const App: React.FC = () => { setCurrentSessionTitle('Past Conversations'); break; + case 'sessionTitleUpdated': { + // Update session title when first message is sent + const sessionId = message.data?.sessionId as string; + const title = message.data?.title as string; + if (sessionId && title) { + console.log('[App] Session title updated:', title); + setCurrentSessionId(sessionId); + setCurrentSessionTitle(title); + } + break; + } + case 'activeEditorChanged': { // 从扩展接收当前激活编辑器的文件名 const fileName = message.data?.fileName as string | null; @@ -474,6 +782,52 @@ export const App: React.FC = () => { break; } + case 'fileAttached': { + // Handle file attachment from VSCode - insert as @mention + const attachment = message.data as { + id: string; + type: string; + name: string; + value: string; + }; + + // Store file reference + fileReferenceMap.current.set(attachment.name, attachment.value); + + // Insert @filename into input + if (inputFieldRef.current) { + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText + ? `${currentText} @${attachment.name} ` + : `@${attachment.name} `; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + // Move cursor to end + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + break; + } + + case 'workspaceFiles': { + // Handle workspace files list from VSCode + const files = message.data?.files as Array<{ + id: string; + label: string; + description: string; + path: string; + }>; + if (files) { + setWorkspaceFiles(files); + } + break; + } + default: break; } @@ -588,16 +942,38 @@ export const App: React.FC = () => { setIsWaitingForResponse(true); setLoadingMessage(getRandomLoadingMessage()); + // Parse @file references from input text + const context: Array<{ type: string; name: string; value: string }> = []; + const fileRefPattern = /@([^\s]+)/g; + let match; + + while ((match = fileRefPattern.exec(inputText)) !== null) { + const fileName = match[1]; + const filePath = fileReferenceMap.current.get(fileName); + + if (filePath) { + context.push({ + type: 'file', + name: fileName, + value: filePath, + }); + } + } + vscode.postMessage({ type: 'sendMessage', - data: { text: inputText }, + data: { + text: inputText, + context: context.length > 0 ? context : undefined, + }, }); - // Clear input field + // Clear input field and file reference map setInputText(''); if (inputFieldRef.current) { inputFieldRef.current.textContent = ''; } + fileReferenceMap.current.clear(); }; const handleLoadQwenSessions = () => { @@ -911,10 +1287,12 @@ export const App: React.FC = () => { ); })} - {/* Tool Calls */} - {Array.from(toolCalls.values()).map((toolCall) => ( - - ))} + {/* Tool Calls - only show those with actual output */} + {Array.from(toolCalls.values()) + .filter((toolCall) => hasToolCallOutput(toolCall)) + .map((toolCall) => ( + + ))} {/* Plan Display - shows task list when available */} {planEntries.length > 0 && } @@ -1006,6 +1384,8 @@ export const App: React.FC = () => {
+ {/* Context Pills - Removed: now using inline @mentions in input */} +
@@ -1031,6 +1411,10 @@ export const App: React.FC = () => { onKeyDown={(e) => { // 如果正在进行中文输入法输入(拼音输入),不处理回车键 if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + // 如果 CompletionMenu 打开,让它处理 Enter 键(选中文件) + if (completion.isOpen) { + return; + } e.preventDefault(); handleSubmit(e); } @@ -1078,6 +1462,8 @@ export const App: React.FC = () => { )}
+ {/* Spacer 将右侧按钮推到右边 */} +
-
+ +
); }; diff --git a/packages/vscode-ide-companion/src/webview/FileOperations.ts b/packages/vscode-ide-companion/src/webview/FileOperations.ts index 2ed99b9c..045beae5 100644 --- a/packages/vscode-ide-companion/src/webview/FileOperations.ts +++ b/packages/vscode-ide-companion/src/webview/FileOperations.ts @@ -13,8 +13,8 @@ import { getFileName } from '../utils/webviewUtils.js'; */ export class FileOperations { /** - * 打开文件并可选跳转到指定行 - * @param filePath 文件路径,可以包含行号(格式:path/to/file.ts:123) + * 打开文件并可选跳转到指定行和列 + * @param filePath 文件路径,可以包含行号和列号(格式:path/to/file.ts:123 或 path/to/file.ts:123:45) */ static async openFile(filePath?: string): Promise { try { @@ -25,15 +25,17 @@ export class FileOperations { console.log('[FileOperations] Opening file:', filePath); - // Parse file path and line number (format: path/to/file.ts:123) - const match = filePath.match(/^(.+?)(?::(\d+))?$/); + // Parse file path, line number, and column number + // Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45 + const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/); if (!match) { console.warn('[FileOperations] Invalid file path format:', filePath); return; } - const [, path, lineStr] = match; + const [, path, lineStr, columnStr] = match; const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers + const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers // Convert to absolute path if relative let absolutePath = path; @@ -53,9 +55,9 @@ export class FileOperations { preserveFocus: false, }); - // Navigate to line if specified + // Navigate to line and column if specified if (lineStr) { - const position = new vscode.Position(lineNumber, 0); + const position = new vscode.Position(lineNumber, columnNumber); editor.selection = new vscode.Selection(position, position); editor.revealRange( new vscode.Range(position, position), diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 6ae45106..6623b9f5 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -172,13 +172,12 @@ export class MessageHandler { break; case 'openDiff': - await FileOperations.openDiff( - data as { - path?: string; - oldText?: string; - newText?: string; - }, - ); + console.log('[MessageHandler] openDiff called with:', data); + await vscode.commands.executeCommand('qwenCode.showDiff', { + path: (data as { path?: string })?.path || '', + oldText: (data as { oldText?: string })?.oldText || '', + newText: (data as { newText?: string })?.newText || '', + }); break; case 'openNewChatTab': @@ -186,6 +185,18 @@ export class MessageHandler { await vscode.commands.executeCommand('qwenCode.openNewChatTab'); break; + case 'attachFile': + await this.handleAttachFile(); + break; + + case 'showContextPicker': + await this.handleShowContextPicker(); + break; + + case 'getWorkspaceFiles': + await this.handleGetWorkspaceFiles(data?.query as string); + break; + default: console.warn('[MessageHandler] Unknown message type:', message.type); break; @@ -237,6 +248,35 @@ export class MessageHandler { return; } + // Check if this is the first message by checking conversation messages + let isFirstMessage = false; + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + // First message if conversation has no messages yet + isFirstMessage = !conversation || conversation.messages.length === 0; + console.log('[MessageHandler] Is first message:', isFirstMessage); + } catch (error) { + console.error('[MessageHandler] Failed to check conversation:', error); + } + + // If this is the first message, generate and send session title + if (isFirstMessage) { + // Generate title from first message (max 50 characters) + const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + console.log('[MessageHandler] Generated session title:', title); + + // Send title update to WebView + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: this.currentConversationId, + title, + }, + }); + } + // Save user message const userMessage: ChatMessage = { role: 'user', @@ -521,4 +561,231 @@ export class MessageHandler { }); } } + + /** + * 处理附加文件请求 + * 打开文件选择器,将选中的文件信息发送回WebView + */ + private async handleAttachFile(): Promise { + try { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uris && uris.length > 0) { + const uri = uris[0]; + const fileName = getFileName(uri.fsPath); + + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri.fsPath, + }, + }); + } + } catch (error) { + console.error('[MessageHandler] Failed to attach file:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to attach file: ${error}` }, + }); + } + } + + /** + * 获取工作区文件列表 + * 用于在 @ 触发时显示文件补全 + * 优先显示最近使用的文件(打开的标签页) + */ + private async handleGetWorkspaceFiles(query?: string): Promise { + try { + const files: Array<{ + id: string; + label: string; + description: string; + path: string; + }> = []; + const addedPaths = new Set(); + + // Helper function to add a file + const addFile = (uri: vscode.Uri, isCurrentFile = false) => { + if (addedPaths.has(uri.fsPath)) { + return; + } + + const fileName = getFileName(uri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(uri, false) + : uri.fsPath; + + // Filter by query if provided + if ( + query && + !fileName.toLowerCase().includes(query.toLowerCase()) && + !relativePath.toLowerCase().includes(query.toLowerCase()) + ) { + return; + } + + files.push({ + id: isCurrentFile ? 'current-file' : uri.fsPath, + label: fileName, + description: relativePath, + path: uri.fsPath, + }); + addedPaths.add(uri.fsPath); + }; + + // If query provided, search entire workspace + if (query) { + // Search workspace files matching the query + const uris = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50, // Allow more results for search + ); + + for (const uri of uris) { + addFile(uri); + } + } else { + // No query: show recently used files + // 1. Add current active file first + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + addFile(activeEditor.document.uri, true); + } + + // 2. Add all open tabs (recently used files) + const tabGroups = vscode.window.tabGroups.all; + for (const tabGroup of tabGroups) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input && input.uri instanceof vscode.Uri) { + addFile(input.uri); + } + } + } + + // 3. If still not enough files (less than 10), add some workspace files + if (files.length < 10) { + const recentUris = await vscode.workspace.findFiles( + '**/*', + '**/node_modules/**', + 20, + ); + + for (const uri of recentUris) { + if (files.length >= 20) { + break; + } + addFile(uri); + } + } + } + + this.sendToWebView({ + type: 'workspaceFiles', + data: { files }, + }); + } catch (error) { + console.error('[MessageHandler] Failed to get workspace files:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get workspace files: ${error}` }, + }); + } + } + + /** + * 处理显示上下文选择器请求 + * 显示快速选择菜单,包含文件、符号等选项 + * 参考 vscode-copilot-chat 的 AttachContextAction + */ + private async handleShowContextPicker(): Promise { + try { + const items: vscode.QuickPickItem[] = []; + + // Add current file + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + items.push({ + label: `$(file) ${fileName}`, + description: 'Current file', + detail: activeEditor.document.uri.fsPath, + }); + } + + // Add file picker option + items.push({ + label: '$(file) File...', + description: 'Choose a file to attach', + }); + + // Add workspace files option + items.push({ + label: '$(search) Search files...', + description: 'Search workspace files', + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Attach context', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected) { + if (selected.label.includes('Current file') && activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: activeEditor.document.uri.fsPath, + }, + }); + } else if (selected.label.includes('File...')) { + await this.handleAttachFile(); + } else if (selected.label.includes('Search files')) { + // Open workspace file picker + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uri && uri.length > 0) { + const fileName = getFileName(uri[0].fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri[0].fsPath, + }, + }); + } + } + } + } catch (error) { + console.error('[MessageHandler] Failed to show context picker:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to show context picker: ${error}` }, + }); + } + } }