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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ current `^0.1.0` range are selected from those tags.
- 10-second tree/file external-change polling;
- create, rename (safely preserves unsaved drafts), duplicate, recursive delete, copy file content, copy relative path, and **download** actions;
- optional **MD Annotate integration** for opening Markdown files in a review/commenting tab;
- optional **SQL integration** for opening `.sql` files with the preferred host opener
([yazydzhi/bb-plugin-sql](https://github.com/yazydzhi/bb-plugin-sql));
- **Open with preferred…** on any file — reopens via BB’s host file flow so
**Settings → File openers** apply (the in-panel editor itself does not);
- narrow panel navigation with a Back control;
- symlinks and `node_modules` remain excluded by BB's host lister.

Expand Down Expand Up @@ -91,6 +95,30 @@ The integration intentionally uses BB's standard file-open flow. Consequently,
if Annotate is installed but is not the configured default for that extension,
the action opens whichever viewer the client selected instead.

## SQL integration

When a compatible, running `sql` plugin is detected, `.sql` files receive:

- a terminal icon in the active file toolbar (**Open in SQL**);
- **Open in SQL** in the file context menu.

Every file also gets **Open with preferred…** (toolbar + context menu), which
asks BB to reopen the workspace path through the host. That honors
**Settings → File openers** (e.g. `.sql` → **SQL**). Clicking a file in the
Files tree still uses Files’ built-in preview — use these actions to leave it.

### Compatibility and setup (SQL)

- BB `>=0.35.1` with Plugin SDK `^0.4.1`;
- SQL plugin id `sql` with a compatible app bundle;
- in **Settings → File openers**, set `.sql` to **SQL (sql)**.

```bash
bb plugin install git:https://github.com/yazydzhi/bb-plugin-sql.git@^0.1.0 --yes
# or from a local checkout:
# bb plugin install /path/to/bb-plugin-sql --yes
```

## Hand-off / Current Status

This is a comprehensive summary of the current implementation for future maintenance and feature development.
Expand Down
86 changes: 67 additions & 19 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, sqlAvailable: 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,8 +114,7 @@ describe("Files plugin app", () => {
positions: [],
},
],
truncated: false,
annotateAvailable: true,
annotateAvailable: true, sqlAvailable: false,
}),
readFile: () => ({
state: "text",
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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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, sqlAvailable: 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"]);
});
});
32 changes: 31 additions & 1 deletion src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@ function SaveLabel({ state, dirty }: { state: SaveState; dirty: boolean }) {

export function getFileIconForEditor(name: string) {
const lower = name.toLowerCase();
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|yaml|yml|sh|bash)$/.test(lower)) return "Code";
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|yaml|yml|sh|bash|sql)$/.test(lower)) return "Code";
if (/\.(md|txt|csv|log)$/.test(lower)) return "FileText";
if (/\.(png|jpg|jpeg|gif|svg|webp|ico|icns)$/.test(lower)) return "FileAttachment";
return "File";
}

function isSqlPath(path: string): boolean {
return /\.sql$/iu.test(path);
}

export function EditorPane({
tabs,
activePath,
Expand All @@ -62,6 +66,9 @@ export function EditorPane({
onDownload,
onOpenInAnnotate,
showAnnotate,
onOpenInSql,
showSql,
onOpenPreferred,
onToggleSidebar,
isSidebarOpen,
getDownloadUrl,
Expand All @@ -78,6 +85,9 @@ export function EditorPane({
onDownload(path: string): void;
onOpenInAnnotate(path: string): void;
showAnnotate: boolean;
onOpenInSql(path: string): void;
showSql: boolean;
onOpenPreferred(path: string): void;
onToggleSidebar?(): void;
isSidebarOpen?: boolean;
getDownloadUrl(path: string): Promise<string>;
Expand Down Expand Up @@ -204,6 +214,26 @@ export function EditorPane({
) : null}
{file !== null ? (
<>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Open with preferred opener"
onClick={() => onOpenPreferred(activePath!)}
>
<Icon name="ExternalLink" className="h-3.5 w-3.5" />
</Button>
{showSql && isSqlPath(activePath ?? "") ? (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Open in SQL"
onClick={() => onOpenInSql(activePath!)}
>
<Icon name="Terminal" className="h-3.5 w-3.5" />
</Button>
) : null}
{markdown && showAnnotate ? (
<Button
size="icon"
Expand Down
26 changes: 23 additions & 3 deletions src/components/FileContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,35 @@ export type FileAction =
| "delete"
| "copy-path"
| "download"
| "annotate";
| "annotate"
| "open-sql"
| "open-preferred";

function isMarkdownPath(path: string): boolean {
return /\.(?:md|mdx|markdown)$/iu.test(path);
}

function isSqlPath(path: string): boolean {
return /\.sql$/iu.test(path);
}

export function FileContextMenu({
children,
entry,
onAction,
showAnnotate,
showSql,
}: {
children: ReactNode;
entry: FileTreeEntry;
onAction(action: FileAction, entry: FileTreeEntry): void;
showAnnotate: boolean;
showSql: boolean;
}) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuContent className="w-56">
{entry.kind === "directory" ? (
<>
<ContextMenuItem onSelect={() => onAction("create-file", entry)}>
Expand All @@ -48,7 +60,15 @@ export function FileContextMenu({
</>
) : (
<>
{showAnnotate && /\.(?:md|mdx|markdown)$/iu.test(entry.path) ? (
<ContextMenuItem onSelect={() => onAction("open-preferred", entry)}>
<Icon name="ExternalLink" /> Open with preferred…
</ContextMenuItem>
{showSql && isSqlPath(entry.path) ? (
<ContextMenuItem onSelect={() => onAction("open-sql", entry)}>
<Icon name="Terminal" /> Open in SQL
</ContextMenuItem>
) : null}
{showAnnotate && isMarkdownPath(entry.path) ? (
<ContextMenuItem onSelect={() => onAction("annotate", entry)}>
<Icon name="MessageSquare" /> Open in Annotate
</ContextMenuItem>
Expand Down
Loading