History

fix(web): open the file a bare filename reference names (#6297)

Rodrigo Brechard committed 2 months ago

Commit 2cb1a261 parent b277cc63 files · +192 −2Browse files

Unified · Split

3 files
  1. apps/web/src/components/ChatMarkdown.tsx +51 −2
  2. apps/web/src/workspaceBasenameLookup.test.ts +93 −0
  3. apps/web/src/workspaceBasenameLookup.ts +48 −0
apps/web/src/components/ChatMarkdown.tsx+51−2
@@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; · 89 unmodified lines
9090 import { previewEnvironment } from "../state/preview";
9191 import { useAtomCommand } from "../state/use-atom-command";
9292 import { useAtomQueryRunner } from "../state/use-atom-query-runner";
93+import { projectEnvironment } from "../state/projects";
94+import {
95+ claimWorkspaceBasenameLookup,
96+ needsWorkspaceBasenameLookup,
97+ pickWorkspaceBasenameMatch,
98+ WORKSPACE_BASENAME_LOOKUP_LIMIT,
99+} from "../workspaceBasenameLookup";
93100 import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
94101 import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
95102 import { isPreviewSupportedInRuntime } from "../previewStateStore";
@@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { · 715 unmodified lines
811818 theme: "light" | "dark";
812819 threadRef?: ScopedThreadRef | undefined;
813820 onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
821+ onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
814822 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
815823 className?: string | undefined;
816824 }
@@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 299 unmodified lines
11161124 theme,
11171125 threadRef,
11181126 onOpen,
1127+ onOpenInPanel,
11191128 onOpenInBrowser,
11201129 className,
11211130 }: MarkdownFileLinkProps) {
@@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ · 37 unmodified lines
11591168 handleOpenInEditor();
11601169 return;
11611170 }
1162− useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line);
1163− }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]);
1171+ onOpenInPanel(workspaceRelativePath, line);
1172+ }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]);
11641173
11651174 const handleOpenInBrowser = useCallback(() => {
11661175 if (!onOpenInBrowser) {
@@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( · 169 unmodified lines
13361345 previous.theme === next.theme &&
13371346 previous.threadRef === next.threadRef &&
13381347 previous.onOpen === next.onOpen &&
1348+ previous.onOpenInPanel === next.onOpenInPanel &&
13391349 previous.onOpenInBrowser === next.onOpenInBrowser &&
13401350 previous.className === next.className
13411351 );
@@ -1355,6 +1365,9 @@ function ChatMarkdown({ · 13 unmodified lines
13551365 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
13561366 reportFailure: false,
13571367 });
1368+ const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
1369+ reportFailure: false,
1370+ });
13581371 const openPreview = useAtomCommand(previewEnvironment.open, {
13591372 reportFailure: false,
13601373 });
@@ -1457,6 +1470,40 @@ function ChatMarkdown({ · 96 unmodified lines
14571470 },
14581471 [createAssetUrl, openPreview, preparedConnection, threadRef],
14591472 );
1473+ // A bare filename resolves to the workspace root, which is rarely where the
1474+ // file is, so ask the index before opening.
1475+ const openFileInPanel = useCallback(
1476+ (workspaceRelativePath: string, line: number | undefined) => {
1477+ if (!threadRef) return;
1478+ // Claimed on every open so a synchronous one supersedes a lookup already
1479+ // in flight.
1480+ const isLatestLookup = claimWorkspaceBasenameLookup();
1481+ const openAt = (path: string) =>
1482+ useRightPanelStore.getState().openFile(threadRef, path, line);
1483+ if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
1484+ openAt(workspaceRelativePath);
1485+ return;
1486+ }
1487+ void (async () => {
1488+ const result = await searchProjectEntries({
1489+ environmentId: threadRef.environmentId,
1490+ input: {
1491+ cwd,
1492+ query: workspaceRelativePath,
1493+ limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
1494+ kind: "file",
1495+ },
1496+ });
1497+ const match =
1498+ result._tag === "Success"
1499+ ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
1500+ : null;
1501+ if (!isLatestLookup()) return;
1502+ openAt(match ?? workspaceRelativePath);
1503+ })();
1504+ },
1505+ [cwd, searchProjectEntries, threadRef],
1506+ );
14601507 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
14611508 * renderers that close over this message's metadata. useMemo keeps them stable until that
14621509 * metadata changes. */
@@ -1490,6 +1537,7 @@ function ChatMarkdown({ · 27 unmodified lines
14901537 theme={resolvedTheme}
14911538 threadRef={threadRef}
14921539 onOpen={openInPreferredEditor}
1540+ onOpenInPanel={openFileInPanel}
14931541 onOpenInBrowser={
14941542 threadRef &&
14951543 isPreviewSupportedInRuntime() &&
@@ -1718,6 +1766,7 @@ function ChatMarkdown({ · 222 unmodified lines
17181766 isStreaming,
17191767 markdownFileLinkMetaByHref,
17201768 onTaskListChange,
1769+ openFileInPanel,
17211770 openInPreferredEditor,
17221771 openExternalLinkInPreview,
17231772 openMarkdownFileInPreview,
apps/web/src/workspaceBasenameLookup.test.ts+93−0
@@ -0,0 +1,93 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ claimWorkspaceBasenameLookup,
5+ needsWorkspaceBasenameLookup,
6+ pickWorkspaceBasenameMatch,
7+} from "./workspaceBasenameLookup";
8+
9+describe("needsWorkspaceBasenameLookup", () => {
10+ it("flags bare filenames", () => {
11+ expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true);
12+ expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true);
13+ });
14+
15+ it("leaves anything with a directory alone", () => {
16+ expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);
17+ expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);
18+ expect(needsWorkspaceBasenameLookup(" ")).toBe(false);
19+ });
20+});
21+
22+describe("pickWorkspaceBasenameMatch", () => {
23+ const entries = [
24+ { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const },
25+ { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const },
26+ ];
27+
28+ it("takes the first exact filename match, not the closest fuzzy one", () => {
29+ expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe(
30+ "apps/web/src/components/ChatView.tsx",
31+ );
32+ });
33+
34+ it("ignores directories", () => {
35+ expect(
36+ pickWorkspaceBasenameMatch("components", [
37+ { path: "apps/web/src/components", kind: "directory" },
38+ { path: "apps/web/src/components/components", kind: "file" },
39+ ]),
40+ ).toBe("apps/web/src/components/components");
41+ });
42+
43+ it("prefers the exactly-cased file over a case-only twin", () => {
44+ expect(
45+ pickWorkspaceBasenameMatch("foo.ts", [
46+ { path: "src/Foo.ts", kind: "file" },
47+ { path: "src/foo.ts", kind: "file" },
48+ ]),
49+ ).toBe("src/foo.ts");
50+ });
51+
52+ it("falls back to case-insensitive when only the casing differs", () => {
53+ expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe(
54+ "apps/web/src/components/ChatView.tsx",
55+ );
56+ });
57+
58+ it("returns null when the case-insensitive fallback is ambiguous", () => {
59+ expect(
60+ pickWorkspaceBasenameMatch("FOO.ts", [
61+ { path: "src/Foo.ts", kind: "file" },
62+ { path: "src/foo.ts", kind: "file" },
63+ ]),
64+ ).toBeNull();
65+ });
66+
67+ it("returns null when nothing matches the name", () => {
68+ expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull();
69+ expect(
70+ pickWorkspaceBasenameMatch("ChatView.tsx", [
71+ { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" },
72+ ]),
73+ ).toBeNull();
74+ });
75+});
76+
77+describe("claimWorkspaceBasenameLookup", () => {
78+ it("keeps only the newest claim, whatever order the lookups settle in", () => {
79+ const first = claimWorkspaceBasenameLookup();
80+ const second = claimWorkspaceBasenameLookup();
81+
82+ // The older lookup answering last must not reopen the panel behind the
83+ // newer one.
84+ expect(second()).toBe(true);
85+ expect(first()).toBe(false);
86+ });
87+
88+ it("stays valid while it is the only claim", () => {
89+ const only = claimWorkspaceBasenameLookup();
90+ expect(only()).toBe(true);
91+ expect(only()).toBe(true);
92+ });
93+});
apps/web/src/workspaceBasenameLookup.ts+48−0
@@ -0,0 +1,48 @@
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 trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\");
29+}
30+
31+export function pickWorkspaceBasenameMatch(
32+ basename: string,
33+ entries: ReadonlyArray<WorkspaceEntryCandidate>,
34+): string | null {
35+ const target = basename.trim();
36+ if (!target) return null;
37+ const files = entries.filter((entry) => entry.kind === "file");
38+ const exact = files.find((entry) => basenameOfPath(entry.path) === target);
39+ if (exact) return exact.path;
40+ // Folded matching covers casing that drifted from disk, but `FOO.ts` against
41+ // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing
42+ // rather than opening whichever the index ranked first.
43+ const folded = target.toLowerCase();
44+ const foldedMatches = files.filter(
45+ (entry) => basenameOfPath(entry.path).toLowerCase() === folded,
46+ );
47+ return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null;
48+}