mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
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:
parent
bd900d3668
commit
aa02bcc4e1
7 changed files with 266 additions and 21 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue