mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:12:18 +01:00
Preserve file viewer sibling browse scope
This commit is contained in:
1 parent
963cbe9a5c
commit
c5c35e3400
4 files changed
+156
-11
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user