Render file viewer browser as a tree

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent f2b869ecdc
commit e8bdeadac9
3 files changed
+300 -83

No files matched your search

+15 -28
View File
@@ -497,7 +497,7 @@ export function FileViewerSheet({
return (
<Dialog open={computedOpen} onOpenChange={handleOpenChange}>
<DialogContent
className="flex h-[min(840px,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[min(1120px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0 sm:w-[92vw] sm:max-w-[1120px]"
className="flex h-[min(840px,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[min(1280px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0 sm:w-[94vw] sm:max-w-[1280px]"
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
showCloseButton={false}
@@ -510,14 +510,14 @@ export function FileViewerSheet({
}}
>
<DialogHeader className="border-b border-border gap-1 p-3">
<div className="flex items-start gap-2">
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2">
{browseMode ? (
<FolderSearch aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<DialogTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
<DialogTitle id={FILE_VIEWER_LABELLED_BY_ID} className="whitespace-normal break-all text-sm leading-5">
{title}
</DialogTitle>
<DialogDescription
@@ -531,7 +531,7 @@ export function FileViewerSheet({
{description}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-1 self-start">
{cameFromBrowse ? (
<Button
type="button"
@@ -571,30 +571,17 @@ export function FileViewerSheet({
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
</Button>
) : null}
{cameFromBrowse ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => handleOpenChange(false)}
className="h-7 w-7"
aria-label="Close file viewer"
title="Close"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleOpenChange(false)}
className="h-7 px-2 text-xs"
aria-label="Close file viewer"
>
Close
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => handleOpenChange(false)}
className="h-7 w-7"
aria-label="Close file viewer"
title="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{resolvedResource ? (
@@ -176,18 +176,18 @@ describe("WorkspaceFileBrowser", () => {
return { root, onOpen };
}
it("renders the Recently changed list as a listbox and opens a row with its relative path", () => {
it("renders the Recently changed files as a tree and opens a row with its relative path", () => {
useQueryMock.mockReturnValue(
ok(availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })])),
);
const { onOpen } = renderBrowser();
expect(container.querySelector('[role="listbox"]')).not.toBeNull();
expect(container.querySelector('[role="tree"]')).not.toBeNull();
expect(container.textContent).toContain("Recently changed");
expect(container.textContent).toContain("From Isolated workspace");
const option = Array.from(container.querySelectorAll('[role="option"]')).find(
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
);
expect(option).not.toBeUndefined();
@@ -198,6 +198,42 @@ describe("WorkspaceFileBrowser", () => {
expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" });
});
it("groups nested paths into collapsible folder rows instead of repeating full paths", () => {
useQueryMock.mockReturnValue(
ok(availableResponse([
createItem({
title: "tweet.md",
relativePath: "videos/90-days-paperclip/tweet.md",
displayPath: "videos/90-days-paperclip/tweet.md",
}),
createItem({
title: "90-days-paperclip-1x1.mp4",
relativePath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
displayPath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4",
previewKind: "video",
}),
])),
);
renderBrowser();
expect(container.querySelector('[role="tree"]')).not.toBeNull();
expect(container.textContent).toContain("videos");
expect(container.textContent).toContain("90-days-paperclip");
expect(container.textContent).toContain("tweet.md");
expect(container.textContent).toContain("90-days-paperclip-1x1.mp4");
expect(container.textContent).not.toContain("videos/90-days-paperclip/tweet.md");
const videosFolder = Array.from(container.querySelectorAll('button[role="treeitem"]')).find(
(el) => el.getAttribute("title") === "videos",
);
expect(videosFolder).not.toBeUndefined();
act(() => {
videosFolder!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(container.textContent).not.toContain("tweet.md");
});
it("can render without autofocus when embedded beside a preview", () => {
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
renderBrowser(vi.fn(), { autoFocusSearch: false });
@@ -234,7 +270,7 @@ describe("WorkspaceFileBrowser", () => {
useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace")));
renderBrowser();
expect(container.textContent).toContain("Remote workspace preview not supported");
expect(container.querySelector('[role="listbox"]')).toBeNull();
expect(container.querySelector('[role="tree"]')).toBeNull();
});
it("shows the no-workspace state when the issue has no workspace", () => {
@@ -273,7 +309,9 @@ describe("WorkspaceFileBrowser", () => {
workspaceId: "workspace-content",
});
const option = container.querySelector('[role="option"]')!;
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
(el) => el.getAttribute("title") === contentItem.displayPath,
)!;
act(() => {
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
@@ -308,7 +346,8 @@ describe("WorkspaceFileBrowser", () => {
initialFolderPath: folderPath,
});
expect(container.textContent).toContain(folderPath);
expect(container.textContent).toContain("bundled-skills");
expect(container.textContent).not.toContain(folderPath);
expect(container.textContent).toContain("Files in folder");
const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list");
expect(listCall?.[0].queryKey[4]).toMatchObject({
@@ -318,7 +357,9 @@ describe("WorkspaceFileBrowser", () => {
path: folderPath,
});
const option = container.querySelector('[role="option"]')!;
const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find(
(el) => el.getAttribute("title") === contentItem.displayPath,
)!;
act(() => {
option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
+237 -48
View File
@@ -8,7 +8,7 @@ import {
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
@@ -48,6 +48,22 @@ function dirname(path: string): string {
return idx < 0 ? "" : path.slice(0, idx + 1);
}
function trimTrailingSlash(path: string): string {
return path.replace(/\/+$/, "");
}
function folderName(path: string): string {
const trimmed = trimTrailingSlash(path);
if (!trimmed) return "";
return basename(trimmed);
}
function normalizeFolderPrefix(path: string | null | undefined): string {
if (!path) return "";
const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed ? `${trimmed}/` : "";
}
/**
* Maps a server `unavailableReason` to a calm, board-readable explanation.
* Copy is kept in sync with `describeDenial` in FileViewerSheet so the browse
@@ -155,47 +171,33 @@ function SourceSelector({
interface WorkspaceFileRowProps {
item: WorkspaceFileListItem;
optionId: string;
treeItemId: string;
selected: boolean;
showTimestamp: boolean;
depth: number;
onOpen: () => void;
onHover: () => void;
}
function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onHover }: WorkspaceFileRowProps) {
const name = basename(item.displayPath);
const dir = dirname(item.displayPath);
function WorkspaceFileRow({ item, treeItemId, selected, showTimestamp, depth, onOpen, onHover }: WorkspaceFileRowProps) {
const name = basename(item.relativePath);
const stamp = showTimestamp && item.modifiedAt ? timeAgo(item.modifiedAt) : null;
return (
<div
id={optionId}
role="option"
id={treeItemId}
role="treeitem"
aria-selected={selected}
onClick={onOpen}
onMouseEnter={onHover}
title={item.displayPath}
className={cn(
"flex min-h-[44px] cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 sm:min-h-0",
"flex min-h-[36px] cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 sm:min-h-0",
selected ? "bg-accent" : "hover:bg-accent/60",
)}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{/* Desktop (>=sm): single line, directory truncates and basename stays visible. */}
<span className="hidden min-w-0 flex-1 items-baseline gap-0 overflow-hidden sm:flex">
{dir ? (
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">{dir}</span>
) : null}
<span className="shrink-0 truncate font-mono text-xs text-foreground">{name}</span>
</span>
{/* Mobile (<sm): two lines, basename first so the filename is always readable. */}
<span className="flex min-w-0 flex-1 flex-col overflow-hidden sm:hidden">
<span className="truncate font-mono text-xs text-foreground">{name}</span>
{dir ? (
<span className="truncate font-mono text-[11px] text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
{dir}
</span>
) : null}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{name}</span>
{stamp ? (
<span className="hidden shrink-0 text-[11px] tabular-nums text-muted-foreground sm:inline">{stamp}</span>
) : null}
@@ -203,6 +205,174 @@ function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onH
);
}
interface WorkspaceFileTreeFolderNode {
kind: "folder";
key: string;
name: string;
depth: number;
children: WorkspaceFileTreeNode[];
}
interface WorkspaceFileTreeFileNode {
kind: "file";
key: string;
item: WorkspaceFileListItem;
depth: number;
}
type WorkspaceFileTreeNode = WorkspaceFileTreeFolderNode | WorkspaceFileTreeFileNode;
interface MutableTreeFolder {
kind: "folder";
key: string;
name: string;
depth: number;
folders: Map<string, MutableTreeFolder>;
files: WorkspaceFileTreeFileNode[];
}
function itemKey(item: WorkspaceFileListItem): string {
return `${item.workspaceId}:${item.relativePath}`;
}
function compareTreeNodes(a: WorkspaceFileTreeNode, b: WorkspaceFileTreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const aName = a.kind === "folder" ? a.name : basename(a.item.relativePath);
const bName = b.kind === "folder" ? b.name : basename(b.item.relativePath);
return aName.localeCompare(bName);
}
function finalizeTreeFolder(folder: MutableTreeFolder): WorkspaceFileTreeFolderNode {
const children: WorkspaceFileTreeNode[] = [
...Array.from(folder.folders.values()).map(finalizeTreeFolder),
...folder.files,
];
children.sort(compareTreeNodes);
return {
kind: "folder",
key: folder.key,
name: folder.name,
depth: folder.depth,
children,
};
}
function buildWorkspaceFileTree(items: WorkspaceFileListItem[], rootPath: string | null | undefined) {
const rootPrefix = normalizeFolderPrefix(rootPath);
const root: MutableTreeFolder = {
kind: "folder",
key: "__root__",
name: "",
depth: -1,
folders: new Map(),
files: [],
};
for (const item of items) {
const path = item.relativePath.startsWith(rootPrefix)
? item.relativePath.slice(rootPrefix.length)
: item.relativePath;
const segments = path.split("/").filter(Boolean);
const fileName = segments.pop() ?? basename(item.relativePath);
let cursor = root;
let currentPath = rootPrefix.replace(/\/$/, "");
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
let folder = cursor.folders.get(segment);
if (!folder) {
folder = {
kind: "folder",
key: currentPath,
name: segment,
depth: cursor.depth + 1,
folders: new Map(),
files: [],
};
cursor.folders.set(segment, folder);
}
cursor = folder;
}
cursor.files.push({
kind: "file",
key: itemKey(item),
item: { ...item, title: fileName },
depth: cursor.depth + 1,
});
}
const tree = finalizeTreeFolder(root);
return tree.children;
}
interface WorkspaceFileTreeProps {
nodes: WorkspaceFileTreeNode[];
listboxId: string;
highlightedItemKey: string | null;
collapsedFolders: Set<string>;
showTimestamp: boolean;
onToggleFolder: (key: string) => void;
onOpen: (item: WorkspaceFileListItem) => void;
onHoverFile: (item: WorkspaceFileListItem) => void;
}
function WorkspaceFileTree({
nodes,
listboxId,
highlightedItemKey,
collapsedFolders,
showTimestamp,
onToggleFolder,
onOpen,
onHoverFile,
}: WorkspaceFileTreeProps) {
function renderNode(node: WorkspaceFileTreeNode): ReactNode {
if (node.kind === "folder") {
const expanded = !collapsedFolders.has(node.key);
return (
<div key={node.key}>
<button
type="button"
role="treeitem"
aria-expanded={expanded}
title={node.key}
onClick={() => onToggleFolder(node.key)}
className="flex min-h-[32px] w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-accent/60"
style={{ paddingLeft: `${0.25 + node.depth * 0.875}rem` }}
>
{expanded ? (
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{node.name}</span>
</button>
{expanded ? node.children.map(renderNode) : null}
</div>
);
}
return (
<WorkspaceFileRow
key={node.key}
item={node.item}
treeItemId={`${listboxId}-file-${node.key}`}
selected={node.key === highlightedItemKey}
showTimestamp={showTimestamp}
depth={node.depth}
onOpen={() => onOpen(node.item)}
onHover={() => onHoverFile(node.item)}
/>
);
}
return (
<div role="tree" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
{nodes.map(renderNode)}
</div>
);
}
export interface WorkspaceFileBrowserProps {
issueId: string;
companyId?: string | null;
@@ -247,6 +417,7 @@ export function WorkspaceFileBrowser({
// listing for the default (empty-query) view, per spec.
const [recentUnavailable, setRecentUnavailable] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
@@ -342,6 +513,7 @@ export function WorkspaceFileBrowser({
const data = listQuery.data;
const items = useMemo(() => data?.items ?? [], [data]);
const workspaceLabel = data?.workspace?.workspaceLabel ?? null;
const treeNodes = useMemo(() => buildWorkspaceFileTree(items, folderPath), [folderPath, items]);
// Silent fallback: empty-query view with no change-tracking → list everything.
useEffect(() => {
@@ -406,7 +578,35 @@ export function WorkspaceFileBrowser({
setSelectedWorkspaceId(project?.primaryWorkspace?.id ?? project?.workspaces[0]?.id ?? null);
}
const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined;
const highlightedItem = highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
const highlightedItemKey = highlightedItem ? itemKey(highlightedItem) : null;
const activeOptionId = highlightedItemKey ? `${listboxId}-file-${highlightedItemKey}` : undefined;
function openItem(item: WorkspaceFileListItem) {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}
function toggleFolder(key: string) {
setCollapsedFolders((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function handleHoverFile(item: WorkspaceFileListItem) {
const key = itemKey(item);
const index = items.findIndex((candidate) => itemKey(candidate) === key);
if (index >= 0) setHighlightedIndex(index);
}
let body: ReactNode;
if (source === "other" && !companyId) {
@@ -470,28 +670,16 @@ export function WorkspaceFileBrowser({
);
} else {
body = (
<div role="listbox" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
{items.map((item, index) => (
<WorkspaceFileRow
key={`${item.workspaceId}:${item.relativePath}`}
item={item}
optionId={`${listboxId}-opt-${index}`}
selected={index === highlightedIndex}
showTimestamp={!isSearch}
onOpen={() => {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}}
onHover={() => setHighlightedIndex(index)}
/>
))}
</div>
<WorkspaceFileTree
nodes={treeNodes}
listboxId={listboxId}
highlightedItemKey={highlightedItemKey}
collapsedFolders={collapsedFolders}
showTimestamp={!isSearch}
onToggleFolder={toggleFolder}
onOpen={openItem}
onHoverFile={handleHoverFile}
/>
);
}
@@ -581,7 +769,8 @@ export function WorkspaceFileBrowser({
{folderPath ? (
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground" title={folderPath}>
<FolderOpen aria-hidden="true" className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate font-mono">{folderPath}</span>
<span className="shrink-0">Folder</span>
<span className="min-w-0 truncate font-mono text-foreground">{folderName(folderPath)}</span>
</div>
) : null}