Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ describe("Files plugin app", () => {

it("uses BB Markdown for Preview and exposes Raw", async () => {
setRpcHandlers({
listTree: () => ({
listDirectory: () => ({
path: "",
rootName: "repo",
entries: [
{
Expand All @@ -74,7 +75,7 @@ describe("Files plugin app", () => {
positions: [],
},
],
truncated: false,
annotateAvailable: false,
}),
readFile: () => ({
state: "text",
Expand All @@ -101,7 +102,8 @@ describe("Files plugin app", () => {
const openFile = vi.fn(() => ({ delivered: 1 }));
setRpcHandlers({
openFile,
listTree: () => ({
listDirectory: () => ({
path: "",
rootName: "repo",
entries: [
{
Expand All @@ -112,7 +114,6 @@ describe("Files plugin app", () => {
positions: [],
},
],
truncated: false,
annotateAvailable: true,
}),
readFile: () => ({
Expand Down Expand Up @@ -146,7 +147,7 @@ describe("Files plugin app", () => {
);
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => {
const path =
typeof input === "object" &&
Expand Down Expand Up @@ -195,7 +196,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }),
});
setBbContext({ projectId: "project-a", threadId: "thread-1" });
Expand All @@ -213,20 +214,20 @@ describe("Files plugin app", () => {
{ kind: "workspace" as const, threadId: "thread-1", environmentId: "foreign-environment", projectId: null },
{ kind: "workspace" as const, threadId: "thread-1", environmentId: null, projectId: "foreign-project" },
])("does not authorize file-opener sources without a host context", async (source) => {
const listTree = vi.fn();
const listDirectory = vi.fn();
const readFile = vi.fn();
setBbContext({ projectId: null, threadId: null });
setRpcHandlers({ listTree, readFile });
setRpcHandlers({ listDirectory, readFile });
render(<FilesPanel path="README.md" source={source} />);
await new Promise((resolve) => window.setTimeout(resolve, 250));
expect(listTree).not.toHaveBeenCalled();
expect(listDirectory).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
});

it("fails closed for unauthorized callback invocations", async () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
const handlers = { openFile: vi.fn(), saveFile: vi.fn(), createFile: vi.fn(), createDirectory: vi.fn(), movePath: vi.fn(), removePath: vi.fn(), readFile: vi.fn(), listTree: vi.fn() };
const handlers = { openFile: vi.fn(), saveFile: vi.fn(), createFile: vi.fn(), createDirectory: vi.fn(), movePath: vi.fn(), removePath: vi.fn(), readFile: vi.fn(), listDirectory: vi.fn() };
setRpcHandlers(handlers);
setBbContext({ projectId: null, threadId: null });
const hook = renderHook(() => useFilesWorkspace());
Expand Down Expand Up @@ -258,7 +259,7 @@ describe("Files plugin app", () => {
it("focuses an existing tab for the same source and path", async () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({ listTree: () => ({ rootName: "repo", entries: [], truncated: false }), readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }) });
setRpcHandlers({ listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }), readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }) });
const hook = renderHook(() => useFilesWorkspace());
await act(async () => { await hook.result.current.openPath("README.md"); await hook.result.current.openPath("README.md"); });
expect(hook.result.current.tabs).toHaveLength(1);
Expand All @@ -275,7 +276,7 @@ describe("Files plugin app", () => {

it("resets panel state when the trusted host source changes", async () => {
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [{ kind: "file", path: "README.md", name: "README.md", score: 0, positions: [] }], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [{ kind: "file", path: "README.md", name: "README.md", score: 0, positions: [] }], annotateAvailable: false }),
readFile: () => ({ state: "text", path: "README.md", sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "x" }),
});
const view = render(<FilesPanel threadId="thread-1" params={null} />);
Expand All @@ -290,7 +291,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => {
const path = (input as { path: string }).path;
return { state: "text", path, sha256: path, sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: path };
Expand All @@ -312,7 +313,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }),
});
const hook = renderHook(() => useFilesWorkspace());
Expand All @@ -331,7 +332,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }),
saveFile: () => ({ outcome: "conflict", currentSha256: "new-sha" }),
});
Expand All @@ -351,7 +352,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => ({ state: "text", path: (input as { path: string }).path, sha256: "sha", sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: "saved" }),
});
const hook = renderHook(() => useFilesWorkspace());
Expand All @@ -372,7 +373,7 @@ describe("Files plugin app", () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: (input: unknown) => {
const path = (input as { path: string }).path;
return { state: "text", path, sha256: path, sizeBytes: 1, mimeType: null, modifiedAtMs: null, content: path };
Expand All @@ -394,7 +395,7 @@ describe("Files plugin app", () => {
"./src/hooks/useFilesWorkspace"
);
setRpcHandlers({
listTree: () => ({ rootName: "repo", entries: [], truncated: false }),
listDirectory: () => ({ path: "", rootName: "repo", entries: [], annotateAvailable: false }),
readFile: () => ({
state: "text",
path: "README.md",
Expand Down Expand Up @@ -426,4 +427,51 @@ describe("Files plugin app", () => {
expect(hook.result.current.tabs.find(t => t.path === "README.md")?.draftText).toBe("my draft");
expect(hook.result.current.activePath).toBe("README.md");
});

it("lazily loads a directory's children on expand and drops them on collapse", async () => {
const { useFilesWorkspace } = await import("./src/hooks/useFilesWorkspace");
const { renderHook } = await import("@testing-library/react");
const listDirectory = vi.fn((input: unknown) => {
const path = (input as { path: string }).path;
if (path === "") {
return {
path: "",
rootName: "repo",
annotateAvailable: false,
entries: [{ kind: "directory", path: "src", name: "src", score: 0, positions: [] }],
};
}
if (path === "src") {
return {
path: "src",
entries: [{ kind: "file", path: "src/a.ts", name: "a.ts", score: 0, positions: [] }],
};
}
throw new Error(`unexpected listDirectory path: ${path}`);
});
setRpcHandlers({ listDirectory });
const hook = renderHook(() => useFilesWorkspace());

await waitFor(() => {
expect(hook.result.current.entries.map((entry) => entry.path)).toEqual(["src"]);
});
expect(hook.result.current.expandedDirs.has("src")).toBe(false);

await act(async () => {
hook.result.current.toggleDirectory("src");
});
await waitFor(() => {
expect(hook.result.current.entries.map((entry) => entry.path)).toEqual(
expect.arrayContaining(["src", "src/a.ts"]),
);
});
expect(hook.result.current.expandedDirs.has("src")).toBe(true);
expect(listDirectory).toHaveBeenCalledWith(expect.objectContaining({ path: "src" }));

// Collapsing drops the fetched children from state instead of merely
// hiding them, so re-expanding fetches fresh data.
act(() => hook.result.current.toggleDirectory("src"));
expect(hook.result.current.expandedDirs.has("src")).toBe(false);
expect(hook.result.current.entries.map((entry) => entry.path)).toEqual(["src"]);
});
});
2 changes: 2 additions & 0 deletions src/components/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,13 @@ function FilesPanelContent({ initialPath }: { initialPath: string | null }) {
<TreePane
entries={workspace.entries}
error={workspace.treeError}
expandedDirs={workspace.expandedDirs}
loading={workspace.treeLoading}
onAction={handleAction}
onCreateRoot={(kind) => requestCreate(kind)}
onOpen={(path) => void workspace.openPath(path)}
onRefresh={() => void workspace.refreshTree()}
onToggleDirectory={workspace.toggleDirectory}
query={workspace.query}
rootName={workspace.rootName}
selectedPath={workspace.activePath}
Expand Down
47 changes: 8 additions & 39 deletions src/components/TreePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Button } from "@/components/ui/button";
Expand All @@ -16,7 +15,6 @@ import type { FileTreeEntry } from "../hooks/useFilesWorkspace";
import {
filterVisibleEntries,
orderTreeEntries,
parentPath,
searchSortEntries,
} from "../tree-order";

Expand Down Expand Up @@ -158,11 +156,13 @@ function TreeRow({
export function TreePane({
entries,
error,
expandedDirs,
loading,
onAction,
onCreateRoot,
onOpen,
onRefresh,
onToggleDirectory,
query,
rootName,
selectedPath,
Expand All @@ -172,48 +172,24 @@ export function TreePane({
}: {
entries: FileTreeEntry[];
error: string | null;
expandedDirs: ReadonlySet<string>;
loading: boolean;
onAction(action: FileAction, entry: FileTreeEntry): void;
onCreateRoot(kind: "file" | "directory"): void;
onOpen(path: string): void;
onRefresh(): void;
onToggleDirectory(path: string): void;
query: string;
rootName: string;
selectedPath: string | null;
setQuery(value: string): void;
showAnnotate: boolean;
truncated: boolean;
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());

// Reveal a newly selected file by expanding every folder above it, so a
// file created or opened inside a collapsed folder appears in the tree
// instead of only in the editor.
useEffect(() => {
if (selectedPath === null) return;
const ancestors: string[] = [];
let parent = parentPath(selectedPath);
while (parent.length > 0) {
ancestors.push(parent);
parent = parentPath(parent);
}
if (ancestors.length === 0) return;
setExpanded((current) => {
let next = current;
for (const ancestor of ancestors) {
if (!next.has(ancestor)) {
if (next === current) next = new Set(current);
next.add(ancestor);
}
}
return next;
});
}, [selectedPath]);

const visibleEntries = useMemo(() => {
if (query.length > 0) return searchSortEntries(entries);
return filterVisibleEntries(orderTreeEntries(entries), expanded);
}, [entries, expanded, query]);
return filterVisibleEntries(orderTreeEntries(entries), expandedDirs);
}, [entries, expandedDirs, query]);

return (
<aside className="flex h-full min-h-0 min-w-0 flex-col bg-background">
Expand Down Expand Up @@ -290,19 +266,12 @@ export function TreePane({
<TreeRow
key={entry.path}
entry={entry}
expanded={expanded.has(entry.path)}
expanded={expandedDirs.has(entry.path)}
selected={selectedPath === entry.path}
onAction={onAction}
onOpen={onOpen}
showAnnotate={showAnnotate}
onToggle={(path) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
})
}
onToggle={onToggleDirectory}
/>
))
)}
Expand Down
18 changes: 18 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ export const filesRpcContract = defineRpcContract({
})
.strict(),
},
// Single-level directory listing for the lazily-expanding tree. Unlike
// listTree (a recursive walk used only for search), this reads exactly one
// directory so expanding a folder costs one shallow call instead of
// re-scanning the whole workspace.
listDirectory: {
input: z
.object({ threadId: threadIdSchema, path: z.string() })
.strict(),
output: z
.object({
path: z.string(),
entries: z.array(treeEntrySchema),
// Present only for the root ("") listing.
rootName: z.string().min(1).optional(),
annotateAvailable: z.boolean().optional(),
})
.strict(),
},
readFile: {
input: z
.object({ threadId: threadIdSchema, path: targetPathSchema })
Expand Down
Loading