diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 7488beb73..c5f416a83 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -64,7 +64,6 @@ import type { WorkspaceCapabilities, WorkspaceMemorySummary, FileEntry, - FileSearchResult, EnvironmentCheckResult, InstallerManifest, InstallerDownloadRequest, @@ -259,6 +258,7 @@ import { getReleaseByTag, } from './lib/github-release-service' import { watchAttachedDirectory, unwatchAttachedDirectory } from './lib/workspace-watcher' +import { searchWorkspaceFiles } from './lib/workspace-file-search' import { getFeishuConfig, saveFeishuConfig, @@ -3390,191 +3390,11 @@ export function registerIpcHandlers(): void { } ) - // 搜索工作区文件(用于 @ 引用,递归扫描,支持附加目录) + // 搜索工作区文件(用于 @ 引用,使用缓存索引避免输入期间重复扫描) ipcMain.handle( AGENT_IPC_CHANNELS.SEARCH_WORKSPACE_FILES, - async (_, rootPath: string, query: string, limit = 20, additionalPaths?: string[], sessionPaths?: string[]): Promise => { - const { readdirSync, statSync } = await import('node:fs') - const { resolve, relative, basename } = await import('node:path') - - const safeRoot = resolve(rootPath) - const ignoreDirs = new Set(['node_modules', '.git', 'dist', '.next', '__pycache__', '.venv', 'build', '.cache']) - const ignoreFiles = new Set(['.DS_Store', '.Spotlight-V100', '.Trashes', 'Thumbs.db', 'desktop.ini']) - const BROWSE_LIMIT_PER_GROUP = 2000 - const BROWSE_TOTAL_CAP = 3000 - - // 按来源分组收集文件 - type Entry = { name: string; path: string; type: 'file' | 'dir'; source: 'session' | 'workspace' } - const rootEntries: Entry[] = [] - const workspaceEntries: Entry[] = [] - - function scan( - dir: string, - depth: number, - baseRoot: string, - target: Entry[], - useAbsPath: boolean, - source: 'session' | 'workspace', - ): void { - if (depth > 10) return - try { - const items = readdirSync(dir, { withFileTypes: true }) - for (const item of items) { - if (ignoreFiles.has(item.name)) continue - if (item.isDirectory() && ignoreDirs.has(item.name)) continue - - const fullPath = resolve(dir, item.name) - const entryPath = useAbsPath ? fullPath : relative(baseRoot, fullPath) - target.push({ - name: item.name, - path: entryPath, - type: item.isDirectory() ? 'dir' : 'file', - source, - }) - - if (item.isDirectory()) { - scan(fullPath, depth + 1, baseRoot, target, useAbsPath, source) - } - } - } catch { - // 忽略无权限的目录 - } - } - - function addAttachedPath(pathValue: string, target: Entry[], source: 'session' | 'workspace'): void { - try { - const attachedPath = resolve(pathValue) - const name = basename(attachedPath) - if (ignoreFiles.has(name)) return - - const stats = statSync(attachedPath) - if (stats.isFile()) { - target.push({ - name, - path: attachedPath, - type: 'file', - source, - }) - return - } - - if (!stats.isDirectory()) return - if (ignoreDirs.has(name)) return - - target.push({ - name: name === 'workspace-files' ? '工作文件' : name, - path: attachedPath, - type: 'dir', - source, - }) - scan(attachedPath, 0, attachedPath, target, true, source) - } catch { - // 忽略不存在或无权限的附加路径 - } - } - - // session 目录:相对路径 - scan(safeRoot, 0, safeRoot, rootEntries, false, 'session') - - // 会话级附加路径:绝对路径,标记为 session(归入会话文件分组) - if (sessionPaths && sessionPaths.length > 0) { - for (const sp of sessionPaths) { - addAttachedPath(sp, rootEntries, 'session') - } - } - - // 工作区文件 + 工作区级附加路径:绝对路径,标记为 workspace - if (additionalPaths && additionalPaths.length > 0) { - for (const addPath of additionalPaths) { - addAttachedPath(addPath, workspaceEntries, 'workspace') - } - } - - // 组内排序:目录优先,前缀匹配优先,路径短优先 - function sortGroup(entries: Entry[], q: string): void { - entries.sort((a, b) => { - const aStartsWith = a.name.toLowerCase().startsWith(q) ? 0 : 1 - const bStartsWith = b.name.toLowerCase().startsWith(q) ? 0 : 1 - if (aStartsWith !== bStartsWith) return aStartsWith - bStartsWith - if (a.type === 'dir' && b.type !== 'dir') return -1 - if (a.type !== 'dir' && b.type === 'dir') return 1 - return a.path.length - b.path.length - }) - } - - function matchEntries(entries: Entry[], q: string): Entry[] { - return entries.filter((entry) => { - const nameLower = entry.name.toLowerCase() - const pathLower = entry.path.toLowerCase() - if (nameLower.startsWith(q)) return true - if (nameLower.includes(q) || pathLower.includes(q)) return true - let qi = 0 - for (let i = 0; i < nameLower.length && qi < q.length; i++) { - if (nameLower[i] === q[qi]) qi++ - } - return qi === q.length - }) - } - - // 目录优先排序:确保截断前所有目录(特别是顶层目录)排在前面 - function sortDirsFirst(entries: Entry[]): void { - entries.sort((a, b) => { - if (a.type === 'dir' && b.type !== 'dir') return -1 - if (a.type !== 'dir' && b.type === 'dir') return 1 - return a.path.length - b.path.length || a.name.localeCompare(b.name) - }) - } - - const q = query.toLowerCase() - - if (!q) { - // 空 query:目录优先排序后再截断,保证文件夹结构完整可见 - sortDirsFirst(rootEntries) - sortDirsFirst(workspaceEntries) - const maxPerGroup = Math.max(limit, BROWSE_LIMIT_PER_GROUP) - const sessionSlice = rootEntries.slice(0, maxPerGroup) - const workspaceSlice = workspaceEntries.slice(0, maxPerGroup) - const combined = [...sessionSlice, ...workspaceSlice] - const capped = combined.length > BROWSE_TOTAL_CAP ? combined.slice(0, BROWSE_TOTAL_CAP) : combined - return { - entries: capped, - total: rootEntries.length + workspaceEntries.length, - sessionEntries: sessionSlice, - workspaceEntries: workspaceSlice, - } - } - - const sessionMatched = matchEntries(rootEntries, q) - const workspaceMatched = matchEntries(workspaceEntries, q) - sortGroup(sessionMatched, q) - sortGroup(workspaceMatched, q) - - const totalMatched = sessionMatched.length + workspaceMatched.length - let sessionSlice: Entry[] - let workspaceSlice: Entry[] - if (totalMatched <= limit) { - sessionSlice = sessionMatched - workspaceSlice = workspaceMatched - } else { - const sessionQuota = Math.max( - sessionMatched.length > 0 ? 1 : 0, - Math.round(limit * sessionMatched.length / totalMatched), - ) - const workspaceQuota = Math.max( - workspaceMatched.length > 0 ? 1 : 0, - limit - sessionQuota, - ) - sessionSlice = sessionMatched.slice(0, sessionQuota) - workspaceSlice = workspaceMatched.slice(0, workspaceQuota) - } - - return { - entries: [...sessionSlice, ...workspaceSlice], - total: sessionMatched.length + workspaceMatched.length, - sessionEntries: sessionSlice, - workspaceEntries: workspaceSlice, - } - } + (_, rootPath: string, query: string, limit = 20, additionalPaths?: string[], sessionPaths?: string[]) => + searchWorkspaceFiles(rootPath, query, limit, additionalPaths, sessionPaths), ) // ===== 系统提示词管理 ===== diff --git a/apps/electron/src/main/lib/workspace-file-search.test.ts b/apps/electron/src/main/lib/workspace-file-search.test.ts new file mode 100644 index 000000000..537883de2 --- /dev/null +++ b/apps/electron/src/main/lib/workspace-file-search.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + clearWorkspaceFileSearchCacheForTest, + invalidateWorkspaceFileSearchCache, + searchWorkspaceFiles, +} from './workspace-file-search' + +const testRoots: string[] = [] + +function createRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'proma-file-mention-search-')) + testRoots.push(root) + return root +} + +afterEach(() => { + clearWorkspaceFileSearchCacheForTest() + for (const root of testRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('workspace file search', () => { + test('Given a nested matching file When searching Then returns its ancestor directories for tree rendering', async () => { + const root = createRoot() + mkdirSync(join(root, 'src', 'components'), { recursive: true }) + writeFileSync(join(root, 'src', 'components', 'MentionPicker.tsx'), '') + + const result = await searchWorkspaceFiles(root, 'picker') + + expect(result.entries.map((entry) => entry.path)).toEqual(['src/components/MentionPicker.tsx']) + expect(result.sessionEntries.map((entry) => entry.path)).toEqual([ + 'src', + 'src/components', + 'src/components/MentionPicker.tsx', + ]) + }) + + test('Given multiple matches When searching Then flat results retain relevance order without tree ancestors', async () => { + const root = createRoot() + mkdirSync(join(root, 'alpha'), { recursive: true }) + writeFileSync(join(root, 'z-target.md'), '') + writeFileSync(join(root, 'target-top.md'), '') + writeFileSync(join(root, 'alpha', 'target.md'), '') + + const result = await searchWorkspaceFiles(root, 'target') + + expect(result.entries.map((entry) => entry.path)).toEqual([ + 'target-top.md', + 'alpha/target.md', + 'z-target.md', + ]) + expect(result.sessionEntries.map((entry) => entry.path)).toContain('alpha') + }) + + test('Given an attached directory When a query only matches its absolute parent path Then it returns no unrelated files', async () => { + const root = createRoot() + const attachedDirectory = join(root, 'attached-project') + mkdirSync(attachedDirectory, { recursive: true }) + writeFileSync(join(attachedDirectory, 'unrelated.sql'), '') + + const result = await searchWorkspaceFiles(root, 'proma-file-mention-search', 80, [attachedDirectory]) + + expect(result.workspaceEntries).toEqual([]) + }) + + test('Given a cached index When files change and the watcher invalidates it Then the next search includes the new file', async () => { + const root = createRoot() + writeFileSync(join(root, 'existing.md'), '') + + await searchWorkspaceFiles(root, 'existing') + writeFileSync(join(root, 'new-file.md'), '') + + expect((await searchWorkspaceFiles(root, 'new-file')).sessionEntries).toEqual([]) + + invalidateWorkspaceFileSearchCache() + + expect((await searchWorkspaceFiles(root, 'new-file')).sessionEntries.map((entry) => entry.name)).toEqual([ + 'new-file.md', + ]) + }) +}) diff --git a/apps/electron/src/main/lib/workspace-file-search.ts b/apps/electron/src/main/lib/workspace-file-search.ts new file mode 100644 index 000000000..7ea72c60d --- /dev/null +++ b/apps/electron/src/main/lib/workspace-file-search.ts @@ -0,0 +1,334 @@ +import { readdir, stat } from 'node:fs/promises' +import { basename, dirname, relative, resolve } from 'node:path' +import type { FileIndexEntry, FileSearchResult } from '@proma/shared' + +const INDEX_CACHE_TTL_MS = 5_000 +const MAX_SCAN_DEPTH = 10 +const MAX_QUERY_RESULTS = 80 +const MAX_BROWSE_RESULTS = 120 +const MAX_CACHE_ENTRIES = 32 + +const IGNORE_DIRECTORIES = new Set([ + 'node_modules', '.git', 'dist', '.next', '__pycache__', '.venv', 'build', '.cache', +]) +const IGNORE_FILES = new Set([ + '.DS_Store', '.Spotlight-V100', '.Trashes', 'Thumbs.db', 'desktop.ini', +]) + +interface IndexedFileEntry extends FileIndexEntry { + parentPath: string | null + searchPath: string +} + +interface WorkspaceFileIndex { + sessionEntries: IndexedFileEntry[] + workspaceEntries: IndexedFileEntry[] +} + +interface CacheEntry { + expiresAt: number + index?: WorkspaceFileIndex + pending?: Promise +} + +const indexCache = new Map() +let cacheGeneration = 0 + +function normalizeSearchText(value: string): string { + return value.replace(/\\/g, '/').toLocaleLowerCase() +} + +function normalizedPaths(paths?: string[]): string[] { + return [...new Set((paths ?? []).map((pathValue) => resolve(pathValue)))].sort() +} + +function createCacheKey(rootPath: string, additionalPaths?: string[], sessionPaths?: string[]): string { + return JSON.stringify({ + rootPath: resolve(rootPath), + additionalPaths: normalizedPaths(additionalPaths), + sessionPaths: normalizedPaths(sessionPaths), + }) +} + +function getParentPath(entryPath: string): string | null { + const parentPath = dirname(entryPath) + return parentPath === '.' ? null : parentPath +} + +async function scanDirectory( + directoryPath: string, + depth: number, + baseRoot: string, + target: IndexedFileEntry[], + useAbsolutePath: boolean, + source: FileIndexEntry['source'], +): Promise { + if (depth > MAX_SCAN_DEPTH) return + + try { + const items = await readdir(directoryPath, { encoding: 'utf8', withFileTypes: true }) + for (const item of items) { + if (IGNORE_FILES.has(item.name)) continue + if (item.isDirectory() && IGNORE_DIRECTORIES.has(item.name)) continue + + const fullPath = resolve(directoryPath, item.name) + const entryPath = useAbsolutePath ? fullPath : relative(baseRoot, fullPath) + const relativeSearchPath = relative(baseRoot, fullPath) + target.push({ + name: item.name, + path: entryPath, + type: item.isDirectory() ? 'dir' : 'file', + source, + parentPath: getParentPath(entryPath), + // 绝对路径不参与搜索,避免输入盘符或用户目录片段时命中全部附加文件。 + searchPath: useAbsolutePath + ? `${basename(baseRoot)}/${relativeSearchPath}` + : relativeSearchPath, + }) + + if (item.isDirectory()) { + await scanDirectory(fullPath, depth + 1, baseRoot, target, useAbsolutePath, source) + } + } + } catch { + return + } +} + +async function addAttachedPath( + pathValue: string, + target: IndexedFileEntry[], + source: FileIndexEntry['source'], +): Promise { + const attachedPath = resolve(pathValue) + const name = basename(attachedPath) + if (IGNORE_FILES.has(name) || IGNORE_DIRECTORIES.has(name)) return + + let stats: Awaited> + try { + stats = await stat(attachedPath) + } catch { + return + } + + if (stats.isFile()) { + target.push({ + name, + path: attachedPath, + type: 'file', + source, + parentPath: null, + searchPath: name, + }) + return + } + if (!stats.isDirectory()) return + + target.push({ + name: name === 'workspace-files' ? '工作文件' : name, + path: attachedPath, + type: 'dir', + source, + parentPath: null, + searchPath: name, + }) + await scanDirectory(attachedPath, 0, attachedPath, target, true, source) +} + +async function buildIndex( + rootPath: string, + additionalPaths?: string[], + sessionPaths?: string[], +): Promise { + const sessionEntries: IndexedFileEntry[] = [] + const workspaceEntries: IndexedFileEntry[] = [] + const safeRoot = resolve(rootPath) + + await scanDirectory(safeRoot, 0, safeRoot, sessionEntries, false, 'session') + for (const pathValue of normalizedPaths(sessionPaths)) { + await addAttachedPath(pathValue, sessionEntries, 'session') + } + for (const pathValue of normalizedPaths(additionalPaths)) { + await addAttachedPath(pathValue, workspaceEntries, 'workspace') + } + + return { sessionEntries, workspaceEntries } +} + +function pruneIndexCache(now: number): void { + for (const [cacheKey, entry] of indexCache) { + if (!entry.pending && entry.expiresAt <= now) indexCache.delete(cacheKey) + } + while (indexCache.size >= MAX_CACHE_ENTRIES) { + const oldestKey = indexCache.keys().next().value + if (!oldestKey) return + indexCache.delete(oldestKey) + } +} + +async function getIndex( + rootPath: string, + additionalPaths?: string[], + sessionPaths?: string[], +): Promise { + const cacheKey = createCacheKey(rootPath, additionalPaths, sessionPaths) + const now = Date.now() + pruneIndexCache(now) + + const cached = indexCache.get(cacheKey) + if (cached?.index && cached.expiresAt > now) return cached.index + if (cached?.pending) return cached.pending + + const generation = cacheGeneration + const entry: CacheEntry = { expiresAt: now + INDEX_CACHE_TTL_MS } + const pending = buildIndex(rootPath, additionalPaths, sessionPaths) + entry.pending = pending + indexCache.set(cacheKey, entry) + + try { + const index = await pending + // 失效前启动的扫描只能服务于当前请求,不能回填已经失效的缓存。 + if (generation === cacheGeneration && indexCache.get(cacheKey) === entry) { + indexCache.set(cacheKey, { expiresAt: Date.now() + INDEX_CACHE_TTL_MS, index }) + } + return index + } catch (error) { + if (indexCache.get(cacheKey) === entry) indexCache.delete(cacheKey) + throw error + } +} + +function fuzzyMatches(value: string, query: string): boolean { + let queryIndex = 0 + for (let index = 0; index < value.length && queryIndex < query.length; index++) { + if (value[index] === query[queryIndex]) queryIndex++ + } + return queryIndex === query.length +} + +function matchScore(entry: IndexedFileEntry, query: string): number | null { + const name = normalizeSearchText(entry.name) + const searchPath = normalizeSearchText(entry.searchPath) + if (name.startsWith(query)) return 0 + if (searchPath.startsWith(query)) return 1 + if (name.includes(query)) return 2 + if (searchPath.includes(query)) return 3 + return fuzzyMatches(name, query) ? 4 : null +} + +function pathDepth(pathValue: string): number { + return normalizeSearchText(pathValue).split('/').filter(Boolean).length +} + +function sortBrowseEntries(entries: IndexedFileEntry[]): IndexedFileEntry[] { + return [...entries].sort((a, b) => { + const depthDifference = pathDepth(a.path) - pathDepth(b.path) + if (depthDifference !== 0) return depthDifference + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) +} + +function toPublicEntry(entry: IndexedFileEntry): FileIndexEntry { + const { parentPath: _parentPath, searchPath: _searchPath, ...publicEntry } = entry + return publicEntry +} + +function includeAncestorEntries( + entries: IndexedFileEntry[], + selectedEntries: IndexedFileEntry[], +): FileIndexEntry[] { + const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])) + const includedPaths = new Set() + + for (const entry of selectedEntries) { + let current: IndexedFileEntry | undefined = entry + while (current) { + if (includedPaths.has(current.path)) break + includedPaths.add(current.path) + current = current.parentPath ? entriesByPath.get(current.parentPath) : undefined + } + } + + return entries + .filter((entry) => includedPaths.has(entry.path)) + .map(toPublicEntry) +} + +interface SearchSelection { + matchedEntries: FileIndexEntry[] + treeEntries: FileIndexEntry[] + total: number +} + +function selectEntries( + entries: IndexedFileEntry[], + query: string, + requestedLimit: number, +): SearchSelection { + const resultLimit = Math.min( + Math.max(1, requestedLimit), + query ? MAX_QUERY_RESULTS : MAX_BROWSE_RESULTS, + ) + + if (!query) { + const selectedEntries = sortBrowseEntries(entries).slice(0, resultLimit) + return { + matchedEntries: selectedEntries.map(toPublicEntry), + treeEntries: includeAncestorEntries(entries, selectedEntries), + total: entries.length, + } + } + + const matchedEntries = entries + .map((entry) => ({ entry, score: matchScore(entry, query) })) + .filter((candidate): candidate is { entry: IndexedFileEntry; score: number } => candidate.score !== null) + .sort((a, b) => { + if (a.score !== b.score) return a.score - b.score + if (a.entry.type !== b.entry.type) return a.entry.type === 'dir' ? -1 : 1 + const depthDifference = pathDepth(a.entry.path) - pathDepth(b.entry.path) + if (depthDifference !== 0) return depthDifference + return a.entry.searchPath.localeCompare(b.entry.searchPath) + }) + + const selectedEntries = matchedEntries.slice(0, resultLimit).map(({ entry }) => entry) + return { + matchedEntries: selectedEntries.map(toPublicEntry), + treeEntries: includeAncestorEntries(entries, selectedEntries), + total: matchedEntries.length, + } +} + +export async function searchWorkspaceFiles( + rootPath: string, + query: string, + limit = MAX_QUERY_RESULTS, + additionalPaths?: string[], + sessionPaths?: string[], +): Promise { + const index = await getIndex(rootPath, additionalPaths, sessionPaths) + const normalizedQuery = normalizeSearchText(query.trim()) + const session = selectEntries(index.sessionEntries, normalizedQuery, limit) + const workspace = selectEntries(index.workspaceEntries, normalizedQuery, limit) + + return { + // 平铺搜索消费者(侧栏)只接收按相关性排序的真实匹配项。 + entries: [...session.matchedEntries, ...workspace.matchedEntries], + total: session.total + workspace.total, + // @ 文件引用需要祖先目录来构建可展开的层级树。 + sessionEntries: session.treeEntries, + workspaceEntries: workspace.treeEntries, + } +} + +/** 文件 watcher 在防抖后的变更通知中调用。 */ +export function invalidateWorkspaceFileSearchCache(): void { + cacheGeneration++ + indexCache.clear() +} + +/** 仅供测试重置缓存。 */ +export function clearWorkspaceFileSearchCacheForTest(): void { + cacheGeneration++ + indexCache.clear() +} diff --git a/apps/electron/src/main/lib/workspace-watcher.ts b/apps/electron/src/main/lib/workspace-watcher.ts index ea76174af..81d0f9f6e 100644 --- a/apps/electron/src/main/lib/workspace-watcher.ts +++ b/apps/electron/src/main/lib/workspace-watcher.ts @@ -16,6 +16,7 @@ import type { FSWatcher } from 'node:fs' import type { BrowserWindow } from 'electron' import { AGENT_IPC_CHANNELS } from '@proma/shared' import { getAgentWorkspacesDir } from './config-paths' +import { invalidateWorkspaceFileSearchCache } from './workspace-file-search' /** debounce 延迟(ms) */ const DEBOUNCE_MS = 300 @@ -94,6 +95,7 @@ export function startWorkspaceWatcher(win: BrowserWindow): void { // 其他文件变化 → 通知文件浏览器刷新 if (filesTimer) clearTimeout(filesTimer) filesTimer = setTimeout(() => { + invalidateWorkspaceFileSearchCache() if (!win.isDestroyed()) { win.webContents.send(AGENT_IPC_CHANNELS.WORKSPACE_FILES_CHANGED) } @@ -155,6 +157,7 @@ export function watchAttachedDirectory(dirPath: string): void { // 统一防抖:所有附加目录变化合并为一次刷新 if (attachedFilesTimer) clearTimeout(attachedFilesTimer) attachedFilesTimer = setTimeout(() => { + invalidateWorkspaceFileSearchCache() if (mainWin && !mainWin.isDestroyed()) { mainWin.webContents.send(AGENT_IPC_CHANNELS.WORKSPACE_FILES_CHANGED) } diff --git a/apps/electron/src/renderer/components/file-browser/FileMentionList.test.ts b/apps/electron/src/renderer/components/file-browser/FileMentionList.test.ts new file mode 100644 index 000000000..391b60c11 --- /dev/null +++ b/apps/electron/src/renderer/components/file-browser/FileMentionList.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { buildFileMentionTree, findPreferredMatchIndex } from './FileMentionList' + +describe('FileMentionList tree builder', () => { + test('Given Windows paths When constructing the tree Then links nested files to their directory', () => { + const tree = buildFileMentionTree([ + { name: 'src', path: 'D:\\project\\src', type: 'dir', source: 'workspace' }, + { name: 'index.ts', path: 'D:\\project\\src\\index.ts', type: 'file', source: 'workspace' }, + ]) + + expect(tree).toHaveLength(1) + expect(tree[0]?.name).toBe('src') + expect(tree[0]?.children.map((node) => node.name)).toEqual(['index.ts']) + }) + + test('Given alphabetically ordered nodes When choosing the default Then preserves search relevance order', () => { + const nodes = buildFileMentionTree([ + { name: 'alpha.md', path: 'alpha.md', type: 'file', source: 'session' }, + { name: 'target.md', path: 'target.md', type: 'file', source: 'session' }, + ]) + + expect(findPreferredMatchIndex(nodes, ['target.md', 'alpha.md'])).toBe(1) + }) +}) diff --git a/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx b/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx index fa3c3fdaa..a3f5d19af 100644 --- a/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx +++ b/apps/electron/src/renderer/components/file-browser/FileMentionList.tsx @@ -49,7 +49,7 @@ class MentionErrorBoundary extends React.Component< // ===== 树形结构类型 ===== -interface FileTreeNode { +export interface FileTreeNode { name: string path: string type: 'file' | 'dir' @@ -64,6 +64,9 @@ interface FileTreeNode { export interface FileMentionListProps { sessionEntries: FileIndexEntry[] workspaceEntries: FileIndexEntry[] + sessionMatchedPaths?: string[] + workspaceMatchedPaths?: string[] + hasQuery?: boolean onSelect: (item: { name: string; path: string; type: 'file' | 'dir' }) => void } @@ -74,12 +77,16 @@ export interface FileMentionRef { // ===== 工具函数 ===== /** 从扁平条目列表构建树 */ -function buildTree(entries: FileIndexEntry[]): FileTreeNode[] { +function toTreePath(pathValue: string): string { + return pathValue.replace(/\\/g, '/') +} + +export function buildFileMentionTree(entries: FileIndexEntry[]): FileTreeNode[] { const pathMap = new Map() const roots: FileTreeNode[] = [] for (const entry of entries) { - pathMap.set(entry.path, { + pathMap.set(toTreePath(entry.path), { name: entry.name, path: entry.path, type: entry.type, @@ -91,9 +98,10 @@ function buildTree(entries: FileIndexEntry[]): FileTreeNode[] { } for (const entry of entries) { - const node = pathMap.get(entry.path)! - const lastSlash = entry.path.lastIndexOf('/') - const parentPath = lastSlash === -1 ? '' : entry.path.slice(0, lastSlash) + const treePath = toTreePath(entry.path) + const node = pathMap.get(treePath)! + const lastSlash = treePath.lastIndexOf('/') + const parentPath = lastSlash === -1 ? '' : treePath.slice(0, lastSlash) if (parentPath && pathMap.has(parentPath)) { pathMap.get(parentPath)!.children.push(node) @@ -140,22 +148,66 @@ function flattenVisible(nodes: FileTreeNode[]): FileTreeNode[] { return result } +function getAutoExpandedPaths(nodes: FileTreeNode[], matchedPaths: Set): Set { + const autoExpandedPaths = new Set() + + function visit(node: FileTreeNode): boolean { + const hasMatchingChild = node.children.some(visit) + const isMatch = matchedPaths.has(node.path) + if (hasMatchingChild && node.type === 'dir') autoExpandedPaths.add(node.path) + return isMatch || hasMatchingChild + } + + for (const node of nodes) visit(node) + return autoExpandedPaths +} + +export function findPreferredMatchIndex(nodes: FileTreeNode[], matchedPaths: string[]): number { + for (const matchedPath of matchedPaths) { + const index = nodes.findIndex((node) => node.path === matchedPath) + if (index >= 0) return index + } + return -1 +} + // ===== 组件 ===== export const FileMentionList = React.forwardRef( - function FileMentionList({ sessionEntries, workspaceEntries, onSelect }, ref) { + function FileMentionList({ + sessionEntries, + workspaceEntries, + sessionMatchedPaths = [], + workspaceMatchedPaths = [], + hasQuery = true, + onSelect, + }, ref) { // 构建树(仅在条目变化时重建) const sessionTree = React.useMemo( - () => buildTree(sessionEntries), + () => buildFileMentionTree(sessionEntries), [sessionEntries], ) const workspaceTree = React.useMemo( - () => buildTree(workspaceEntries), + () => buildFileMentionTree(workspaceEntries), [workspaceEntries], ) // 折叠/展开状态(用 expandedPaths Set 管理) const [expandedPaths, setExpandedPaths] = React.useState>(new Set()) + const sessionMatchedPathSet = React.useMemo(() => new Set(sessionMatchedPaths), [sessionMatchedPaths]) + const workspaceMatchedPathSet = React.useMemo(() => new Set(workspaceMatchedPaths), [workspaceMatchedPaths]) + const sessionAutoExpandedPaths = React.useMemo( + () => getAutoExpandedPaths(sessionTree, sessionMatchedPathSet), + [sessionTree, sessionMatchedPathSet], + ) + const workspaceAutoExpandedPaths = React.useMemo( + () => getAutoExpandedPaths(workspaceTree, workspaceMatchedPathSet), + [workspaceTree, workspaceMatchedPathSet], + ) + + // 搜索结果更新时自动展开命中项的祖先,避免 Enter 默认引用到补齐的目录节点。 + React.useEffect(() => { + setExpandedPaths(new Set([...sessionAutoExpandedPaths, ...workspaceAutoExpandedPaths])) + }, [sessionAutoExpandedPaths, workspaceAutoExpandedPaths]) // 将 expanded 状态注入树节点 const sessionTreeWithState = React.useMemo(() => { @@ -195,10 +247,20 @@ export const FileMentionList = React.forwardRef(null) - // 条目变化或展开状态变化时重置/修正索引 + // 条目变化或展开状态变化时默认选中最相关的匹配项,而不是为树补齐的父目录。 React.useEffect(() => { + const sessionMatchIndex = findPreferredMatchIndex(sessionVisible, sessionMatchedPaths) + if (sessionMatchIndex >= 0) { + setSelectedIndex(sessionMatchIndex) + return + } + const workspaceMatchIndex = findPreferredMatchIndex(workspaceVisible, workspaceMatchedPaths) + if (workspaceMatchIndex >= 0) { + setSelectedIndex(sessionVisible.length + workspaceMatchIndex) + return + } setSelectedIndex((prev) => (totalItems > 0 ? Math.min(prev, totalItems - 1) : 0)) - }, [sessionEntries, workspaceEntries, totalItems]) + }, [sessionEntries, workspaceEntries, sessionVisible, workspaceVisible, sessionMatchedPaths, workspaceMatchedPaths, totalItems]) // 滚动选中项到可见区域 React.useEffect(() => { @@ -306,7 +368,7 @@ export const FileMentionList = React.forwardRef文件 Esc 关闭 · Enter 选中 -
无匹配文件
+
{hasQuery ? '无匹配文件' : '输入文件名或路径搜索'}
) } diff --git a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx index 74e7b65d1..5f92afe51 100644 --- a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx +++ b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx @@ -43,6 +43,11 @@ export function createFileMentionSuggestion( return [] } missingWorkspaceToastShown = false + const normalizedQuery = query?.trim() ?? '' + if (!normalizedQuery) { + lastResult = null + return [] + } try { const additionalPaths = attachedDirsRef?.current ?? [] @@ -50,8 +55,8 @@ export function createFileMentionSuggestion( const result = await window.electronAPI.searchWorkspaceFiles( wsPath, - query ?? '', - 200, + normalizedQuery, + 80, additionalPaths.length > 0 ? additionalPaths : undefined, sessionPaths.length > 0 ? sessionPaths : undefined, ) @@ -73,18 +78,24 @@ export function createFileMentionSuggestion( let editorRef: SuggestionProps['editor'] | null = null function splitEntries(result: FileSearchResult | null) { + const matchedEntries = result?.entries ?? [] return { sessionEntries: result?.sessionEntries ?? [], workspaceEntries: result?.workspaceEntries ?? [], + sessionMatchedPaths: matchedEntries.filter((entry) => entry.source === 'session').map((entry) => entry.path), + workspaceMatchedPaths: matchedEntries.filter((entry) => entry.source === 'workspace').map((entry) => entry.path), } } function createRenderer(props: SuggestionProps) { - const { sessionEntries, workspaceEntries } = splitEntries(lastResult) + const { sessionEntries, workspaceEntries, sessionMatchedPaths, workspaceMatchedPaths } = splitEntries(lastResult) renderer = new ReactRenderer(FileMentionList, { props: { sessionEntries, workspaceEntries, + sessionMatchedPaths, + workspaceMatchedPaths, + hasQuery: Boolean(props.query?.trim()), onSelect: (item: { name: string; path: string; type: 'file' | 'dir' }) => { props.command({ id: item.path, label: item.name }) }, @@ -164,10 +175,13 @@ export function createFileMentionSuggestion( if (mentionItemCountRef) mentionItemCountRef.current = props.items.length latestClientRect = props.clientRect - const { sessionEntries, workspaceEntries } = splitEntries(lastResult) + const { sessionEntries, workspaceEntries, sessionMatchedPaths, workspaceMatchedPaths } = splitEntries(lastResult) renderer?.updateProps({ sessionEntries, workspaceEntries, + sessionMatchedPaths, + workspaceMatchedPaths, + hasQuery: Boolean(props.query?.trim()), onSelect: (item: { name: string; path: string; type: 'file' | 'dir' }) => { props.command({ id: item.path, label: item.name }) },