1import {
2 isAbsolutePath,
3 isWindowsAbsolutePath,
4 resolveWorkspaceFilePath,
5} from "@t3tools/shared/path";
6
7export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25;
8
9// All callers open one panel; the newest click must win across callers.
10let latestLookupSequence = 0;
11
12export function claimWorkspaceBasenameLookup(): () => boolean {
13 latestLookupSequence += 1;
14 const claimed = latestLookupSequence;
15 return () => claimed === latestLookupSequence;
16}
17
18export interface WorkspaceEntryCandidate {
19 readonly path: string;
20 readonly kind: "file" | "directory";
21}
22
23function basenameOfPath(path: string): string {
24 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
25 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
26}
27
28export function needsWorkspaceBasenameLookup(relativePath: string): boolean {
29 const trimmed = relativePath.trim();
30 return (
31 trimmed !== "." &&
32 trimmed !== ".." &&
33 trimmed.length > 0 &&
34 !trimmed.includes("/") &&
35 !trimmed.includes("\\")
36 );
37}
38
39export function pickWorkspaceBasenameMatch(
40 basename: string,
41 entries: ReadonlyArray<WorkspaceEntryCandidate>,
42): string | null {
43 const target = basename.trim();
44 if (!target) return null;
45 const files = entries.filter((entry) => entry.kind === "file");
46 const exact = files.find((entry) => basenameOfPath(entry.path) === target);
47 if (exact) return exact.path;
48 const folded = target.toLowerCase();
49 const foldedMatches = files.filter(
50 (entry) => basenameOfPath(entry.path).toLowerCase() === folded,
51 );
52 return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null;
53}
54
55// Repository-relative paths can repeat the project's suffix in subfolder projects.
56function stripRepeatedWorkspacePrefix(relativePath: string, cwd: string): string | null {
57 if (isAbsolutePath(relativePath)) return null;
58 const pathSegments = relativePath.split(/[\\/]+/);
59 const cwdSegments = cwd.split(/[\\/]+/).filter(Boolean);
60 const fold = isWindowsAbsolutePath(cwd)
61 ? (segment: string | undefined) => segment?.toLowerCase()
62 : (segment: string | undefined) => segment;
63 for (let count = Math.min(cwdSegments.length, pathSegments.length - 1); count > 0; count--) {
64 const prefix = cwdSegments.slice(-count);
65 if (prefix.every((segment, index) => fold(segment) === fold(pathSegments[index]))) {
66 return pathSegments.slice(count).join("/") || null;
67 }
68 }
69 return null;
70}
71
72/**
73 * The project search query that may find a chat file link's real file, or null when the link
74 * names its file exactly. Agents often cite paths relative to a package (`cloud/http.ts`).
75 */
76export function workspaceLinkLookupQuery(relativePath: string, cwd: string): string | null {
77 const trimmed = relativePath.trim();
78 if (isAbsolutePath(trimmed) || /^(?:\.{1,2}|~)[\\/]/.test(trimmed) || /[\\/]$/.test(trimmed)) {
79 return null;
80 }
81 if (needsWorkspaceBasenameLookup(trimmed)) return trimmed;
82 return stripRepeatedWorkspacePrefix(trimmed, cwd) ?? trimmed;
83}
84
85/** The one file a chat link names: its basename, its path past a repeated prefix, or its suffix. */
86export function pickWorkspaceLinkMatch(
87 relativePath: string,
88 cwd: string,
89 entries: ReadonlyArray<WorkspaceEntryCandidate>,
90): string | null {
91 const trimmed = relativePath.trim();
92 if (needsWorkspaceBasenameLookup(trimmed)) return pickWorkspaceBasenameMatch(trimmed, entries);
93 const stripped = stripRepeatedWorkspacePrefix(trimmed, cwd);
94 const strippedMatch =
95 stripped === null ? null : pickStrippedWorkspaceMatch(stripped, cwd, entries);
96 if (strippedMatch !== null) return strippedMatch;
97 const suffix = trimmed.replaceAll("\\", "/");
98 const matches = entries.filter(
99 (entry) =>
100 entry.kind === "file" &&
101 (entry.path.replaceAll("\\", "/") === suffix ||
102 entry.path.replaceAll("\\", "/").endsWith(`/${suffix}`)),
103 );
104 return matches.length === 1 ? (matches[0]?.path ?? null) : null;
105}
106
107function pickStrippedWorkspaceMatch(
108 strippedPath: string,
109 cwd: string,
110 entries: ReadonlyArray<WorkspaceEntryCandidate>,
111): string | null {
112 const caseInsensitive = isWindowsAbsolutePath(cwd);
113 const key = (path: string) => {
114 const normalized = path.replaceAll("\\", "/");
115 return caseInsensitive ? normalized.toLowerCase() : normalized;
116 };
117 const files = entries.filter((entry) => entry.kind === "file");
118 return files.find((entry) => key(entry.path) === key(strippedPath))?.path ?? null;
119}
120
121/**
122 * The absolute path of the one file a chat link names across other project roots, or null when
123 * no root or more than one has it. Agents cite paths relative to whichever repo they worked in.
124 */
125export function pickProjectLinkMatch(
126 relativePath: string,
127 searches: ReadonlyArray<{
128 readonly root: string;
129 readonly entries: ReadonlyArray<WorkspaceEntryCandidate>;
130 }>,
131): string | null {
132 const matches = searches.flatMap(({ root, entries }) => {
133 const match = pickWorkspaceLinkMatch(relativePath, root, entries);
134 return match === null ? [] : [resolveWorkspaceFilePath(match, root)];
135 });
136 return matches.length === 1 ? (matches[0] ?? null) : null;
137}