diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 9b4a188c8..af9140905 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -55,7 +55,7 @@ export class QwenConnectionHandler { let availableModels: ModelInfo[] | undefined; // Build extra CLI arguments (only essential parameters) - const extraArgs: string[] = []; + const extraArgs: string[] = ['--experimental-skills']; await connection.connect(cliEntryPath!, workingDir, extraArgs); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index a1a4ceb0a..33f509929 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -264,16 +264,11 @@ export const App: React.FC = () => { [fileContext.workspaceFiles], ); - // When workspace files update while menu open for @, refresh items so the first @ shows the list + // When workspace files update while menu open for @, refresh items to reflect latest search results. // Note: Avoid depending on the entire `completion` object here, since its identity // changes on every render which would retrigger this effect and can cause a refresh loop. useEffect(() => { - // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search - if ( - completion.isOpen && - completion.triggerChar === '@' && - !completion.query - ) { + if (completion.isOpen && completion.triggerChar === '@') { // Only refresh items; do not change other completion state to avoid re-renders loops completion.refreshCompletion(); } diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts new file mode 100644 index 000000000..8cccae79e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import { FileMessageHandler } from './FileMessageHandler.js'; +import * as vscode from 'vscode'; + +const shouldIgnoreFileMock = vi.hoisted(() => vi.fn()); +const vscodeMock = vi.hoisted(() => { + class Uri { + fsPath: string; + constructor(fsPath: string) { + this.fsPath = fsPath; + } + static file(fsPath: string) { + return new Uri(fsPath); + } + } + + return { + Uri, + workspace: { + findFiles: vi.fn(), + getWorkspaceFolder: vi.fn(), + asRelativePath: vi.fn(), + workspaceFolders: [], + }, + window: { + activeTextEditor: undefined, + tabGroups: { + all: [], + }, + }, + }; +}); + +vi.mock('vscode', () => vscodeMock); +vi.mock( + '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js', + () => ({ + FileDiscoveryService: class { + shouldIgnoreFile(filePath: string, options?: unknown) { + return shouldIgnoreFileMock(filePath, options); + } + }, + }), +); + +describe('FileMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters ignored paths and includes request metadata in workspace files', async () => { + const rootPath = '/workspace'; + const allowedPath = `${rootPath}/allowed.txt`; + const ignoredPath = `${rootPath}/ignored.log`; + + const allowedUri = vscode.Uri.file(allowedPath); + const ignoredUri = vscode.Uri.file(ignoredPath); + + vscodeMock.workspace.findFiles.mockResolvedValue([allowedUri, ignoredUri]); + vscodeMock.workspace.getWorkspaceFolder.mockImplementation(() => ({ + uri: vscode.Uri.file(rootPath), + })); + vscodeMock.workspace.asRelativePath.mockImplementation((uri: vscode.Uri) => + uri.fsPath.replace(`${rootPath}/`, ''), + ); + + shouldIgnoreFileMock.mockImplementation((filePath: string) => + filePath.includes('ignored'), + ); + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'getWorkspaceFiles', + data: { query: 'txt', requestId: 7 }, + }); + + expect(vscodeMock.workspace.findFiles).toHaveBeenCalledWith( + '**/*[tT][xX][tT]*', + '**/{.git,node_modules}/**', + 50, + ); + expect(shouldIgnoreFileMock).toHaveBeenCalledWith(ignoredPath, { + respectGitIgnore: true, + respectQwenIgnore: false, + }); + + expect(sendToWebView).toHaveBeenCalledTimes(1); + const payload = sendToWebView.mock.calls[0]?.[0] as { + type: string; + data: { + files: Array<{ path: string }>; + query?: string; + requestId?: number; + }; + }; + + expect(payload.type).toBe('workspaceFiles'); + expect(payload.data.requestId).toBe(7); + expect(payload.data.query).toBe('txt'); + expect(payload.data.files).toHaveLength(1); + expect(payload.data.files[0]?.path).toBe(allowedPath); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index c786d1eea..908de9ca4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -13,12 +13,32 @@ import { ensureLeftGroupOfChatWebview, } from '../../utils/editorGroupUtils.js'; import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js'; +import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js'; /** * File message handler * Handles all file-related messages */ export class FileMessageHandler extends BaseMessageHandler { + private readonly fileDiscoveryServices = new Map< + string, + FileDiscoveryService + >(); + private readonly globSpecialChars = new Set([ + '\\', + '*', + '?', + '[', + ']', + '{', + '}', + '(', + ')', + '!', + '+', + '@', + ]); + canHandle(messageType: string): boolean { return [ 'attachFile', @@ -43,7 +63,10 @@ export class FileMessageHandler extends BaseMessageHandler { break; case 'getWorkspaceFiles': - await this.handleGetWorkspaceFiles(data?.query as string | undefined); + await this.handleGetWorkspaceFiles( + data?.query as string | undefined, + data?.requestId as number | undefined, + ); break; case 'openFile': @@ -190,10 +213,14 @@ export class FileMessageHandler extends BaseMessageHandler { /** * Get workspace files */ - private async handleGetWorkspaceFiles(query?: string): Promise { + private async handleGetWorkspaceFiles( + query?: string, + requestId?: number, + ): Promise { try { console.log('[FileMessageHandler] handleGetWorkspaceFiles start', { query, + requestId, }); const files: Array<{ id: string; @@ -208,8 +235,26 @@ export class FileMessageHandler extends BaseMessageHandler { return; } - const fileName = getFileName(uri.fsPath); const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder) { + const rootPath = workspaceFolder.uri.fsPath; + let discovery = this.fileDiscoveryServices.get(rootPath); + if (!discovery) { + discovery = new FileDiscoveryService(rootPath); + this.fileDiscoveryServices.set(rootPath, discovery); + } + // Apply gitignore filtering so ignored paths don't appear in @ results. + if ( + discovery.shouldIgnoreFile(uri.fsPath, { + respectGitIgnore: true, + respectQwenIgnore: false, + }) + ) { + return; + } + } + + const fileName = getFileName(uri.fsPath); const relativePath = workspaceFolder ? vscode.workspace.asRelativePath(uri, false) : uri.fsPath; @@ -234,14 +279,15 @@ export class FileMessageHandler extends BaseMessageHandler { // Search or show recent files if (query) { + const includePattern = `**/*${this.buildCaseInsensitiveGlob(query)}*`; // Query mode: perform filesystem search (may take longer on large workspaces) console.log( '[FileMessageHandler] Searching workspace files for query', query, ); const uris = await vscode.workspace.findFiles( - `**/*${query}*`, - '**/node_modules/**', + includePattern, + '**/{.git,node_modules}/**', 50, ); @@ -269,7 +315,10 @@ export class FileMessageHandler extends BaseMessageHandler { // Send an initial quick response so UI can render immediately try { - this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + this.sendToWebView({ + type: 'workspaceFiles', + data: { files, query, requestId }, + }); console.log( '[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)', files.length, @@ -285,7 +334,7 @@ export class FileMessageHandler extends BaseMessageHandler { if (files.length < 10) { const recentUris = await vscode.workspace.findFiles( '**/*', - '**/node_modules/**', + '**/{.git,node_modules}/**', 20, ); @@ -298,7 +347,10 @@ export class FileMessageHandler extends BaseMessageHandler { } } - this.sendToWebView({ type: 'workspaceFiles', data: { files } }); + this.sendToWebView({ + type: 'workspaceFiles', + data: { files, query, requestId }, + }); console.log( '[FileMessageHandler] Sent final workspaceFiles', files.length, @@ -496,4 +548,18 @@ export class FileMessageHandler extends BaseMessageHandler { ); } } + + private buildCaseInsensitiveGlob(query: string): string { + let pattern = ''; + for (const char of query) { + if (/[a-zA-Z]/.test(char)) { + pattern += `[${char.toLowerCase()}${char.toUpperCase()}]`; + } else if (this.globSpecialChars.has(char)) { + pattern += `\\${char}`; + } else { + pattern += char; + } + } + return pattern; + } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts index 8bccc658e..0f5296550 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -34,6 +34,10 @@ export const useFileContext = (vscode: VSCodeAPI) => { // Whether workspace files have been requested const hasRequestedFilesRef = useRef(false); + // Use request ids to avoid applying stale workspace file responses. + const workspaceFilesRequestIdRef = useRef(0); + const latestWorkspaceFilesRequestIdRef = useRef(null); + // Last non-empty query to decide when to refetch full list const lastQueryRef = useRef(undefined); @@ -46,31 +50,47 @@ export const useFileContext = (vscode: VSCodeAPI) => { const requestWorkspaceFiles = useCallback( (query?: string) => { const normalizedQuery = query?.trim(); + const normalizedQueryKey = normalizedQuery?.toLowerCase(); // If there's a query, clear previous timer and set up debounce if (normalizedQuery && normalizedQuery.length >= 1) { + if (normalizedQueryKey === lastQueryRef.current) { + return; + } if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } + const requestId = workspaceFilesRequestIdRef.current + 1; + workspaceFilesRequestIdRef.current = requestId; + latestWorkspaceFilesRequestIdRef.current = requestId; + searchTimerRef.current = setTimeout(() => { vscode.postMessage({ type: 'getWorkspaceFiles', - data: { query: normalizedQuery }, + data: { query: normalizedQuery, requestId }, }); }, 300); - lastQueryRef.current = normalizedQuery; + lastQueryRef.current = normalizedQueryKey; } else { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = null; + } + // For empty query, request once initially and whenever we are returning from a search const shouldRequestFullList = !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; if (shouldRequestFullList) { + const requestId = workspaceFilesRequestIdRef.current + 1; + workspaceFilesRequestIdRef.current = requestId; + latestWorkspaceFilesRequestIdRef.current = requestId; lastQueryRef.current = undefined; hasRequestedFilesRef.current = true; vscode.postMessage({ type: 'getWorkspaceFiles', - data: {}, + data: { requestId }, }); } } @@ -78,6 +98,30 @@ export const useFileContext = (vscode: VSCodeAPI) => { [vscode], ); + /** + * Apply workspace file responses only if they are current. + */ + const setWorkspaceFilesFromResponse = useCallback( + ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + requestId?: number, + ) => { + if ( + typeof requestId === 'number' && + latestWorkspaceFilesRequestIdRef.current !== requestId + ) { + return; + } + setWorkspaceFiles(files); + }, + [], + ); + /** * Add file reference */ @@ -130,6 +174,7 @@ export const useFileContext = (vscode: VSCodeAPI) => { setActiveFilePath, setActiveSelection, setWorkspaceFiles, + setWorkspaceFilesFromResponse, // File reference operations addFileReference, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index b18843ef5..f3a660366 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -57,6 +57,8 @@ export function useCompletionTrigger( // Timer for loading timeout const timeoutRef = useRef | null>(null); + // Track request order so slower responses can't overwrite newer completions. + const requestIdRef = useRef(0); const closeCompletion = useCallback(() => { // Clear pending timeout @@ -64,6 +66,7 @@ export function useCompletionTrigger( clearTimeout(timeoutRef.current); timeoutRef.current = null; } + requestIdRef.current += 1; setState({ isOpen: false, triggerChar: null, @@ -79,6 +82,8 @@ export function useCompletionTrigger( query: string, position: { top: number; left: number }, ) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; // Clear previous timeout if any if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -96,6 +101,9 @@ export function useCompletionTrigger( // Schedule a timeout fallback if loading takes too long timeoutRef.current = setTimeout(() => { + if (requestIdRef.current !== requestId) { + return; + } setState((prev) => { // Only show timeout if still open and still for the same request if ( @@ -112,6 +120,9 @@ export function useCompletionTrigger( }, TIMEOUT_MS); const items = await getCompletionItems(trigger, query); + if (requestIdRef.current !== requestId) { + return; + } // Clear timeout on success if (timeoutRef.current) { @@ -171,7 +182,12 @@ export function useCompletionTrigger( if (!state.isOpen || !state.triggerChar) { return; } + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; const items = await getCompletionItems(state.triggerChar, state.query); + if (requestIdRef.current !== requestId) { + return; + } // Only update state if items have actually changed setState((prev) => { diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 43375f5a6..7a66e393f 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -54,13 +54,14 @@ interface UseWebViewMessagesProps { setActiveSelection: ( selection: { startLine: number; endLine: number } | null, ) => void; - setWorkspaceFiles: ( + setWorkspaceFilesFromResponse: ( files: Array<{ id: string; label: string; description: string; path: string; }>, + requestId?: number, ) => void; addFileReference: (name: string, path: string) => void; }; @@ -923,9 +924,13 @@ export const useWebViewMessages = ({ description: string; path: string; }>; + const requestId = message.data?.requestId as number | undefined; if (files) { console.log('[WebView] Received workspaceFiles:', files.length); - handlers.fileContext.setWorkspaceFiles(files); + handlers.fileContext.setWorkspaceFilesFromResponse( + files, + requestId, + ); } break; }