Preserve file viewer sibling browse scope

This commit is contained in:
Dotta committed 2026-06-09 21:35:49 +00:00
1 parent 963cbe9a5c
commit c5c35e3400
4 files changed
+156 -11

No files matched your search

+9 -1
View File
@@ -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],
@@ -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<ComponentProps<typeof WorkspaceFileBrowser>> = {}) {
@@ -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({
+38 -3
View File
@@ -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<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
@@ -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}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto">{body}</div>
{data?.truncated ? (
<div className="border-t border-border pt-2 text-[11px] text-muted-foreground">
+17 -5
View File
@@ -27,7 +27,10 @@ export interface FileViewerContextValue {
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column" | "projectId" | "workspaceId"> & {
workspace?: WorkspaceFileSelector;
},
opts?: { fromBrowse?: boolean },
opts?: {
fromBrowse?: boolean;
browseState?: Partial<FileViewerBrowseState>;
},
): 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<FileViewerProvide
if (opts?.fromBrowse) {
const params = new URLSearchParams(nextSearch);
params.set("browse", "1");
const prevQ = new URLSearchParams(location.search).get("q");
const prevFolder = new URLSearchParams(location.search).get("folder");
if (prevQ) params.set("q", prevQ);
if (prevFolder) params.set("folder", prevFolder);
const previousParams = new URLSearchParams(location.search);
const prevQ = previousParams.get("q");
const prevFolder = previousParams.get("folder");
const nextQ = Object.prototype.hasOwnProperty.call(opts.browseState ?? {}, "q")
? opts.browseState?.q
: prevQ;
const nextFolder = Object.prototype.hasOwnProperty.call(opts.browseState ?? {}, "folderPath")
? opts.browseState?.folderPath
: prevFolder;
if (nextQ) params.set("q", nextQ);
else params.delete("q");
if (nextFolder) params.set("folder", nextFolder);
else params.delete("folder");
nextSearch = params.toString() ? `?${params.toString()}` : "";
}
navigateSearch(nextSearch);