Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions packages/vscode-ide-companion/src/webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,11 @@ export const App: React.FC = () => {
}),
);

if (query && query.length >= 1) {
const lowerQuery = query.toLowerCase();
return allItems.filter(
(item) =>
item.label.toLowerCase().includes(lowerQuery) ||
(item.description &&
item.description.toLowerCase().includes(lowerQuery)),
);
}
// Fuzzy search is handled by the backend (FileSearchFactory)
// No client-side filtering needed - results are already fuzzy-matched

// If first time and still loading, show a placeholder
if (allItems.length === 0) {
if (allItems.length === 0 && query && query.length >= 1) {
return [
{
id: 'loading-files',
Expand Down Expand Up @@ -678,7 +671,9 @@ export const App: React.FC = () => {
// Replace from trigger to cursor with selected value
const textBeforeCursor = text.substring(0, cursorPos);
const atPos = textBeforeCursor.lastIndexOf('@');
const slashPos = textBeforeCursor.lastIndexOf('/');
// Only consider slash as trigger if we're in slash command mode
const slashPos =
completion.triggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1;
const triggerPos = Math.max(atPos, slashPos);

if (triggerPos >= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { FileMessageHandler } from './FileMessageHandler.js';
import * as vscode from 'vscode';

const shouldIgnoreFileMock = vi.hoisted(() => vi.fn());
const fileSearchMock = vi.hoisted(() => ({
initialize: vi.fn(),
search: vi.fn(),
}));

const vscodeMock = vi.hoisted(() => {
class Uri {
fsPath: string;
Expand All @@ -20,6 +25,9 @@ const vscodeMock = vi.hoisted(() => {
static file(fsPath: string) {
return new Uri(fsPath);
}
static joinPath(base: Uri, ...pathSegments: string[]) {
return new Uri(`${base.fsPath}/${pathSegments.join('/')}`);
}
}

return {
Expand All @@ -28,7 +36,14 @@ const vscodeMock = vi.hoisted(() => {
findFiles: vi.fn(),
getWorkspaceFolder: vi.fn(),
asRelativePath: vi.fn(),
workspaceFolders: [],
workspaceFolders: [] as vscode.WorkspaceFolder[],
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(),
onDidDelete: vi.fn(),
onDidChange: vi.fn(),
dispose: vi.fn(),
})),
onDidChangeWorkspaceFolders: vi.fn(() => ({ dispose: vi.fn() })),
},
window: {
activeTextEditor: undefined,
Expand All @@ -50,20 +65,75 @@ vi.mock(
},
}),
);
vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js', () => ({
FileSearchFactory: {
create: () => fileSearchMock,
},
}));
vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js', () => ({
clear: vi.fn(),
}));

describe('FileMessageHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('filters ignored paths and includes request metadata in workspace files', async () => {
it('searches files using fuzzy search when query is provided', async () => {
const rootPath = '/workspace';

vscodeMock.workspace.workspaceFolders = [
{ uri: vscode.Uri.file(rootPath), name: 'workspace', index: 0 },
];

fileSearchMock.initialize.mockResolvedValue(undefined);
fileSearchMock.search.mockResolvedValue([
'src/test.txt',
'docs/readme.txt',
]);

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(fileSearchMock.search).toHaveBeenCalledWith('txt', {
maxResults: 50,
});

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

it('filters ignored paths in non-query mode', 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.workspaceFolders = [];
vscodeMock.workspace.findFiles.mockResolvedValue([allowedUri, ignoredUri]);
vscodeMock.workspace.getWorkspaceFolder.mockImplementation(() => ({
uri: vscode.Uri.file(rootPath),
Expand All @@ -86,21 +156,22 @@ describe('FileMessageHandler', () => {

await handler.handle({
type: 'getWorkspaceFiles',
data: { query: 'txt', requestId: 7 },
data: { requestId: 7 },
});

expect(vscodeMock.workspace.findFiles).toHaveBeenCalledWith(
'**/*[tT][xX][tT]*',
'**/*',
'**/{.git,node_modules}/**',
50,
20,
);
expect(shouldIgnoreFileMock).toHaveBeenCalledWith(ignoredPath, {
respectGitIgnore: true,
respectQwenIgnore: false,
});

expect(sendToWebView).toHaveBeenCalledTimes(1);
const payload = sendToWebView.mock.calls[0]?.[0] as {
const payload = sendToWebView.mock.calls[
sendToWebView.mock.calls.length - 1
]?.[0] as {
type: string;
data: {
files: Array<{ path: string }>;
Expand All @@ -111,8 +182,5 @@ describe('FileMessageHandler', () => {

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
} from '../../utils/editorGroupUtils.js';
import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js';
import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js';
import {
FileSearchFactory,
type FileSearch,
} from '@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js';
import * as crawlCache from '@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js';
import { getErrorMessage } from '../../utils/errorMessage.js';

/**
Expand All @@ -25,6 +30,9 @@ export class FileMessageHandler extends BaseMessageHandler {
string,
FileDiscoveryService
>();
private readonly fileSearchInstances = new Map<string, FileSearch>();
private readonly fileSearchInitializing = new Map<string, Promise<void>>();
private readonly fileWatchers = new Map<string, vscode.FileSystemWatcher>();
private readonly globSpecialChars = new Set([
'\\',
'*',
Expand All @@ -51,6 +59,122 @@ export class FileMessageHandler extends BaseMessageHandler {
].includes(messageType);
}

private async getOrCreateFileSearch(
rootPath: string,
): Promise<FileSearch | null> {
const existing = this.fileSearchInstances.get(rootPath);
if (existing) {
return existing;
}

const initializing = this.fileSearchInitializing.get(rootPath);
if (initializing) {
await initializing;
return this.fileSearchInstances.get(rootPath) ?? null;
}

const initPromise = (async () => {
const search = FileSearchFactory.create({
projectRoot: rootPath,
ignoreDirs: ['.git', 'node_modules'],
useGitignore: true,
useQwenignore: false,
cache: true,
cacheTtl: 30000,
enableRecursiveFileSearch: true,
enableFuzzySearch: true,
});
await search.initialize();
this.fileSearchInstances.set(rootPath, search);
})();

this.fileSearchInitializing.set(rootPath, initPromise);

try {
await initPromise;
return this.fileSearchInstances.get(rootPath) ?? null;
} catch (error) {
this.fileSearchInitializing.delete(rootPath);
console.error(
'[FileMessageHandler] Failed to initialize file search:',
error,
);
return null;
}
}

private clearFileSearchCache(rootPath: string): void {
this.fileSearchInstances.delete(rootPath);
this.fileSearchInitializing.delete(rootPath);
crawlCache.clear();
console.log(
'[FileMessageHandler] Cleared file search cache, trigger:',
rootPath,
);
}

private createWatcherForFolder(folder: vscode.WorkspaceFolder): void {
const rootPath = folder.uri.fsPath;

// Skip if watcher already exists for this folder
if (this.fileWatchers.has(rootPath)) {
return;
}

const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(folder, '**/*'),
);

const onFileAddOrDelete = () => this.clearFileSearchCache(rootPath);
watcher.onDidCreate(onFileAddOrDelete);
watcher.onDidDelete(onFileAddOrDelete);
// Note: onDidChange is not needed - file search is based on names, not content

this.fileWatchers.set(rootPath, watcher);
}

private disposeWatcherForFolder(rootPath: string): void {
const watcher = this.fileWatchers.get(rootPath);
if (watcher) {
watcher.dispose();
this.fileWatchers.delete(rootPath);
}
}

setupFileWatchers(): vscode.Disposable {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
for (const folder of workspaceFolders) {
this.createWatcherForFolder(folder);
}
}

const foldersChangeListener = vscode.workspace.onDidChangeWorkspaceFolders(
(e) => {
for (const folder of e.removed) {
const rootPath = folder.uri.fsPath;
this.clearFileSearchCache(rootPath);
this.disposeWatcherForFolder(rootPath);
}
for (const folder of e.added) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like the direction here with cache invalidation on workspace changes. I think there is one lifecycle gap to watch out for: when a new workspace folder is added, we invalidate its cache entry, but we do not register a new file watcher for that folder.

That means searches in the newly added root can build a FileSearch index once, but later file create/change/delete events in that root will not invalidate it, so results can become stale until the provider is recreated.

It may be worth creating and tracking a watcher for each newly added folder inside onDidChangeWorkspaceFolders, so the invalidation behavior stays consistent for roots added after initialization too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I should create a watcher for each folder. The ideal behavior would be:

Event Before After
Init with folders A, B Watchers for A, B Watchers for A, B
Add folder C Cache invalidated, no watcher Cache invalidated, watcher created for C
Remove folder B Cache invalidated, watcher leaks Cache invalidated, watcher disposed
Dispose all All disposed All disposed

const rootPath = folder.uri.fsPath;
this.clearFileSearchCache(rootPath);
this.createWatcherForFolder(folder);
}
},
);

return {
dispose: () => {
for (const watcher of this.fileWatchers.values()) {
watcher.dispose();
}
this.fileWatchers.clear();
foldersChangeListener.dispose();
},
};
}

async handle(message: { type: string; data?: unknown }): Promise<void> {
const data = message.data as Record<string, unknown> | undefined;

Expand Down Expand Up @@ -282,20 +406,43 @@ 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',
'[FileMessageHandler] Searching workspace files with fuzzy search for query',
query,
);
const uris = await vscode.workspace.findFiles(
includePattern,
'**/{.git,node_modules}/**',
50,
);

for (const uri of uris) {
addFile(uri);
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
for (const folder of workspaceFolders) {
const rootPath = folder.uri.fsPath;
const fileSearch = await this.getOrCreateFileSearch(rootPath);
if (!fileSearch) {
continue;
}

const relativePaths = await fileSearch.search(query, {
maxResults: 50,
});

for (let relativePath of relativePaths) {
const isDirectory = relativePath.endsWith('/');
if (isDirectory) {
relativePath = relativePath.slice(0, -1);
}
const absolutePath = vscode.Uri.joinPath(
folder.uri,
relativePath,
).fsPath;

files.push({
id: absolutePath,
label: relativePath,
description: relativePath,
path: absolutePath,
});
addedPaths.add(absolutePath);
}
}
}
} else {
// Non-query mode: respond quickly with currently active and open files
Expand Down
Loading
Loading