From 0567844fddc33093c61d5093149e8767e39db164 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:22:01 +0800 Subject: [PATCH 1/4] fix(files): open workspace search results at their matching line --- .../components/workspace-explorer.test.tsx | 132 ++++++++++++++++++ web/src/components/workspace-explorer.tsx | 28 ++-- 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/web/src/components/workspace-explorer.test.tsx b/web/src/components/workspace-explorer.test.tsx index 29c1afeca..1d787dfae 100644 --- a/web/src/components/workspace-explorer.test.tsx +++ b/web/src/components/workspace-explorer.test.tsx @@ -1,7 +1,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../api/client"; +import { FileEditDraftProvider, useFileEditLeaveEntry } from "./file-edit-drafts"; import { WorkspaceExplorer } from "./workspace-explorer"; describe("WorkspaceExplorer", () => { @@ -91,8 +93,138 @@ describe("WorkspaceExplorer", () => { expect(await screen.findByRole("alert")).toHaveTextContent(/out of loaded range|超出当前显示范围/); }); + + it.each([ + { name: "without a parent callback", mirrored: false, initialPath: ".", initialLine: undefined }, + { name: "when the parent mirrors a new path", mirrored: true, initialPath: ".", initialLine: undefined }, + { name: "when the parent clears the same file's citation line", mirrored: true, initialPath: "README.md", initialLine: 1 }, + ])("opens a search result at its matching line $name", async ({ mirrored, initialPath, initialLine }) => { + const client = searchableClient(); + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( + {mirrored ? : + } + ); + + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); + + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + expect(view.container.querySelector('[data-line="2"]')).toHaveClass("is-highlighted"); + expect(client.workspaceExplore).toHaveBeenLastCalledWith("workspace-1", "README.md", expect.any(AbortSignal)); + }); + + it("clears a previous hit for filename results and ordinary directory navigation", async () => { + const client = searchableClient(); + const user = userEvent.setup(); + const view = renderExplorer(client); + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + + client.workspaceSearch.mockResolvedValueOnce(searchSnapshot(0)); + await searchFor(user, "readme"); + await user.click(await screen.findByRole("button", { name: /README.md.*filename/ })); + expect(view.container.querySelector(".is-highlighted")).not.toBeInTheDocument(); + expect(screen.queryByText(/Positioned at line/)).not.toBeInTheDocument(); + + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Open parent directory" })); + await user.click(screen.getByRole("button", { name: "Close search results" })); + await user.click(await screen.findByRole("button", { name: /README.md/ })); + expect(view.container.querySelector(".is-highlighted")).not.toBeInTheDocument(); + }); + + it("scrolls back to the matching line when the same result is selected again", async () => { + const client = searchableClient(); + const user = userEvent.setup(); + const view = renderExplorer(client); + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + const line = view.container.querySelector('[data-line="2"]')!; + const scroll = vi.fn(); + Object.defineProperty(line, "scrollIntoView", { value: scroll }); + await user.click(screen.getByRole("button", { name: /README.md.*line 2/ })); + expect(scroll).toHaveBeenCalledWith({ block: "center", behavior: "smooth" }); + }); + + it("honors a later external location and resets the hit when the workspace or run changes", async () => { + const client = searchableClient(); + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const explorer = (workspaceID: string, runID: string, initialLine?: number) => + ; + const view = render(explorer("workspace-1", "run-1")); + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ })); + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + view.rerender(explorer("workspace-1", "run-1", 1)); + expect(await screen.findByText("Positioned at line 1")).toBeInTheDocument(); + view.rerender(explorer("workspace-2", "run-2")); + await waitFor(() => expect(client.workspaceExplore).toHaveBeenLastCalledWith("workspace-2", "README.md", expect.any(AbortSignal))); + expect(view.container.querySelector(".is-highlighted")).not.toBeInTheDocument(); + }); + + it("changes neither file nor line until unsaved edits allow the search navigation", async () => { + const client = searchableClient(); + client.workspaceSearch.mockResolvedValue(searchSnapshot(2, "notes.txt")); + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + ); + await searchFor(user, "automated"); + await user.click(await screen.findByRole("button", { name: /notes.txt.*line 2/ })); + await user.click(screen.getByRole("button", { name: "Continue editing" })); + expect(screen.getByText("Positioned at line 1")).toBeInTheDocument(); + expect(client.workspaceExplore).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: /notes.txt.*line 2/ })); + await user.click(screen.getByRole("button", { name: "Keep draft" })); + expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument(); + expect(client.workspaceExplore).toHaveBeenLastCalledWith("workspace-1", "notes.txt", expect.any(AbortSignal)); + }); }); +function MirroredExplorer({ client, initialPath, initialLine }: { + client: APIClient; initialPath: string; initialLine?: number; +}) { + const [location, setLocation] = useState({ path: initialPath, line: initialLine }); + return setLocation({ path, line: undefined })} />; +} + +function UnsavedFile() { + useFileEditLeaveEntry({ path: "README.md", dirty: true, busy: false, canSave: false, + save: async () => {}, keep: () => {}, discard: () => {} }); + return null; +} + +function searchableClient() { + return Object.assign({} as APIClient, { + workspaceExplore: vi.fn(async (_workspaceID: string, path = ".") => path === "." ? directorySnapshot() : + { ...fileSnapshot(), path }), + workspaceSearch: vi.fn().mockResolvedValue(searchSnapshot()), + }); +} + +function searchSnapshot(line = 2, path = "README.md") { + return { results: [{ path, line, match_kind: line ? "content" : "filename", snippet: "", + content_truncated: false, provenance: fileSnapshot().provenance }], truncated: false }; +} + +async function searchFor(user: ReturnType, query: string) { + const input = await screen.findByLabelText("Search Workspace evidence"); + await user.clear(input); + await user.type(input, query); + await user.click(screen.getByRole("button", { name: "Search Workspace" })); +} + function directorySnapshot() { return { protocol_version: "workspace_explorer.v1", workspace_id: "workspace-1", path: ".", diff --git a/web/src/components/workspace-explorer.tsx b/web/src/components/workspace-explorer.tsx index d1671f275..5e454fd78 100644 --- a/web/src/components/workspace-explorer.tsx +++ b/web/src/components/workspace-explorer.tsx @@ -20,15 +20,23 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath }) { const { t } = useLocale(); const leave = useFileEditLeave(); - const [path, setPath] = useState(initialPath); - const [highlightLine, setHighlightLine] = useState(initialLine); + const [location, setLocation] = useState({ path: initialPath, line: initialLine }); + const { path, line: highlightLine } = location; const highlightedLineRef = useRef(null); const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const operationKeys = useRef(new Map()); + const reportedNavigation = useRef<{ workspaceID: string; runID: string; path: string } | undefined>(undefined); useEffect(() => { - setPath(initialPath); - setHighlightLine(initialLine); + const reported = reportedNavigation.current; + // Parents can mirror onPathChange while clearing the previous citation line. + // That acknowledgment must not replace the line selected by a search result. + const isNavigationEcho = reported?.workspaceID === workspaceID && reported.runID === runID && + reported.path === initialPath && initialLine === undefined; + reportedNavigation.current = undefined; + if (!isNavigationEcho) { + setLocation({ path: initialPath, line: initialLine }); + } setSearchInput(""); setSearchQuery(""); operationKeys.current.clear(); @@ -43,7 +51,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath if (highlightLine && highlightedLineRef.current) { highlightedLineRef.current.scrollIntoView?.({ block: "center", behavior: "smooth" }); } - }, [highlightLine, path, query.data]); + }, [location, query.data]); const search = useQuery({ queryKey: ["workspace", workspaceID, "search", searchQuery], queryFn: ({ signal }) => client.workspaceSearch(workspaceID, searchQuery, signal), @@ -64,9 +72,11 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath mutationFn: (sourcePath: string) => client.issueFileEditProposalSource(runID, sourcePath), }); const parent = useMemo(() => parentPath(path), [path]); - const navigate = (nextPath: string) => leave(() => { + const navigate = (nextPath: string, nextLine?: number) => leave(() => { proposalSource.reset(); - setPath(nextPath); + setLocation({ path: nextPath, line: nextLine }); + reportedNavigation.current = onPathChange && (nextPath !== initialPath || initialLine !== undefined) + ? { workspaceID, runID, path: nextPath } : undefined; onPathChange?.(nextPath); }); @@ -214,7 +224,7 @@ function WorkspaceSearchResults({ client, runID, pending, query, onAttach, onCle query: UseQueryResult; onAttach: (path: string, digest: string) => void; onClear: () => void; - onOpen: (path: string) => void; + onOpen: (path: string, line?: number) => void; }) { const { t } = useLocale(); if (query.isLoading) return ; @@ -228,7 +238,7 @@ function WorkspaceSearchResults({ client, runID, pending, query, onAttach, onCle {data.results.length === 0 && {t("没有匹配的证据", "No matching evidence")}} {data.results.map((result) =>
-