From c5c35e340000875f3ae34c0ac035cfe433e346fb Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 9 Jun 2026 18:56:27 +0000 Subject: [PATCH] Preserve file viewer sibling browse scope --- ui/src/components/FileViewerSheet.tsx | 10 +- .../components/WorkspaceFileBrowser.test.tsx | 94 ++++++++++++++++++- ui/src/components/WorkspaceFileBrowser.tsx | 41 +++++++- ui/src/context/FileViewerContext.tsx | 22 ++++- 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx index 33f18e2b1a6..8a13b64cfa3 100644 --- a/ui/src/components/FileViewerSheet.tsx +++ b/ui/src/components/FileViewerSheet.tsx @@ -513,6 +513,8 @@ export function FileViewerSheet({ column?: number | null; projectId?: string | null; workspaceId?: string | null; + browseFolderPath?: string | null; + browseQuery?: string | null; }) => { viewer.open( { @@ -523,7 +525,13 @@ export function FileViewerSheet({ projectId: ref.projectId ?? null, workspaceId: ref.workspaceId ?? null, }, - { fromBrowse: true }, + { + fromBrowse: true, + browseState: { + folderPath: ref.browseFolderPath ?? null, + q: ref.browseQuery ?? null, + }, + }, ); }, [viewer], diff --git a/ui/src/components/WorkspaceFileBrowser.test.tsx b/ui/src/components/WorkspaceFileBrowser.test.tsx index 13fa033a87a..985fdf4ca29 100644 --- a/ui/src/components/WorkspaceFileBrowser.test.tsx +++ b/ui/src/components/WorkspaceFileBrowser.test.tsx @@ -156,6 +156,10 @@ describe("WorkspaceFileBrowser", () => { container = document.createElement("div"); document.body.appendChild(container); useQueryMock.mockReset(); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); }); afterEach(() => { @@ -165,6 +169,7 @@ describe("WorkspaceFileBrowser", () => { }); } container.remove(); + vi.restoreAllMocks(); }); function renderBrowser(onOpen = vi.fn(), props: Partial> = {}) { @@ -196,7 +201,12 @@ describe("WorkspaceFileBrowser", () => { act(() => { option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); - expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" }); + expect(onOpen).toHaveBeenCalledWith({ + path: "ui/src/pages/IssueDetail.tsx", + workspace: "auto", + browseFolderPath: null, + browseQuery: null, + }); }); it("groups nested paths into collapsible folder rows instead of repeating full paths", () => { @@ -385,7 +395,12 @@ describe("WorkspaceFileBrowser", () => { act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); - expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" }); + expect(onOpen).toHaveBeenCalledWith({ + path: "ui/src/pages/IssueDetail.tsx", + workspace: "auto", + browseFolderPath: null, + browseQuery: null, + }); }); it("reports live search state for URL-backed browse preservation", async () => { @@ -467,6 +482,8 @@ describe("WorkspaceFileBrowser", () => { workspace: "project", projectId: "project-content", workspaceId: "workspace-content", + browseFolderPath: null, + browseQuery: null, }); }); @@ -515,9 +532,82 @@ describe("WorkspaceFileBrowser", () => { workspace: "project", projectId: "project-content", workspaceId: "workspace-content", + browseFolderPath: folderPath, + browseQuery: null, }); }); + it("opens sibling files with the current folder scope", () => { + const folderPath = "docs/cli"; + const controlPlane = createItem({ + title: "control-plane-commands.md", + relativePath: `${folderPath}/control-plane-commands.md`, + displayPath: `${folderPath}/control-plane-commands.md`, + }); + useQueryMock.mockReturnValue(ok(availableResponse([ + createItem({ + title: "setup-commands.md", + relativePath: `${folderPath}/setup-commands.md`, + displayPath: `${folderPath}/setup-commands.md`, + }), + controlPlane, + ]))); + + const { onOpen } = renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + selectedPath: `${folderPath}/setup-commands.md`, + }); + + const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === controlPlane.displayPath, + )!; + act(() => { + option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onOpen).toHaveBeenCalledWith({ + path: `${folderPath}/control-plane-commands.md`, + workspace: "auto", + browseFolderPath: folderPath, + browseQuery: null, + }); + }); + + it("scrolls the selected file into view after breadcrumb-scoped listings", () => { + const folderPath = "docs"; + const selectedPath = "docs/cli/setup-commands.md"; + const scrollIntoView = vi.fn(); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRect(this: Element) { + if (this.getAttribute("aria-selected") === "true") { + return { top: 1086, bottom: 1114, left: 0, right: 100, width: 100, height: 28, x: 0, y: 1086, toJSON: () => ({}) }; + } + if (typeof this.className === "string" && this.className.includes("overflow-y-auto")) { + return { top: 188, bottom: 546, left: 0, right: 320, width: 320, height: 358, x: 0, y: 188, toJSON: () => ({}) }; + } + return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }; + }); + useQueryMock.mockReturnValue(ok(availableResponse([createItem({ + title: "setup-commands.md", + relativePath: selectedPath, + displayPath: selectedPath, + })]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + selectedPath, + }); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + }); + it("lets breadcrumb folders navigate to parent directories", () => { const folderPath = "content-os/cases/active/"; useQueryMock.mockReturnValue(ok(availableResponse([createItem({ diff --git a/ui/src/components/WorkspaceFileBrowser.tsx b/ui/src/components/WorkspaceFileBrowser.tsx index dd1ede33aed..d0836324894 100644 --- a/ui/src/components/WorkspaceFileBrowser.tsx +++ b/ui/src/components/WorkspaceFileBrowser.tsx @@ -354,6 +354,8 @@ export interface WorkspaceFileBrowserProps { column?: number | null; projectId?: string | null; workspaceId?: string | null; + browseFolderPath?: string | null; + browseQuery?: string | null; }) => void; onBrowseStateChange?: (state: { q: string | null; @@ -410,6 +412,7 @@ export function WorkspaceFileBrowser({ const listboxId = useId(); const inputRef = useRef(null); + const scrollContainerRef = useRef(null); const projectsQuery = useQuery({ queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"], @@ -564,8 +567,23 @@ export function WorkspaceFileBrowser({ setFolderPath(parsed.path); setSearchInput(""); setDebouncedQuery(""); - } else if (parsed) onOpen({ path: parsed.path, ...target, line: parsed.line, column: parsed.column }); - else onOpen({ path: value, ...target }); + } else if (parsed) { + onOpen({ + path: parsed.path, + ...target, + line: parsed.line, + column: parsed.column, + browseFolderPath: folderPath, + browseQuery: searchInput.trim() || null, + }); + } else { + onOpen({ + path: value, + ...target, + browseFolderPath: folderPath, + browseQuery: searchInput.trim() || null, + }); + } } function handleSearchKeyDown(event: KeyboardEvent) { @@ -586,6 +604,8 @@ export function WorkspaceFileBrowser({ path: item.relativePath, workspace: effectiveWorkspace, ...itemTarget, + browseFolderPath: folderPath, + browseQuery: searchInput.trim() || null, }); } else openTypedPath(); @@ -596,8 +616,21 @@ export function WorkspaceFileBrowser({ const highlightedItemKey = highlightedItem ? itemKey(highlightedItem) : null; const selectedItem = selectedItemIndex >= 0 ? items[selectedItemIndex] : null; const selectedItemKey = selectedItem ? itemKey(selectedItem) : null; + const selectedOptionId = selectedItemKey ? `${listboxId}-file-${selectedItemKey}` : null; const activeOptionId = highlightedItemKey ? `${listboxId}-file-${highlightedItemKey}` : undefined; + useEffect(() => { + if (!selectedOptionId) return; + const scrollContainer = scrollContainerRef.current; + const selectedElement = document.getElementById(selectedOptionId); + if (!scrollContainer || !selectedElement) return; + const containerRect = scrollContainer.getBoundingClientRect(); + const selectedRect = selectedElement.getBoundingClientRect(); + if (selectedRect.top < containerRect.top || selectedRect.bottom > containerRect.bottom) { + selectedElement.scrollIntoView({ block: "nearest" }); + } + }, [folderPath, items, selectedOptionId]); + function openFolder(path: string | null) { setFolderPath(path); setSearchInput(""); @@ -612,6 +645,8 @@ export function WorkspaceFileBrowser({ path: item.relativePath, workspace: effectiveWorkspace, ...itemTarget, + browseFolderPath: folderPath, + browseQuery: searchInput.trim() || null, }); } @@ -741,7 +776,7 @@ export function WorkspaceFileBrowser({ {announcement} -
{body}
+
{body}
{data?.truncated ? (
diff --git a/ui/src/context/FileViewerContext.tsx b/ui/src/context/FileViewerContext.tsx index 312b7ddfc35..6764b17276b 100644 --- a/ui/src/context/FileViewerContext.tsx +++ b/ui/src/context/FileViewerContext.tsx @@ -27,7 +27,10 @@ export interface FileViewerContextValue { ref: Pick & { workspace?: WorkspaceFileSelector; }, - opts?: { fromBrowse?: boolean }, + opts?: { + fromBrowse?: boolean; + browseState?: Partial; + }, ): void; /** Open (or stay in) browse mode, optionally seeding the search query. */ openBrowse(opts?: { q?: string }): void; @@ -217,10 +220,19 @@ function EnabledFileViewerProvider({ issueId, children }: Omit