右侧文件窗口的全局文件名搜索会深度优先遍历整个工作区,原先只跳过 .git。含大型 node_modules 的仓库会先把 10 万条访问预算耗尽,导致 docs/ 等后序目录里的真实文件搜不到。 搜索改为跳过依赖、包管理存储和常见构建缓存目录(不匹配也不下降), 并补回归测试钉住 node_modules / dist 内的命中不会泄漏。
174 lines
6.9 KiB
TypeScript
174 lines
6.9 KiB
TypeScript
/**
|
|
* fs-search: the host's recursive file-name search behind the editor side
|
|
* panel's search box. Matches are case-insensitive name substrings, reported
|
|
* RELATIVE to the root ('/'-separated); noise directories (`.git`,
|
|
* `node_modules`, build caches) are skipped, symlinked directories are
|
|
* never descended (cycle safety), and the maxMatches/maxVisited budgets
|
|
* stop a runaway walk with `truncated: true`.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { searchFiles } from '../src/fs-search.ts'
|
|
|
|
/**
|
|
* Symlink creation needs extra privileges on Windows; the symlink case skips
|
|
* there rather than fails (mirror of the fs-tree symlink spec).
|
|
*/
|
|
const canSymlink = (() => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
|
|
try {
|
|
symlinkSync('target', join(dir, 'link'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})()
|
|
|
|
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
|
|
function makeFixture(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
|
|
mkdirSync(join(dir, 'src'))
|
|
mkdirSync(join(dir, 'docs'))
|
|
mkdirSync(join(dir, '.git'))
|
|
mkdirSync(join(dir, '.git', 'objects'))
|
|
writeFileSync(join(dir, 'README.md'), 'readme')
|
|
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
|
|
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
|
|
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
|
|
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
|
|
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
|
|
return dir
|
|
}
|
|
|
|
describe('fs-search', () => {
|
|
it('matches name substrings and reports root-relative /-separated paths', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
const result = await searchFiles(dir, 'util')
|
|
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
|
// A multi-level match list is sorted and relative (never absolute).
|
|
const md = await searchFiles(dir, '.md')
|
|
expect(md.truncated).toBe(false)
|
|
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
|
|
for (const match of md.matches) {
|
|
expect(match.startsWith(dir)).toBe(false)
|
|
expect(match).not.toContain('\\')
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('matches case-insensitively on the entry name', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
|
|
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
|
|
// Directory names match too (the client can hint where matches live).
|
|
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('never descends into .git directories', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
|
|
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
|
|
expect((await searchFiles(dir, 'config')).matches).toEqual([])
|
|
expect((await searchFiles(dir, '.git')).matches).toEqual([])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('never descends into node_modules or other noise directories', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
mkdirSync(join(dir, 'node_modules', 'left-pad'), { recursive: true })
|
|
mkdirSync(join(dir, 'web', 'dist'), { recursive: true })
|
|
writeFileSync(join(dir, 'node_modules', 'left-pad', 'guide.md'), 'dep')
|
|
writeFileSync(join(dir, 'web', 'dist', 'bundle.js'), 'build')
|
|
writeFileSync(join(dir, 'web', 'app.ts'), 'src')
|
|
// A match hidden behind node_modules / dist must not appear; project
|
|
// files after those forests must still be reachable within budget.
|
|
expect((await searchFiles(dir, 'guide')).matches).toEqual(['docs/guide.md'])
|
|
expect((await searchFiles(dir, 'left-pad')).matches).toEqual([])
|
|
expect((await searchFiles(dir, 'bundle')).matches).toEqual([])
|
|
expect((await searchFiles(dir, 'app.ts')).matches).toEqual(['web/app.ts'])
|
|
expect((await searchFiles(dir, 'node_modules')).matches).toEqual([])
|
|
expect((await searchFiles(dir, 'dist')).matches).toEqual([])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('an empty (or whitespace) query matches nothing without walking', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
|
|
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
// A link back to the root would loop forever if descended; a link to
|
|
// src would duplicate its matches. Neither must be entered.
|
|
symlinkSync(dir, join(dir, 'loop'))
|
|
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
|
|
const result = await searchFiles(dir, 'util')
|
|
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('stops with truncated: true when the match budget is exceeded', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
|
|
try {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
writeFileSync(join(dir, `match-${index}.txt`), 'x')
|
|
}
|
|
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.matches.length).toBe(2)
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('stops with truncated: true when the visited budget is exceeded', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
|
|
try {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
writeFileSync(join(dir, `file-${index}.txt`), 'x')
|
|
}
|
|
// The walk visits more entries than the budget allows and gives up.
|
|
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.matches).toEqual([])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('an unreadable root yields no matches instead of throwing', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
const missing = join(dir, 'does-not-exist')
|
|
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|