fix(vscode-ide-companion): fix race conditions and improve @ file completion search

- Add requestId mechanism to prevent stale async responses from overwriting newer results
- Implement case-insensitive file search with buildCaseInsensitiveGlob method
- Filter gitignored files using FileDiscoveryService integration
- Allow completion list refresh during search by removing query check condition
- Add --experimental-skills CLI argument for qwen connection
- Add unit tests for FileMessageHandler

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yiliang114 2026-01-31 23:45:12 +08:00
parent bd900d3668
commit aa02bcc4e1
7 changed files with 266 additions and 21 deletions

View file

@ -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);

View file

@ -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();
}

View file

@ -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);
});
});

View file

@ -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<void> {
private async handleGetWorkspaceFiles(
query?: string,
requestId?: number,
): Promise<void> {
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;
}
}

View file

@ -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<number | null>(null);
// Last non-empty query to decide when to refetch full list
const lastQueryRef = useRef<string | undefined>(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,

View file

@ -57,6 +57,8 @@ export function useCompletionTrigger(
// Timer for loading timeout
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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) => {

View file

@ -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;
}