| author | |
| committer | |
| log | 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45 |
| tree | d454987ee24ba58faeba64aee2fa1db642dc382f |
| parent | b277cc65e045899e7aa941e92d04f2b4996f27bb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>3 files changed, 192 insertions(+), 2 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+51-2| ... | ... | @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; |
| 90 | 90 | import { previewEnvironment } from "../state/preview"; |
| 91 | 91 | import { useAtomCommand } from "../state/use-atom-command"; |
| 92 | 92 | 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"; | |
| 93 | 100 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; |
| 94 | 101 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 95 | 102 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| ... | ... | @@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { |
| 811 | 818 | theme: "light" | "dark"; |
| 812 | 819 | threadRef?: ScopedThreadRef | undefined; |
| 813 | 820 | onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>; |
| 821 | onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; | |
| 814 | 822 | onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; |
| 815 | 823 | className?: string | undefined; |
| 816 | 824 | } |
| ... | ... | @@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1116 | 1124 | theme, |
| 1117 | 1125 | threadRef, |
| 1118 | 1126 | onOpen, |
| 1127 | onOpenInPanel, | |
| 1119 | 1128 | onOpenInBrowser, |
| 1120 | 1129 | className, |
| 1121 | 1130 | }: MarkdownFileLinkProps) { |
| ... | ... | @@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1159 | 1168 | handleOpenInEditor(); |
| 1160 | 1169 | return; |
| 1161 | 1170 | } |
| 1162 | useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); | |
| 1163 | }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); | |
| 1171 | onOpenInPanel(workspaceRelativePath, line); | |
| 1172 | }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); | |
| 1164 | 1173 | |
| 1165 | 1174 | const handleOpenInBrowser = useCallback(() => { |
| 1166 | 1175 | if (!onOpenInBrowser) { |
| ... | ... | @@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1336 | 1345 | previous.theme === next.theme && |
| 1337 | 1346 | previous.threadRef === next.threadRef && |
| 1338 | 1347 | previous.onOpen === next.onOpen && |
| 1348 | previous.onOpenInPanel === next.onOpenInPanel && | |
| 1339 | 1349 | previous.onOpenInBrowser === next.onOpenInBrowser && |
| 1340 | 1350 | previous.className === next.className |
| 1341 | 1351 | ); |
| ... | ... | @@ -1355,6 +1365,9 @@ function ChatMarkdown({ |
| 1355 | 1365 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1356 | 1366 | reportFailure: false, |
| 1357 | 1367 | }); |
| 1368 | const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { | |
| 1369 | reportFailure: false, | |
| 1370 | }); | |
| 1358 | 1371 | const openPreview = useAtomCommand(previewEnvironment.open, { |
| 1359 | 1372 | reportFailure: false, |
| 1360 | 1373 | }); |
| ... | ... | @@ -1457,6 +1470,40 @@ function ChatMarkdown({ |
| 1457 | 1470 | }, |
| 1458 | 1471 | [createAssetUrl, openPreview, preparedConnection, threadRef], |
| 1459 | 1472 | ); |
| 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 | ); | |
| 1460 | 1507 | /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component |
| 1461 | 1508 | * renderers that close over this message's metadata. useMemo keeps them stable until that |
| 1462 | 1509 | * metadata changes. */ |
| ... | ... | @@ -1490,6 +1537,7 @@ function ChatMarkdown({ |
| 1490 | 1537 | theme={resolvedTheme} |
| 1491 | 1538 | threadRef={threadRef} |
| 1492 | 1539 | onOpen={openInPreferredEditor} |
| 1540 | onOpenInPanel={openFileInPanel} | |
| 1493 | 1541 | onOpenInBrowser={ |
| 1494 | 1542 | threadRef && |
| 1495 | 1543 | isPreviewSupportedInRuntime() && |
| ... | ... | @@ -1718,6 +1766,7 @@ function ChatMarkdown({ |
| 1718 | 1766 | isStreaming, |
| 1719 | 1767 | markdownFileLinkMetaByHref, |
| 1720 | 1768 | onTaskListChange, |
| 1769 | openFileInPanel, | |
| 1721 | 1770 | openInPreferredEditor, |
| 1722 | 1771 | openExternalLinkInPreview, |
| 1723 | 1772 | openMarkdownFileInPreview, |
apps/web/src/workspaceBasenameLookup.test.ts created+93| ... | ... | @@ -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 created+48| ... | ... | @@ -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 | } |