Files
orca/src/shared/quick-open-path-search.ts
T
Neil fff8718c76 Improve Quick Open matching, file locations and recent history (#25371)
* Match Quick Open queries across path terms and identifier separators

* Honor ignored-file and symlink preferences on file inventory hosts

* Open pasted file locations and remember successful workspace file visits

* Verify bounded directory listing preferences across execution routes

* Preserve symlink preferences on legacy directory fallback

* Preserve bounded Quick Open terms and separator alternatives

* Revoke stale Quick Open selections and preserve literal file locations

* Validate recent Quick Open candidates independently on their host

* Use complete old-host inventory fixtures for Quick Open compatibility

* Keep Quick Open validation current across palette and workspace changes

* Route recent candidates through relay file listing dispatch

* Record Quick Open hook renders as test snapshots

* Localize Quick Open eligibility errors and search aliases

* Validate file inventory with bundled search and runtime preferences

* test: use directory junction fixtures on Windows

* fix: negotiate Quick Open policies with nested SSH hosts

* fix: refresh cached linked folders after target changes

* Retry stale expanded links after reads settle and targets recover

* Scope stale refresh request lifetime to the visible workspace

* test: seed linked-folder fixture before watcher startup

* Keep Quick Open responsive and compatible with older hosts

* Exercise directory discovery with unprivileged Windows junctions
2026-10-05 21:52:28 -07:00

321 lines
9.2 KiB
TypeScript

import { matchQuickOpenSeparatorAlternatives } from './quick-open-separator-match'
import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
import { compareFileNames } from './file-name-sort'
export const QUICK_OPEN_RESULT_LIMIT = 50
export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024
export const QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS = 256
export const QUICK_OPEN_SEARCH_VERSION = 3
export type QuickOpenIndexedFile = {
path: string
lowerPath: string
lowerFilename: string
inputIndex: number
}
export type QuickOpenSearchResult = {
path: string
score: number
}
export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexedFile[] {
return files.map((path, inputIndex) => prepareQuickOpenFile(path, inputIndex))
}
const identifierBoundaries = new WeakMap<QuickOpenIndexedFile, ReadonlySet<number>>()
const preparedQuickOpenFiles = new WeakMap<readonly string[], QuickOpenIndexedFile[]>()
export function getPreparedQuickOpenFiles(
files: readonly string[]
): readonly QuickOpenIndexedFile[] {
const cached = preparedQuickOpenFiles.get(files)
if (cached) {
return cached
}
const prepared = prepareQuickOpenFiles(files)
preparedQuickOpenFiles.set(files, prepared)
return prepared
}
export function isQuickOpenQueryTooLarge(
query: string,
maxBytes = QUICK_OPEN_QUERY_MAX_BYTES
): boolean {
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export function isQuickOpenRemoteQueryTooLarge(query: string): boolean {
return query.length > QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS || isQuickOpenQueryTooLarge(query)
}
export function rankQuickOpenFiles(
query: string,
files: readonly QuickOpenIndexedFile[],
limit = QUICK_OPEN_RESULT_LIMIT
): QuickOpenSearchResult[] {
if (limit <= 0 || isQuickOpenQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuickOpenQuery(query)
const results: QuickOpenRankedResult[] = []
for (const file of files) {
const score = scoreQuickOpenTerms(normalizedQuery, file)
if (score !== null) {
retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
}
}
return finalizeResults(results)
}
export class QuickOpenPathRanker {
private readonly normalizedQuery: readonly string[] | null
private readonly retained: QuickOpenRankedResult[] = []
private inputIndex = 0
private matchCount = 0
constructor(
query: string,
private readonly limit: number
) {
this.normalizedQuery =
limit <= 0 || isQuickOpenQueryTooLarge(query) ? null : normalizeQuickOpenQuery(query)
}
consider(path: string): void {
const file = prepareQuickOpenFile(path, this.inputIndex++)
if (this.normalizedQuery === null) {
return
}
const score = scoreQuickOpenTerms(this.normalizedQuery, file)
if (score === null) {
return
}
this.matchCount++
retainTopResult(
this.retained,
{ path: file.path, score, inputIndex: file.inputIndex },
this.limit
)
}
result(): { paths: string[]; totalCount: number } {
return {
paths: finalizeResults(this.retained).map((result) => result.path),
totalCount: this.matchCount
}
}
}
function normalizeQuickOpenQuery(query: string): readonly string[] {
const terms = [...new Set(query.trim().replace(/\\/g, '/').toLowerCase().split(/\s+/))]
.filter(Boolean)
.sort()
return terms
}
function scoreQuickOpenTerms(terms: readonly string[], file: QuickOpenIndexedFile): number | null {
let score = 0
for (const term of terms) {
let termScore = fuzzyMatchIndexedFile(term, file)
if (termScore === null && (term.includes('-') || term.includes('_'))) {
const filenameStart = file.lowerPath.lastIndexOf('/') + 1
const parentStart = file.lowerPath.lastIndexOf('/', filenameStart - 2) + 1
const fallback =
fuzzyMatchIndexedFile(term, file, true) ??
fuzzyMatchIndexedFile(term, file, true, filenameStart) ??
fuzzyMatchIndexedFile(term, file, true, parentStart) ??
(fuzzyMatchIndexedFile(term.replace(/[-_]/g, ''), file) === null
? null
: matchQuickOpenSeparatorAlternatives(
term,
file.lowerPath,
identifierBoundaries.get(file)
))
termScore = fallback === null ? null : fallback + 10
}
if (termScore === null) {
return null
}
score += termScore
}
return score
}
function prepareQuickOpenFile(path: string, inputIndex: number): QuickOpenIndexedFile {
const searchPath = path.replace(/\\/g, '/')
const lastSlash = searchPath.lastIndexOf('/')
const file = {
path,
lowerPath: searchPath.toLowerCase(),
lowerFilename: searchPath.slice(lastSlash + 1).toLowerCase(),
inputIndex
}
if (/[A-Z]/.test(searchPath)) {
const boundaries = new Set<number>()
let lowerOffset = 0
for (let index = 0; index < searchPath.length; index++) {
if (
index > 0 &&
/[A-Z]/.test(searchPath[index]) &&
(/[a-z0-9]/.test(searchPath[index - 1]) ||
(/[A-Z]/.test(searchPath[index - 1]) && /[a-z]/.test(searchPath[index + 1] ?? '')))
) {
boundaries.add(lowerOffset)
}
lowerOffset += searchPath[index].toLowerCase().length
}
if (boundaries.size > 0) {
identifierBoundaries.set(file, boundaries)
}
}
return file
}
function fuzzyMatchIndexedFile(
query: string,
file: QuickOpenIndexedFile,
equivalentSeparators = false,
searchStart = 0
): number | null {
let qi = 0
let score = 0
let lastMatchIdx = -1
while (qi < query.length) {
const next = lastMatchIdx === -1 ? searchStart : lastMatchIdx + 1
let ti = file.lowerPath[next] === query[qi] ? next : file.lowerPath.indexOf(query[qi], next + 1)
if (equivalentSeparators && (query[qi] === '-' || query[qi] === '_')) {
for (const separator of ['-', '_', ' ']) {
const alternate = file.lowerPath.indexOf(separator, next)
if (alternate !== -1 && (ti === -1 || alternate < ti)) {
ti = alternate
}
}
if (lastMatchIdx >= 0 && ti !== next && identifierBoundaries.get(file)?.has(next)) {
score += 2
qi++
continue
}
}
if (ti === -1) {
return null
}
const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1
score += gap
if (
ti > 0 &&
(file.lowerPath[ti - 1] === '/' ||
file.lowerPath[ti - 1] === '.' ||
file.lowerPath[ti - 1] === '-' ||
(equivalentSeparators && file.lowerPath[ti - 1] === '_'))
) {
score -= 5
}
lastMatchIdx = ti
qi++
}
if (qi < query.length) {
return null
}
if (
equivalentSeparators
? filenameContainsSeparatorVariant(file.lowerFilename, query) ||
file.lowerFilename.includes(query.replace(/[-_]/g, ''))
: file.lowerFilename.includes(query)
) {
score -= 100
}
return score
}
function filenameContainsSeparatorVariant(filename: string, query: string): boolean {
for (let start = 0; start <= filename.length - query.length; start++) {
let offset = 0
while (offset < query.length) {
const expected = query[offset]
const actual = filename[start + offset]
if (
expected !== actual &&
!(
(expected === '-' || expected === '_') &&
(actual === '-' || actual === '_' || actual === ' ')
)
) {
break
}
offset++
}
if (offset === query.length) {
return true
}
}
return false
}
type QuickOpenRankedResult = QuickOpenSearchResult & {
inputIndex: number
}
function retainTopResult(
heap: QuickOpenRankedResult[],
candidate: QuickOpenRankedResult,
limit: number
): void {
if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) {
return
}
if (heap.length < limit) {
heap.push(candidate)
siftResultUp(heap, heap.length - 1)
return
}
heap[0] = candidate
siftResultDown(heap)
}
function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void {
let index = startIndex
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2)
if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) {
return
}
;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]]
index = parentIndex
}
}
function siftResultDown(heap: QuickOpenRankedResult[]): void {
let index = 0
while (true) {
const leftIndex = index * 2 + 1
if (leftIndex >= heap.length) {
return
}
const rightIndex = leftIndex + 1
const worseChildIndex =
rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0
? rightIndex
: leftIndex
if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) {
return
}
;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]]
index = worseChildIndex
}
}
function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] {
return results
.sort(compareRankedResult)
.map(({ path, score }): QuickOpenSearchResult => ({ path, score }))
}
function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number {
return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex
}