diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 1b8cd16b8..fd24bf7b3 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -6,7 +6,6 @@ import { ArrowDown, ArrowRightLeft, ArrowUp, - BookOpen, Braces, CheckSquare, Clipboard, @@ -1463,16 +1462,6 @@ function openDiagram(row: ObjectBrowserRow) { }; } -function openDocs(row: ObjectBrowserRow) { - // The docs viewer documents the whole schema rather than one object, so the - // row only supplies which schema to collect. - connectionStore.docsSource = { - connectionId: props.connection.id, - database: props.database, - schema: row.schema || selectedSchema.value, - }; -} - function openTableImport(row: ObjectBrowserRow) { if (row.type !== "TABLE") return; connectionStore.tableImportSource = { @@ -2663,12 +2652,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { ...(canOpenStructureEditor.value ? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }] : []), ...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []), { label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare }, - ...(canOpenDiagram.value - ? [ - { label: t("diagram.open"), action: () => openDiagram(item), icon: Network }, - { label: t("docs.title"), action: () => openDocs(item), icon: BookOpen }, - ] - : []), + ...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []), ...(canOpenTableImport.value ? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }] : []), { label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft }, { label: "", separator: true }, @@ -2718,12 +2702,7 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { }, ...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []), { label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare }, - ...(canOpenDiagram.value - ? [ - { label: t("diagram.open"), action: () => openDiagram(item), icon: Network }, - { label: t("docs.title"), action: () => openDocs(item), icon: BookOpen }, - ] - : []), + ...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []), { label: "", separator: true }, exportDataSubmenu(item), { label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload }, diff --git a/apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts b/apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts new file mode 100644 index 000000000..a484a8719 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +function source(relative: string): string { + return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8"); +} + +/** + * Slices the body of one sidebar menu builder out of SidebarTreeRuntimeHost.vue. + * Matching the builder rather than a formatted line keeps this guard indifferent + * to how oxfmt lays the menu pushes out. + */ +function sidebarMenuBuilder(name: string): string { + const text = source("../../sidebar/SidebarTreeRuntimeHost.vue"); + const start = text.indexOf(`function ${name}(`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + const next = text.indexOf("\nfunction ", start + 1); + return text.slice(start, next === -1 ? undefined : next); +} + +describe("documentation menu placement", () => { + it("registers the entry on database and schema nodes", () => { + // The docs viewer documents a database or a schema, so it belongs on the + // tree nodes that name one. buildDatabaseSidebarMenu serves both types. + expect(sidebarMenuBuilder("buildDatabaseSidebarMenu")).toContain("docs.title"); + }); + + it("keeps the entry off the sidebar's per-object menu", () => { + expect(sidebarMenuBuilder("buildObjectSidebarMenu")).not.toContain("docs.title"); + }); + + it("keeps the entry off the object browser's menus", () => { + // openDocs never read the row's table: a table-level entry advertised a + // scope the viewer cannot render. + expect(source("../ObjectBrowser.vue")).not.toContain("docs.title"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 50a5c633b..eaa5dc2cd 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -11,6 +11,7 @@ import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRunti import { useI18n } from "vue-i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import { + BookOpen, Database, ChevronsDown, FolderOpen, @@ -315,7 +316,7 @@ const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData, acceptedSelectionIds: () => acceptedSelectionIds, }); -const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openFieldLineage, openScheduledBackups, openSchemaDiff, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({ +const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({ activeNode, connectionStore, queryStore, @@ -4203,6 +4204,7 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { } if (canOpenDiagram.value) { items.push({ label: t("diagram.open"), action: openDiagram, icon: Network }); + items.push({ label: t("docs.title"), action: openDocs, icon: BookOpen }); } if (canOpenDatabaseSearch.value) { items.push({ label: t("databaseSearch.open"), action: openDatabaseSearch, icon: Search }); diff --git a/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts new file mode 100644 index 000000000..2b9144a83 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts @@ -0,0 +1,45 @@ +import { shallowRef } from "vue"; +import { describe, expect, it } from "vitest"; +import type { TreeNode } from "@/types/database"; + +import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRuntime"; + +function setup(node: Partial) { + const activeNode = shallowRef({ id: "n-1", label: "node", children: [], ...node } as TreeNode); + const connectionStore = { docsSource: null as unknown }; + const runtime = useSidebarTreeToolRuntime({ + activeNode, + connectionStore: connectionStore as never, + queryStore: {} as never, + settingsStore: {} as never, + tableChildObjectName: () => "", + }); + return { connectionStore, runtime }; +} + +describe("useSidebarTreeToolRuntime openDocs", () => { + it("documents the whole database when invoked on a database node", () => { + const { connectionStore, runtime } = setup({ type: "database", label: "shop", connectionId: "conn-1", database: "shop" }); + + runtime.openDocs(); + + // An absent schema is what makes the collector document every schema. + expect(connectionStore.docsSource).toEqual({ connectionId: "conn-1", database: "shop", schema: undefined }); + }); + + it("narrows to a single schema when invoked on a schema node", () => { + const { connectionStore, runtime } = setup({ type: "schema", label: "public", connectionId: "conn-1", database: "shop", schema: "public" }); + + runtime.openDocs(); + + expect(connectionStore.docsSource).toEqual({ connectionId: "conn-1", database: "shop", schema: "public" }); + }); + + it("does nothing when the node carries no database", () => { + const { connectionStore, runtime } = setup({ type: "connection", label: "local", connectionId: "conn-1" }); + + runtime.openDocs(); + + expect(connectionStore.docsSource).toBeNull(); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts index 4d52c1af8..dfe8e6b25 100644 --- a/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeToolRuntime.ts @@ -63,6 +63,18 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions }; } + function openDocs() { + const node = activeNode.value; + if (!node.connectionId || !node.database) return; + connectionStore.docsSource = { + connectionId: node.connectionId, + database: node.database, + // A database node has no schema, and an absent schema is what tells the + // collector to document every schema in the database. + schema: node.schema, + }; + } + function openDatabaseSearch() { const node = activeNode.value; if (!node.connectionId || !node.database) return; @@ -135,6 +147,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions openDatabaseExport, openDatabaseSearch, openDiagram, + openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, diff --git a/docs/superpowers/plans/2026-08-07-docs-menu-database-scope.md b/docs/superpowers/plans/2026-08-07-docs-menu-database-scope.md new file mode 100644 index 000000000..c4d68ad88 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-docs-menu-database-scope.md @@ -0,0 +1,309 @@ +# Documentation Menu at Database Scope Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the "Documentation" context-menu entry out of the object browser's table and view menus and into the sidebar's database and schema context menu, so the entry sits at the scope it actually documents. + +**Architecture:** A new `openDocs` action in `useSidebarTreeToolRuntime` writes `connectionStore.docsSource` from the active tree node, exactly as the neighbouring `openDiagram` does. One `items.push` in `buildDatabaseSidebarMenu` exposes it on database and schema nodes. The two `ObjectBrowser.vue` entries and their helper are deleted. No backend, dialog, or viewer code changes. + +**Tech Stack:** Vue 3 SFC with `