| 1 | // Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without |
| 2 | // asking for a full listing on a single click. |
| 3 | export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; |
| 4 | |
| 5 | // One counter for every caller: they all open the same panel, so the newest |
| 6 | // click wins regardless of which one started the lookup. |
| 7 | let latestLookupSequence = 0; |
| 8 | |
| 9 | /** Call the returned predicate when the search settles; false means a later click superseded it. */ |
| 10 | export function claimWorkspaceBasenameLookup(): () => boolean { |
| 11 | latestLookupSequence += 1; |
| 12 | const claimed = latestLookupSequence; |
| 13 | return () => claimed === latestLookupSequence; |
| 14 | } |
| 15 | |
| 16 | export interface WorkspaceEntryCandidate { |
| 17 | readonly path: string; |
| 18 | readonly kind: "file" | "directory"; |
| 19 | } |
| 20 | |
| 21 | function basenameOfPath(path: string): string { |
| 22 | const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); |
| 23 | return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; |
| 24 | } |
| 25 | |
| 26 | export function needsWorkspaceBasenameLookup(relativePath: string): boolean { |
| 27 | const trimmed = relativePath.trim(); |
| 28 | return ( |
| 29 | trimmed !== "." && |
| 30 | trimmed !== ".." && |
| 31 | trimmed.length > 0 && |
| 32 | !trimmed.includes("/") && |
| 33 | !trimmed.includes("\\") |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | export function pickWorkspaceBasenameMatch( |
| 38 | basename: string, |
| 39 | entries: ReadonlyArray<WorkspaceEntryCandidate>, |
| 40 | ): string | null { |
| 41 | const target = basename.trim(); |
| 42 | if (!target) return null; |
| 43 | const files = entries.filter((entry) => entry.kind === "file"); |
| 44 | const exact = files.find((entry) => basenameOfPath(entry.path) === target); |
| 45 | if (exact) return exact.path; |
| 46 | // Folded matching covers casing that drifted from disk, but `FOO.ts` against |
| 47 | // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing |
| 48 | // rather than opening whichever the index ranked first. |
| 49 | const folded = target.toLowerCase(); |
| 50 | const foldedMatches = files.filter( |
| 51 | (entry) => basenameOfPath(entry.path).toLowerCase() === folded, |
| 52 | ); |
| 53 | return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; |
| 54 | } |