diff --git a/README.md b/README.md index 8bb0be0..be8b5e5 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/app.test.tsx b/app.test.tsx index 1f313b8..cd018af 100644 --- a/app.test.tsx +++ b/app.test.tsx @@ -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: [ { @@ -74,7 +75,7 @@ describe("Files plugin app", () => { positions: [], }, ], - truncated: false, + annotateAvailable: false, sqlAvailable: false, }), readFile: () => ({ state: "text", @@ -101,7 +102,8 @@ describe("Files plugin app", () => { const openFile = vi.fn(() => ({ delivered: 1 })); setRpcHandlers({ openFile, - listTree: () => ({ + listDirectory: () => ({ + path: "", rootName: "repo", entries: [ { @@ -112,8 +114,7 @@ describe("Files plugin app", () => { positions: [], }, ], - truncated: false, - annotateAvailable: true, + annotateAvailable: true, sqlAvailable: false, }), readFile: () => ({ state: "text", @@ -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" && @@ -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" }); @@ -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(); 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()); @@ -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); @@ -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(); @@ -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 }; @@ -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()); @@ -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" }), }); @@ -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()); @@ -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 }; @@ -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", @@ -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"]); + }); }); diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index f1a716f..9524721 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -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, @@ -62,6 +66,9 @@ export function EditorPane({ onDownload, onOpenInAnnotate, showAnnotate, + onOpenInSql, + showSql, + onOpenPreferred, onToggleSidebar, isSidebarOpen, getDownloadUrl, @@ -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; @@ -204,6 +214,26 @@ export function EditorPane({ ) : null} {file !== null ? ( <> + + {showSql && isSqlPath(activePath ?? "") ? ( + + ) : null} {markdown && showAnnotate ? (