From fe1bd63a64525c54345383cb9bc0a41f15142175 Mon Sep 17 00:00:00 2001 From: "huy.phan9" <32996648+huyplb@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:05:41 -0600 Subject: [PATCH 01/17] feat(ui): v3 activity rail, Snapshots workspace, cheaper loads (#373) * perf: reuse access catalogs and stop N+1 schema/history loads Cache POST /schema/db-access for 60s so Access tabs share one catalog probe, invalidate after GRANT/REVOKE, and stop warming every SQL destination on editor mount (the explorer connection already loads). Snapshot metadata updates read the one row that changed instead of 500 versions, inspect reuses that list for growth, and Lokee GET routes require schema.browse. SQL sidebar opens one section at a time so first paint is a single panel. Co-authored-by: huy.phan9 * feat(ui): v3 activity rail and timeline-first Snapshots Move workspace switching to a left rail (Sync, SQL, Access, Snapshots) and drop the stacked toolbar switcher. Schema history is its own Snapshots view with a version timeline by default; React Flow loads only when Graph is toggled. Compare keeps a +/~/- briefing from the existing DTO, and Access control stays on admin.users / admin.roles. Co-authored-by: huy.phan9 --------- Co-authored-by: Cursor Agent Co-authored-by: huy.phan9 --- apps/e2e/src/pages/LokeeHistoryPage.ts | 4 + apps/e2e/src/pages/SqlEditorPage.ts | 1 + apps/e2e/src/tests/dialects/shared-flow.ts | 4 + apps/e2e/src/tests/smoke.test.ts | 10 +- apps/web/src/frontend/App.tsx | 12 +- .../src/frontend/app/shell/ActivityRail.tsx | 100 +++++++++++++++ .../frontend/app/shell/ProfileMenu.test.tsx | 6 +- .../src/frontend/app/shell/ProfileMenu.tsx | 3 + .../web/src/frontend/app/shell/TopToolbar.tsx | 121 ++++++------------ .../src/frontend/app/store/uiStore.test.ts | 41 ++++++ apps/web/src/frontend/app/store/uiStore.ts | 35 +++-- .../features/lokee-weave/api/lokeeApi.ts | 11 ++ .../components/LokeeWeaveView.test.tsx | 83 +++++++----- .../lokee-weave/components/LokeeWeaveView.tsx | 81 ++++++++++-- .../components/VersionTimeline.tsx | 81 ++++++++++++ .../schema-diff/lib/diffBriefing.test.ts | 31 +++++ .../features/schema-diff/lib/diffBriefing.ts | 25 ++++ .../sql-editor/components/SqlEditorView.tsx | 11 -- .../SqlSidebarSection.order.test.ts | 24 ++++ .../components/SqlSidebarSection.tsx | 53 ++++++-- .../components/DatabaseAccessModal.tsx | 3 +- .../src/frontend/shared/api/schemaApi.test.ts | 47 ++++++- apps/web/src/frontend/shared/api/schemaApi.ts | 48 +++++-- .../src/features/history/history.routes.ts | 64 +++++---- .../features/history/lokee-weave.service.ts | 98 ++++++++------ 25 files changed, 748 insertions(+), 249 deletions(-) create mode 100644 apps/web/src/frontend/app/shell/ActivityRail.tsx create mode 100644 apps/web/src/frontend/app/store/uiStore.test.ts create mode 100644 apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.tsx create mode 100644 apps/web/src/frontend/features/schema-diff/lib/diffBriefing.test.ts create mode 100644 apps/web/src/frontend/features/schema-diff/lib/diffBriefing.ts diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index b3d006b8..0b7ef55b 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -66,6 +66,10 @@ export class LokeeHistoryPage { } async waitForGraph(timeoutMs = 30_000): Promise { + const graphPage = this.page.locator('[data-testid="lokee-weave-page"]'); + if (!(await graphPage.isVisible().catch(() => false))) { + await clickWhen(this.page, '[data-testid="lokee-graph-toggle"]'); + } await waitFor(this.page, '[data-testid="lokee-weave-page"]', timeoutMs); await this.page.locator('[data-testid^="rf-version-"]').first().waitFor({ timeout: timeoutMs }); } diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 081fc9bf..e400cf49 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -430,6 +430,7 @@ export class SqlEditorPage { localStorage.removeItem('foxschema-sql-sidebar-section-heights'); localStorage.removeItem('foxschema-sql-sidebar-order'); localStorage.removeItem('foxschema-sql-sidebar-order-schema-top-v1'); + localStorage.removeItem('foxschema-sql-sidebar-exclusive-v1'); // Width and collapsed state too: a run that inherits a narrow sidebar // from an earlier one is testing a different layout than the next run, // which is how a real explorer layout bug surfaced here as an diff --git a/apps/e2e/src/tests/dialects/shared-flow.ts b/apps/e2e/src/tests/dialects/shared-flow.ts index d9e06f2b..4f2e0000 100644 --- a/apps/e2e/src/tests/dialects/shared-flow.ts +++ b/apps/e2e/src/tests/dialects/shared-flow.ts @@ -232,6 +232,10 @@ export function runDialectFlow( it.skipIf(skipMigration)('schema history pane records a Lokee snapshot after migrate', async () => { await clickWhen(driver, '[data-testid="sync-pane-history-btn"]'); await driver.waitForSelector('[data-testid="lokee-weave-view"]', { timeout: 20_000 }); + const graphToggle = driver.locator('[data-testid="lokee-graph-toggle"]'); + if (await graphToggle.isVisible().catch(() => false)) { + await graphToggle.click(); + } const graph = driver.locator('[data-testid="lokee-weave-page"]'); const hasGraph = await graph .waitFor({ state: 'visible', timeout: 20_000 }) diff --git a/apps/e2e/src/tests/smoke.test.ts b/apps/e2e/src/tests/smoke.test.ts index 79c43b4a..a179ba18 100644 --- a/apps/e2e/src/tests/smoke.test.ts +++ b/apps/e2e/src/tests/smoke.test.ts @@ -30,15 +30,15 @@ describe('App boot', () => { expect(await driver.locator('[data-testid="toolbar"]').isVisible()).toBe(true); }); - it('puts schema history inside Schema Sync, not a standalone tab', async () => { + it('puts schema history on the Snapshots rail, not a standalone tab', async () => { expect(await driver.locator('[data-testid="view-lokee-weave-btn"]').count()).toBe(0); - expect(await driver.locator('[data-testid="sync-pane-switcher"]').isVisible()).toBe(true); + expect(await driver.locator('[data-testid="workspace-switcher"]').isVisible()).toBe(true); await driver.locator('[data-testid="sync-pane-history-btn"]').click(); await driver.waitForSelector('[data-testid="lokee-weave-view"]', { timeout: 15_000 }); expect(await driver.locator('[data-testid="lokee-weave-view"]').isVisible()).toBe(true); - expect(await driver.locator('[data-testid="workspace-switcher"]').count()).toBe(0); - expect(await driver.locator('[data-testid="lokee-history-compare-bar"]').isVisible()).toBe(true); - await driver.locator('[data-testid="sync-pane-compare-btn"]').click(); expect(await driver.locator('[data-testid="workspace-switcher"]').isVisible()).toBe(true); + expect(await driver.locator('[data-testid="lokee-history-compare-bar"]').isVisible()).toBe(true); + await driver.locator('[data-testid="view-sync-btn"]').click(); + expect(await driver.locator('[data-testid="sync-pane-switcher"]').isVisible()).toBe(true); }); }); diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index d8e362b0..fe8cad91 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -1,5 +1,6 @@ import React, { Suspense, lazy, useEffect } from 'react'; import { TopToolbar } from '@/app/shell/TopToolbar'; +import { ActivityRail } from '@/app/shell/ActivityRail'; import { SchemaTreePanel } from '@/features/sql-editor'; import { ObjectDetailPanel } from '@/features/object-detail'; import { ErrorBoundary } from '@/app/shell/ErrorBoundary'; @@ -27,7 +28,6 @@ const LokeeWeaveView = lazy(() => const Workspace: React.FC = () => { const { errorMsg, warnings, dismissWarnings } = useSyncStore(); const activeView = useUiStore((s) => s.activeView); - const syncPane = useUiStore((s) => s.syncPane); const setActiveView = useUiStore((s) => s.setActiveView); const canEditorAccess = useAuthStore((s) => s.can('editor.access')); const canSchemaBrowse = useAuthStore((s) => s.can('schema.browse')); @@ -37,6 +37,9 @@ const Workspace: React.FC = () => { if (activeView === 'sqlEditor' && !canEditorAccess) { setActiveView('sync'); } + if (activeView === 'snapshots' && !canSchemaBrowse) { + setActiveView('sync'); + } if ( activeView === 'sync' && !canSchemaBrowse && @@ -54,7 +57,9 @@ const Workspace: React.FC = () => { ]); return ( -
+
+ +
{/* Above every other banner: when the backend is gone, nothing else on @@ -100,7 +105,7 @@ const Workspace: React.FC = () => { - ) : syncPane === 'history' && canSchemaBrowse ? ( + ) : activeView === 'snapshots' && canSchemaBrowse ? ( }>
@@ -120,6 +125,7 @@ const Workspace: React.FC = () => { )} +
); }; diff --git a/apps/web/src/frontend/app/shell/ActivityRail.tsx b/apps/web/src/frontend/app/shell/ActivityRail.tsx new file mode 100644 index 00000000..b5a86f82 --- /dev/null +++ b/apps/web/src/frontend/app/shell/ActivityRail.tsx @@ -0,0 +1,100 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Left activity rail: one workspace at a time. Replaces the stacked + * Workspace switcher that lived in the top toolbar. + */ +import React from 'react'; +import { Camera, GitCompareArrows, ShieldCheck, Terminal } from 'lucide-react'; +import { useAuthStore } from '@/app/store/authStore'; +import { useUiStore, type ActiveView } from '@/app/store/uiStore'; +import { FoxLogo } from './FoxLogo'; + +const ITEMS: { + view: ActiveView; + testId: string; + label: string; + icon: React.ElementType; + permission: 'schema' | 'editor' | 'access' | 'snapshots'; +}[] = [ + { + view: 'sync', + testId: 'view-sync-btn', + label: 'Sync', + icon: GitCompareArrows, + permission: 'schema', + }, + { + view: 'sqlEditor', + testId: 'view-sql-editor-btn', + label: 'SQL', + icon: Terminal, + permission: 'editor', + }, + { + view: 'access', + testId: 'view-access-btn', + label: 'Access', + icon: ShieldCheck, + permission: 'access', + }, + { + view: 'snapshots', + testId: 'sync-pane-history-btn', + label: 'Snapshots', + icon: Camera, + permission: 'snapshots', + }, +]; + +export function ActivityRail(): React.ReactElement | null { + const activeView = useUiStore((s) => s.activeView); + const setActiveView = useUiStore((s) => s.setActiveView); + const canSchemaBrowse = useAuthStore((s) => s.can('schema.browse')); + const canSchemaCompare = useAuthStore((s) => s.can('schema.compare')); + const canEditorAccess = useAuthStore((s) => s.can('editor.access')); + + const allowed = (permission: (typeof ITEMS)[number]['permission']): boolean => { + if (permission === 'schema') return canSchemaBrowse || canSchemaCompare; + if (permission === 'editor') return canEditorAccess; + if (permission === 'snapshots') return canSchemaBrowse; + return true; + }; + + const visible = ITEMS.filter((item) => allowed(item.permission)); + if (visible.length === 0) return null; + + return ( + + ); +} diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx index 465f517b..ff6c6f1a 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx @@ -49,7 +49,7 @@ describe('ProfileMenu', () => { expect(screen.getByTestId('admin-access-panel')).toBeTruthy(); }); - it('still offers Access control when the signed-in role is editor', () => { + it('hides Access control when the signed-in role cannot manage app users', () => { useAuthStore.setState({ user: { id: 'u-editor', @@ -62,8 +62,6 @@ describe('ProfileMenu', () => { }); render(); fireEvent.click(screen.getByText('editor@example.com')); - expect(screen.getByTestId('profile-access-control')).toBeTruthy(); - fireEvent.click(screen.getByTestId('profile-access-control')); - expect(screen.getByTestId('admin-access-panel')).toBeTruthy(); + expect(screen.queryByTestId('profile-access-control')).toBeNull(); }); }); diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 55bc4aaa..5d6863b6 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -14,6 +14,7 @@ const SettingsPanel = lazy(() => export function ProfileMenu(): React.ReactElement | null { const { user, logout, localSingleUser } = useAuthStore(); + const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles')); const [open, setOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showAdmin, setShowAdmin] = useState(false); @@ -112,6 +113,7 @@ export function ProfileMenu(): React.ReactElement | null { User Preference + {canAdminAccess && ( + )} { const [showCredentials, setShowCredentials] = useState(false); const [showHistory, setShowHistory] = useState(false); const [capturingSnapshot, setCapturingSnapshot] = useState(false); - const { activeView, setActiveView, syncPane, setSyncPane, bumpLokeeEpoch } = useUiStore(); + const { activeView, syncPane, setSyncPane, bumpLokeeEpoch } = useUiStore(); const canSchemaBrowse = useAuthStore((s) => s.can('schema.browse')); const canSchemaCompare = useAuthStore((s) => s.can('schema.compare')); @@ -79,8 +80,6 @@ export const TopToolbar: React.FC = () => { */ const compareBlockedBy = schemaCompareBlocker(sourceConfig.dialect, targetConfig.dialect); - const canEditorAccess = useAuthStore((s) => s.can('editor.access')); - // A saved connection created without a stored password ("Save password" left // unticked) has no password to apply automatically — selecting it from either // dropdown must prompt for a session-only password instead of connecting with none. @@ -184,6 +183,8 @@ export const TopToolbar: React.FC = () => { ? (compareResult?.tables.length ?? 0) : (compareResult?.tables.filter((t) => t.objectType === type).length ?? 0); + const briefing = diffBriefing(compareResult?.tables); + const objectScopeOptions: { type: DbObjectType; label: string }[] = [ { type: 'TABLE', label: 'Tables' }, { type: 'MQT', label: 'MQTs' }, @@ -197,11 +198,10 @@ export const TopToolbar: React.FC = () => { ]; return ( -
- {/* Brand + utilities. Workspace tabs sit on the next row so Lokee Weave - cannot wrap under the logo and disappear in a narrow Cursor preview. */} +
+ {/* One chrome row: brand is on the rail; this bar is actions only. */}
- +
{/* Only renders while something is actually running. */} @@ -218,7 +218,7 @@ export const TopToolbar: React.FC = () => { onClick={() => setShowHistory(true)} className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-semibold text-slate-300 hover:text-slate-100 border border-slate-700 hover:border-slate-500 rounded-md transition cursor-pointer" > - History + Applies {compareResult && activeView === 'sync' && syncPane === 'compare' && (
- {/* Full-width workspace switcher — hidden on History so the version - Original → Target bar can reuse Compare's mental model. */} - {(canSchemaBrowse || canSchemaCompare || canEditorAccess) && - !(activeView === 'sync' && syncPane === 'history') && ( -
- - Workspace - - {(canSchemaBrowse || canSchemaCompare) && ( - - )} - - {canEditorAccess && ( - - )} -
- )} - {/* Sync-only controls — the SQL Editor view brings its own left panel. */} {activeView === 'sync' && ( <> @@ -321,19 +270,6 @@ export const TopToolbar: React.FC = () => { > Browse -
)} - {/* Database Connection Control Grid. - Hidden in History: that pane compares two points in this database's - own recorded past, so a live Original/Target pair says nothing about - what is on screen — History gets the version bar in its place, which - is the same Original → Target gesture over stored versions. */} - {syncPane === 'history' && } {syncPane === 'browse' && } {syncPane === 'compare' && (
@@ -610,6 +540,18 @@ export const TopToolbar: React.FC = () => { )} + {compareResult && ( +
+ +{briefing.added} + ~{briefing.modified} + −{briefing.removed} +
+ )} + +
+ )} + + + )} + + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { migrateUiPersist } from './uiStore'; + +describe('migrateUiPersist', () => { + it('moves the old History pane onto the Snapshots workspace', () => { + const next = migrateUiPersist( + { activeView: 'sync', syncPane: 'history', lokeeEpoch: 3 }, + 1 + ) as { activeView: string; syncPane: string; lokeeEpoch: number }; + expect(next.activeView).toBe('snapshots'); + expect(next.syncPane).toBe('compare'); + expect(next.lokeeEpoch).toBe(3); + }); + + it('rewrites the standalone lokeeWeave view the same way', () => { + const next = migrateUiPersist({ activeView: 'lokeeWeave' }, 0) as { + activeView: string; + syncPane: string; + }; + expect(next.activeView).toBe('snapshots'); + expect(next.syncPane).toBe('compare'); + }); + + it('leaves Compare and Browse on Schema Sync', () => { + const compare = migrateUiPersist({ activeView: 'sync', syncPane: 'compare' }, 2) as { + activeView: string; + syncPane: string; + }; + expect(compare).toMatchObject({ activeView: 'sync', syncPane: 'compare' }); + const browse = migrateUiPersist({ activeView: 'sync', syncPane: 'browse' }, 2) as { + activeView: string; + syncPane: string; + }; + expect(browse).toMatchObject({ activeView: 'sync', syncPane: 'browse' }); + }); +}); diff --git a/apps/web/src/frontend/app/store/uiStore.ts b/apps/web/src/frontend/app/store/uiStore.ts index 6140dc93..060b578e 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -175,17 +175,20 @@ function applyToDocument(themeMode: ThemeMode, tone: ToneId, fontSize: FontSize, return mode; } -/** Top-level workspace views: schema sync (compare + history) vs the SQL Editor. */ -export type ActiveView = 'sync' | 'sqlEditor' | 'access'; -/** Compare tree vs Lokee schema-history graph, both inside Schema Sync. */ +/** Top-level workspace views. Snapshots (Lokee) is its own view, not a Sync pane. */ +export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots'; /** * Browse is its own pane, not a mode hiding inside Compare. It answers a * different question — "what is in this one database?" rather than "how do * these two differ?" — and reaching it by pressing a button on one of Compare's * two connection cards left the app showing a comparison workspace with no * comparison in it. + * + * History used to live here as `syncPane === 'history'`. It is now `activeView + * === 'snapshots'`. `setSyncPane('history')` still routes there so callers and + * persisted state do not strand anyone on a removed pane. */ -export type SyncPane = 'compare' | 'browse' | 'history'; +export type SyncPane = 'compare' | 'browse'; interface UiState { themeMode: ThemeMode; @@ -202,7 +205,8 @@ interface UiState { lokeeEpoch: number; setActiveView: (view: ActiveView) => void; - setSyncPane: (pane: SyncPane) => void; + /** `'history'` is accepted as an alias for `activeView: 'snapshots'`. */ + setSyncPane: (pane: SyncPane | 'history') => void; bumpLokeeEpoch: () => void; setThemeMode: (mode: ThemeMode) => void; setTone: (tone: ToneId) => void; @@ -230,14 +234,17 @@ function syncToServer(s: Pick undefined); } -function migrateUiPersist(persisted: unknown, _version: number): unknown { +export function migrateUiPersist(persisted: unknown, _version: number): unknown { const state = persisted && typeof persisted === 'object' ? { ...(persisted as Record) } : {}; - if (state.activeView === 'lokeeWeave') { + if (state.activeView === 'lokeeWeave' || state.syncPane === 'history') { + state.activeView = 'snapshots'; + state.syncPane = 'compare'; + } + if (!['sync', 'sqlEditor', 'access', 'snapshots'].includes(state.activeView as string)) { state.activeView = 'sync'; - state.syncPane = 'history'; } - if (!['history', 'compare', 'browse'].includes(state.syncPane as string)) { + if (!['compare', 'browse'].includes(state.syncPane as string)) { state.syncPane = 'compare'; } if (typeof state.lokeeEpoch !== 'number') state.lokeeEpoch = 0; @@ -265,7 +272,13 @@ export const useUiStore = create()( lokeeEpoch: 0, setActiveView: (activeView) => set({ activeView }), - setSyncPane: (syncPane) => set({ syncPane, activeView: 'sync' }), + setSyncPane: (pane) => { + if (pane === 'history') { + set({ activeView: 'snapshots' }); + return; + } + set({ syncPane: pane, activeView: 'sync' }); + }, bumpLokeeEpoch: () => set({ lokeeEpoch: get().lokeeEpoch + 1 }), setThemeMode: (themeMode) => update({ themeMode }), setTone: (tone) => update({ tone }), @@ -297,7 +310,7 @@ export const useUiStore = create()( }, }; }, - { name: 'schema-sync-ui', version: 1, migrate: migrateUiPersist } + { name: 'schema-sync-ui', version: 2, migrate: migrateUiPersist } ) ); diff --git a/apps/web/src/frontend/features/lokee-weave/api/lokeeApi.ts b/apps/web/src/frontend/features/lokee-weave/api/lokeeApi.ts index 8c9205c7..efb24084 100644 --- a/apps/web/src/frontend/features/lokee-weave/api/lokeeApi.ts +++ b/apps/web/src/frontend/features/lokee-weave/api/lokeeApi.ts @@ -49,6 +49,17 @@ export async function listLokeeDatabases(): Promise { return body.databases ?? []; } +export async function listLokeeVersions( + databaseId: string, + limit = 100 +): Promise { + const body = await api.get<{ versions: LokeeVersion[] }>( + `/lokee/databases/${encodeURIComponent(databaseId)}/versions`, + { query: { limit } } + ); + return body.versions ?? []; +} + /** The DTO the version graph renders. `limit` is a count of versions. */ export async function loadVersionGraph( databaseId: string, diff --git a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.test.tsx b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.test.tsx index 7b1fc06b..af7a8276 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.test.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.test.tsx @@ -13,11 +13,13 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import type { VersionGraphDTO } from './graphTypes'; const listLokeeDatabases = vi.fn(); +const listLokeeVersions = vi.fn(); const loadVersionGraph = vi.fn(); const captureSchema = vi.fn(); vi.mock('@/features/lokee-weave/api/lokeeApi', () => ({ listLokeeDatabases: (...args: unknown[]) => listLokeeDatabases(...args), + listLokeeVersions: (...args: unknown[]) => listLokeeVersions(...args), loadVersionGraph: (...args: unknown[]) => loadVersionGraph(...args), captureSchema: (...args: unknown[]) => captureSchema(...args), })); @@ -86,6 +88,7 @@ const DTO: VersionGraphDTO = { beforeEach(() => { listLokeeDatabases.mockReset(); + listLokeeVersions.mockReset(); loadVersionGraph.mockReset(); captureSchema.mockReset(); vi.mocked(toast).mockReset(); @@ -112,38 +115,53 @@ beforeEach(() => { }); describe('LokeeWeaveView', () => { - it('renders the graph once history arrives', async () => { + it('renders the timeline once history arrives, without fetching the graph', async () => { listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); render(); - await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy()); - // The subtitle must name the database, not the saved connection. - // ` · schema`, not `.schema`: on SQLite the dotted form rendered as - // `/tmp/app.db.main` and read like a file extension. - expect(screen.getByTestId('graph').textContent).toContain( + await waitFor(() => expect(screen.getByTestId('lokee-timeline')).toBeTruthy()); + expect(screen.getByTestId('lokee-summary').textContent).toContain('2 versions'); + expect(loadVersionGraph).not.toHaveBeenCalled(); + expect(screen.getByTestId('lokee-summary').textContent).toContain( 'postgres · localhost/foxdb · public' ); - // The chrome row is gone — Refresh and Capture moved into HistoryCompareBar. expect(screen.queryByTestId('lokee-weave-chrome')).toBeNull(); }); + it('loads the graph only after the Graph toggle is pressed', async () => { + listLokeeDatabases.mockResolvedValue([DB]); + listLokeeVersions.mockResolvedValue(DTO.versions); + loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + + render(); + await waitFor(() => expect(screen.getByTestId('lokee-timeline')).toBeTruthy()); + + await act(async () => { + screen.getByTestId('lokee-graph-toggle').click(); + }); + + await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy()); + expect(loadVersionGraph).toHaveBeenCalledWith('db1', 20); + }); + it('shows an empty state rather than an empty canvas', async () => { listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, versions: [], truncatedObjects: false }); + listLokeeVersions.mockResolvedValue([]); render(); await waitFor(() => expect(screen.getByText('No schema history yet')).toBeTruthy()); - expect(screen.queryByTestId('graph')).toBeNull(); + expect(screen.queryByTestId('lokee-timeline')).toBeNull(); + expect(loadVersionGraph).not.toHaveBeenCalled(); }); it('puts the first snapshot within reach instead of describing where to find it', async () => { // The one screen where a newcomer has nothing to act on used to be the one // screen with no action on it — a paragraph pointing at a button elsewhere. listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, versions: [], truncatedObjects: false }); + listLokeeVersions.mockResolvedValue([]); render(); @@ -161,7 +179,7 @@ describe('LokeeWeaveView', () => { // An empty state here would be a lie: it says "nothing happened" when the // truth is "we could not find out". listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockRejectedValue(new Error('connection refused')); + listLokeeVersions.mockRejectedValue(new Error('connection refused')); render(); @@ -172,22 +190,27 @@ describe('LokeeWeaveView', () => { expect(screen.queryByText('No schema history yet')).toBeNull(); }); - it('still shows the graph when the database list fails', async () => { + it('still shows the timeline when the database list fails', async () => { // The list only supplies a label; losing it must not hide the history. listLokeeDatabases.mockRejectedValue(new Error('nope')); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); render(); - await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy()); - expect(screen.getByTestId('graph').textContent).toContain('no-subtitle'); + await waitFor(() => expect(screen.getByTestId('lokee-timeline')).toBeTruthy()); + expect(screen.getByTestId('lokee-summary').textContent).toContain('2 versions'); }); it('warns when the graph is showing only part of the schema', async () => { listLokeeDatabases.mockResolvedValue([DB]); + listLokeeVersions.mockResolvedValue(DTO.versions); loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: true }); render(); + await waitFor(() => expect(screen.getByTestId('lokee-timeline')).toBeTruthy()); + await act(async () => { + screen.getByTestId('lokee-graph-toggle').click(); + }); await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy()); expect(screen.getByText(/more objects than the graph draws/i)).toBeTruthy(); @@ -204,27 +227,23 @@ describe('LokeeWeaveView', () => { it('prefers an explicit databaseId over the most recent one', async () => { listLokeeDatabases.mockResolvedValue([DB, { ...DB, id: 'db2' }]); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); render(); - await waitFor(() => expect(loadVersionGraph).toHaveBeenCalled()); - expect(loadVersionGraph).toHaveBeenCalledWith('db2', 20); + await waitFor(() => expect(listLokeeVersions).toHaveBeenCalled()); + expect(listLokeeVersions).toHaveBeenCalledWith('db2', 100); + expect(loadVersionGraph).not.toHaveBeenCalled(); }); it('hides the in-graph database picker when embedded (toolbar owns Original → Target)', async () => { listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); render(); - // Wait on the store, not just on the graph: listing the databases and - // loading the version graph are two awaits, and the graph renders after the - // first. Asserting the store right after the graph appears therefore races - // the second, which is why this failed intermittently under CI load while - // passing every time in isolation. await waitFor(() => { - expect(screen.getByTestId('graph')).toBeTruthy(); + expect(screen.getByTestId('lokee-timeline')).toBeTruthy(); expect(useLokeeHistoryStore.getState().versions.map((v) => v.id)).toEqual(['v2', 'v1']); }); expect(screen.queryByTestId('lokee-database-select')).toBeNull(); @@ -232,11 +251,8 @@ describe('LokeeWeaveView', () => { }); it('captures when the toolbar bar asks for it', async () => { - // The credential picker and Capture button moved to HistoryCompareBar, which - // renders in TopToolbar. It asks by bumping the store counter, so that is - // the seam to test here rather than a button this component no longer owns. listLokeeDatabases.mockResolvedValueOnce([]).mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); captureSchema.mockResolvedValue({ databaseId: 'db1', versionId: 'v1', @@ -269,12 +285,13 @@ describe('LokeeWeaveView', () => { // A naive effect on the request counters would fire once per mount, so a // visit to History would silently snapshot the database. listLokeeDatabases.mockResolvedValue([DB]); - loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false }); + listLokeeVersions.mockResolvedValue(DTO.versions); render(); - await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy()); + await waitFor(() => expect(screen.getByTestId('lokee-timeline')).toBeTruthy()); expect(captureSchema).not.toHaveBeenCalled(); - expect(loadVersionGraph).toHaveBeenCalledTimes(1); + expect(listLokeeVersions).toHaveBeenCalledTimes(1); + expect(loadVersionGraph).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx index 3de6c18f..4955b9a5 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx @@ -14,10 +14,12 @@ import { Camera, GitBranch, Loader2, RefreshCw, TriangleAlert } from 'lucide-rea import { LokeeWeavePage } from './LokeeWeavePage'; import { VersionCompareModal } from './VersionCompareModal'; import { LokeeObjectInspector } from './LokeeObjectInspector'; +import { VersionTimeline } from './VersionTimeline'; import type { SchemaObjectNodeData, VersionGraphDTO } from './graphTypes'; import { captureSchema, listLokeeDatabases, + listLokeeVersions, loadVersionGraph, updateLokeeVersionMeta, type LokeeDatabase, @@ -93,6 +95,8 @@ export function LokeeWeaveView({ const [error, setError] = useState(null); const [selectedObject, setSelectedObject] = useState(null); + /** React Flow reconstructs object nodes — off until the reader asks. */ + const [showGraph, setShowGraph] = useState(false); // Bumped to re-run the effect; a plain refetch() would race the in-flight one. const [reloadToken, setReloadToken] = useState(0); // Which pair the modal is showing. The two *sides* live in the history store, @@ -214,9 +218,16 @@ export function LokeeWeaveView({ setError(null); void (async () => { try { - const graph = await loadVersionGraph(activeId, versionLimit); + // Timeline is GET /versions — cheap. The graph DTO reconstructs object + // nodes and is loaded only when the Graph toggle is on. + const versions = await listLokeeVersions(activeId, 100); if (cancelled) return; - setDto(graph); + setDto({ + ...EMPTY_DTO, + databaseId: activeId, + versions, + totalVersions: versions.length, + }); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'Failed to load schema history'); @@ -228,7 +239,25 @@ export function LokeeWeaveView({ return () => { cancelled = true; }; - }, [activeId, versionLimit, reloadToken, lokeeEpoch]); + }, [activeId, reloadToken, lokeeEpoch]); + + useEffect(() => { + if (!showGraph || !activeId) return; + let cancelled = false; + void (async () => { + try { + const graph = await loadVersionGraph(activeId, versionLimit); + if (cancelled) return; + setDto(graph); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Failed to load schema history'); + } + })(); + return () => { + cancelled = true; + }; + }, [showGraph, activeId, versionLimit, reloadToken, lokeeEpoch]); const subtitle = useMemo( () => describe(databases.find((d) => d.id === activeId)), @@ -476,23 +505,49 @@ export function LokeeWeaveView({ return (
- {dto.truncatedObjects && ( + {dto.truncatedObjects && showGraph && (
Showing the objects that changed in this window. This schema has more objects than the graph draws at once.
)} +
+ +
- setSelectedObject(null)} - onSaveVersionMeta={saveVersionMeta} - /> + {showGraph ? ( + setSelectedObject(null)} + onSaveVersionMeta={saveVersionMeta} + /> + ) : ( + useLokeeHistoryStore.getState().setOriginalVersionId(id)} + /> + )}
{comparePair && activeId && ( + * SPDX-License-Identifier: Apache-2.0 + * + * Default Snapshots workspace: a version list from GET /versions, not the + * React Flow graph (that reconstructs object nodes and is opt-in). + */ +import React from 'react'; +import type { VersionGraphVersion } from '@foxschema/shared'; + +export interface VersionTimelineProps { + versions: readonly (VersionGraphVersion & { changeCount?: number })[]; + totalVersions: number; + selectedId?: string | null; + subtitle?: string; + onSelect?: (versionId: string) => void; +} + +function barWidth(changeCount: number, max: number): string { + if (max <= 0 || changeCount <= 0) return '0%'; + return `${Math.max(4, Math.round((changeCount / max) * 100))}%`; +} + +export function VersionTimeline({ + versions, + totalVersions, + selectedId, + subtitle, + onSelect, +}: VersionTimelineProps): React.ReactElement { + const maxChanges = Math.max(0, ...versions.map((v) => v.changeCount ?? 0)); + return ( +
+
+ Schema history + · + + {totalVersions} versions + + {subtitle && {subtitle}} +
+
    + {versions.map((version) => { + const selected = version.id === selectedId; + return ( +
  1. + +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.test.ts b/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.test.ts new file mode 100644 index 00000000..3b3d165c --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.test.ts @@ -0,0 +1,31 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { diffBriefing } from './diffBriefing'; + +describe('diffBriefing', () => { + it('counts compare statuses from the DTO already in memory', () => { + expect( + diffBriefing([ + { status: 'ADDED' }, + { status: 'ADDED' }, + { status: 'MODIFIED' }, + { status: 'REMOVED' }, + { status: 'UNCHANGED' }, + { status: 'UNCHANGED' }, + ]) + ).toEqual({ added: 2, modified: 1, removed: 1, unchanged: 2 }); + }); + + it('treats a missing table list as empty', () => { + expect(diffBriefing(undefined)).toEqual({ + added: 0, + modified: 0, + removed: 0, + unchanged: 0, + }); + }); +}); diff --git a/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.ts b/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.ts new file mode 100644 index 00000000..16c75b70 --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/lib/diffBriefing.ts @@ -0,0 +1,25 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Counts already in a compare DTO — no extra query. + */ +export interface DiffBriefing { + added: number; + modified: number; + removed: number; + unchanged: number; +} + +export function diffBriefing(tables: readonly { status: string }[] | undefined): DiffBriefing { + const out: DiffBriefing = { added: 0, modified: 0, removed: 0, unchanged: 0 }; + if (!tables) return out; + for (const table of tables) { + if (table.status === 'ADDED') out.added += 1; + else if (table.status === 'REMOVED') out.removed += 1; + else if (table.status === 'MODIFIED') out.modified += 1; + else out.unchanged += 1; + } + return out; +} diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index 75be62de..6221634b 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -123,7 +123,6 @@ export const SqlEditorView: React.FC = () => { const resultsByTab = useSqlEditorStore((s) => s.resultsByTab); const runningTabId = useSqlEditorStore((s) => s.runningTabId); const pendingWriteConfirm = useSqlEditorStore((s) => s.pendingWriteConfirm); - const schemaCache = useSqlEditorStore((s) => s.schemaCache); const setSql = useSqlEditorStore((s) => s.setSql); const execute = useSqlEditorStore((s) => s.execute); const cancelWriteConfirm = useSqlEditorStore((s) => s.cancelWriteConfirm); @@ -135,7 +134,6 @@ export const SqlEditorView: React.FC = () => { const setActiveTab = useSqlEditorStore((s) => s.setActiveTab); const renameTab = useSqlEditorStore((s) => s.renameTab); const moveTab = useSqlEditorStore((s) => s.moveTab); - const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); const setMaxRows = useSqlEditorStore((s) => s.setMaxRows); const maxRows = useSqlEditorStore((s) => s.maxRows); const saveBookmark = useSqlEditorStore((s) => s.saveBookmark); @@ -234,15 +232,6 @@ export const SqlEditorView: React.FC = () => { return () => setSqlMutator(null); }, []); - // Warm schema cache for checked credentials (autocomplete). - useEffect(() => { - for (const id of liveSelectedIds) { - if (!schemaCache[id] || schemaCache[id]?.status === 'idle') { - void ensureSchema(id); - } - } - }, [liveSelectedIds.join(','), ensureSchema]); - const startEditorResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); const root = splitRef.current; diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.order.test.ts b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.order.test.ts index 3de115d2..f00e64a0 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.order.test.ts +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.order.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { moveSidebarSection, pinSchemaFirst, + exclusiveSidebarOpen, type SidebarSectionId, } from './SqlSidebarSection'; @@ -47,3 +48,26 @@ describe('SQL Editor sidebar order', () => { ]); }); }); + +describe('exclusiveSidebarOpen', () => { + const closed = { + destinations: false, + bookmarks: false, + variables: false, + vault: false, + utilities: false, + files: false, + schema: false, + }; + + it('opens one section and closes the others', () => { + expect(exclusiveSidebarOpen({ ...closed, schema: true }, 'utilities')).toEqual({ + ...closed, + utilities: true, + }); + }); + + it('allows closing the only open section', () => { + expect(exclusiveSidebarOpen({ ...closed, schema: true }, 'schema')).toEqual(closed); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx index a06406fe..f495beb6 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx @@ -16,15 +16,18 @@ export type SidebarSectionId = | 'schema'; const DEFAULT_OPEN: Record = { - destinations: true, - bookmarks: true, - variables: true, - vault: true, - utilities: true, - files: true, + destinations: false, + bookmarks: false, + variables: false, + vault: false, + utilities: false, + files: false, schema: true, }; +/** One-time: close every section except Schema so first paint is one panel. */ +const EXCLUSIVE_OPEN_MIGRATION_KEY = 'foxschema-sql-sidebar-exclusive-v1'; + const DEFAULT_HEIGHTS: Record = { destinations: 140, bookmarks: 120, @@ -109,6 +112,25 @@ export function moveSidebarSection( return next; } +/** Open one section; close the rest. Empty sidebar is allowed (toggle the open one off). */ +export function exclusiveSidebarOpen( + prev: Record, + id: SidebarSectionId +): Record { + const opening = !prev[id]; + const next: Record = { + destinations: false, + bookmarks: false, + variables: false, + vault: false, + utilities: false, + files: false, + schema: false, + }; + if (opening) next[id] = true; + return next; +} + /** Persist SQL-editor sidebar section order. */ export function useSidebarSectionOrder(): [ SidebarSectionId[], @@ -133,17 +155,20 @@ export function useSidebarSectionOrder(): [ function loadOpen(): Record { try { + if (!localStorage.getItem(EXCLUSIVE_OPEN_MIGRATION_KEY)) { + localStorage.setItem(EXCLUSIVE_OPEN_MIGRATION_KEY, '1'); + return { ...DEFAULT_OPEN }; + } const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_OPEN }; const parsed = JSON.parse(raw) as Partial>; return { - destinations: parsed.destinations ?? true, - bookmarks: parsed.bookmarks ?? true, - variables: parsed.variables ?? true, - // Prefer `vault`; accept legacy `secrets` key from older localStorage. - vault: parsed.vault ?? (parsed as { secrets?: boolean }).secrets ?? true, - utilities: parsed.utilities ?? true, - files: parsed.files ?? true, + destinations: parsed.destinations ?? false, + bookmarks: parsed.bookmarks ?? false, + variables: parsed.variables ?? false, + vault: parsed.vault ?? (parsed as { secrets?: boolean }).secrets ?? false, + utilities: parsed.utilities ?? false, + files: parsed.files ?? false, schema: parsed.schema ?? true, }; } catch { @@ -193,7 +218,7 @@ export function useSidebarSectionsOpen(): [ }, [open]); const toggle = (id: SidebarSectionId) => { - setOpen((prev) => ({ ...prev, [id]: !prev[id] })); + setOpen((prev) => exclusiveSidebarOpen(prev, id)); }; return [open, toggle]; diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx index b45689ae..02880c22 100644 --- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx @@ -28,7 +28,7 @@ import { } from '@foxschema/sql'; import { PERMISSION_META } from '@foxschema/shared'; import { PasswordInput } from '@/shared/components/PasswordInput'; -import { fetchDbAccess } from '@/shared/api/schemaApi'; +import { fetchDbAccess, invalidateDbAccessCache } from '@/shared/api/schemaApi'; import { runAccessSql } from '@/shared/api/accessSql'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; @@ -201,6 +201,7 @@ export const DatabaseAccessModal: React.FC = ({ setError(outcome.error); } else { setStatus(kind === 'grant' ? 'Granted.' : 'Revoked.'); + invalidateDbAccessCache(connectionId); await load(); } } catch (err: unknown) { diff --git a/apps/web/src/frontend/shared/api/schemaApi.test.ts b/apps/web/src/frontend/shared/api/schemaApi.test.ts index 9e345b14..7553b639 100644 --- a/apps/web/src/frontend/shared/api/schemaApi.test.ts +++ b/apps/web/src/frontend/shared/api/schemaApi.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, afterEach } from 'vitest'; import { cacheKeyForRef, + fetchDbAccess, + invalidateCache, + invalidateDbAccessCache, matchIndexFragmentationRow, nonSecretFingerprint, type ConnectionRef, @@ -95,3 +98,45 @@ describe('cacheKeyForRef', () => { expect(a).not.toBe(b); }); }); + +describe('fetchDbAccess session cache', () => { + afterEach(() => { + invalidateCache(); + vi.unstubAllGlobals(); + }); + + function okAccess(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + it('collapses duplicate catalog probes for the same connection and schema', async () => { + const fetchMock = vi.fn(async () => + okAccess({ principals: [{ name: 'alice' }], privileges: [] }) + ); + vi.stubGlobal('fetch', fetchMock); + const ref = { connectionId: 'c1' }; + const [a, b] = await Promise.all([ + fetchDbAccess(ref, { schema: 'public' }), + fetchDbAccess(ref, { schema: 'public' }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(a.principals?.[0]?.name).toBe('alice'); + expect(b).toBe(a); + await fetchDbAccess(ref, { schema: 'public' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('refetches after invalidateDbAccessCache', async () => { + const fetchMock = vi.fn(async () => + okAccess({ principals: [{ name: 'bob' }], privileges: [] }) + ); + vi.stubGlobal('fetch', fetchMock); + await fetchDbAccess({ connectionId: 'c1' }, { schema: 'public' }); + invalidateDbAccessCache('c1'); + await fetchDbAccess({ connectionId: 'c1' }, { schema: 'public' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/frontend/shared/api/schemaApi.ts b/apps/web/src/frontend/shared/api/schemaApi.ts index fcf9c2ca..49a2bc34 100644 --- a/apps/web/src/frontend/shared/api/schemaApi.ts +++ b/apps/web/src/frontend/shared/api/schemaApi.ts @@ -401,25 +401,45 @@ export type DbAccessResponse = { error?: string; }; +/** How long a Database Access catalog stays reused across Access tabs. */ +export const DB_ACCESS_CACHE_TTL_MS = 60_000; + +/** Drop cached GRANT catalogs (call after executing GRANT/REVOKE). */ +export function invalidateDbAccessCache(connectionId?: string): void { + if (connectionId) { + invalidateCache(`db-access:id:${connectionId}`); + return; + } + invalidateCache('db-access:'); +} + /** Utilities / Access control → database users, groups, and privileges. */ export async function fetchDbAccess( ref: ConnectionRef, opts?: { schema?: string } ): Promise { - const res = await fetch(`${getApiBase()}/schema/db-access`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...ref, - schema: opts?.schema, - }), - }); - const data = await parseJsonBody(res); - if (!res.ok) { - throw new Error(data.error || `Database access failed (${res.status})`); - } - return data; + const schema = opts?.schema ?? ref.schema; + const key = `db-access:${cacheKeyForRef({ ...ref, schema })}`; + return idempotent( + key, + async () => { + const res = await fetch(`${getApiBase()}/schema/db-access`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...ref, + schema, + }), + }); + const data = await parseJsonBody(res); + if (!res.ok) { + throw new Error(data.error || `Database access failed (${res.status})`); + } + return data; + }, + DB_ACCESS_CACHE_TTL_MS + ); } export async function checkDriver(dialect: string): Promise { diff --git a/packages/server/src/features/history/history.routes.ts b/packages/server/src/features/history/history.routes.ts index 72a61de3..6319d03b 100644 --- a/packages/server/src/features/history/history.routes.ts +++ b/packages/server/src/features/history/history.routes.ts @@ -48,30 +48,42 @@ export function createHistoryRoutes(deps: HistoryRouteDeps): Router { } ); - router.get('/lokee/databases', async (req: AppRequest, res: FastifyReply) => { - res.send({ databases: await deps.lokee.listDatabases((req as AuthedRequest).userId!) }); - }); - - router.get('/lokee/databases/:id/versions', async (req: AppRequest, res: FastifyReply) => { - const versions = await deps.lokee.listVersions( - (req as AuthedRequest).userId!, - String(req.params.id), - Number(req.query.limit) || 100 - ); - res.send({ versions }); - }); + router.get( + '/lokee/databases', + requirePermissions('schema.browse'), + async (req: AppRequest, res: FastifyReply) => { + res.send({ databases: await deps.lokee.listDatabases((req as AuthedRequest).userId!) }); + } + ); - router.get('/lokee/databases/:id/graph', async (req: AppRequest, res: FastifyReply) => { - // The store scopes every read to the caller, so an unknown or unowned id - // returns an empty graph rather than another user's history. - res.send( - await deps.lokee.graph( + router.get( + '/lokee/databases/:id/versions', + requirePermissions('schema.browse'), + async (req: AppRequest, res: FastifyReply) => { + const versions = await deps.lokee.listVersions( (req as AuthedRequest).userId!, String(req.params.id), - Number(req.query.limit) || 20 - ) - ); - }); + Number(req.query.limit) || 100 + ); + res.send({ versions }); + } + ); + + router.get( + '/lokee/databases/:id/graph', + requirePermissions('schema.browse'), + async (req: AppRequest, res: FastifyReply) => { + // The store scopes every read to the caller, so an unknown or unowned id + // returns an empty graph rather than another user's history. + res.send( + await deps.lokee.graph( + (req as AuthedRequest).userId!, + String(req.params.id), + Number(req.query.limit) || 20 + ) + ); + } + ); router.get( '/lokee/databases/:id/revert/plan', @@ -289,7 +301,10 @@ export function createHistoryRoutes(deps: HistoryRouteDeps): Router { } ); - router.get('/lokee/databases/:id/inspect', async (req: AppRequest, res: FastifyReply) => { + router.get( + '/lokee/databases/:id/inspect', + requirePermissions('schema.browse'), + async (req: AppRequest, res: FastifyReply) => { const versionId = String(req.query.versionId ?? '').trim(); const objectKey = String(req.query.objectKey ?? '').trim(); if (!versionId || !objectKey) { @@ -309,7 +324,10 @@ export function createHistoryRoutes(deps: HistoryRouteDeps): Router { res.send(result); }); - router.get('/lokee/databases/:id/compare', async (req: AppRequest, res: FastifyReply) => { + router.get( + '/lokee/databases/:id/compare', + requirePermissions('schema.browse'), + async (req: AppRequest, res: FastifyReply) => { const versionId = String(req.query.versionId ?? '').trim(); if (!versionId) { sendError(res, 'invalid_input', 'versionId is required'); diff --git a/packages/server/src/features/history/lokee-weave.service.ts b/packages/server/src/features/history/lokee-weave.service.ts index 6293bfa6..3088a9de 100644 --- a/packages/server/src/features/history/lokee-weave.service.ts +++ b/packages/server/src/features/history/lokee-weave.service.ts @@ -701,6 +701,45 @@ export class LokeeWeaveStore { return row.fingerprint === databaseIdentity(input, sha256) ? 'ok' : 'mismatch'; } + private toVersionSummary(row: VersionRow, emails: ReadonlyMap): VersionSummary { + return { + id: row.id, + number: row.version_number, + rootHash: row.root_hash, + createdAt: row.created_at, + lastObservedAt: row.last_observed_at, + observationCount: Number(row.observation_count) || 1, + source: row.source, + migrationRunId: row.migration_run_id ?? undefined, + authorUserId: row.author_user_id ?? undefined, + author: row.author_user_id + ? emails.get(row.author_user_id) ?? row.author_user_id + : undefined, + name: row.display_name?.trim() || undefined, + description: row.description?.trim() || undefined, + objectCount: Number(row.object_count) || 0, + changeCount: Number(row.change_count) || 0, + revertFromVersionId: row.revert_from_version_id ?? undefined, + revertToVersionId: row.revert_to_version_id ?? undefined, + }; + } + + private async emailsFor( + store: MetadataStore, + authorIds: readonly (string | null | undefined)[] + ): Promise> { + const emails = new Map(); + const ids = [...new Set(authorIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return emails; + const placeholders = ids.map(() => '?').join(', '); + const users = await store.all<{ id: string; email: string }>( + `SELECT id, email FROM users WHERE id IN (${placeholders})`, + ids + ); + for (const u of users) emails.set(u.id, u.email); + return emails; + } + async listVersions(userId: string, databaseId: string, limit = 100): Promise { const store = await this.store(); if (!(await this.assertOwned(store, userId, databaseId))) return []; @@ -709,38 +748,11 @@ export class LokeeWeaveStore { ORDER BY version_number DESC LIMIT ?`, [databaseId, Math.max(1, Math.min(500, limit))] ); - const authorIds = [ - ...new Set(rows.map((r) => r.author_user_id).filter((id): id is string => Boolean(id))), - ]; - const emails = new Map(); - if (authorIds.length) { - const placeholders = authorIds.map(() => '?').join(', '); - const users = await store.all<{ id: string; email: string }>( - `SELECT id, email FROM users WHERE id IN (${placeholders})`, - authorIds - ); - for (const u of users) emails.set(u.id, u.email); - } - return rows.map((r) => ({ - id: r.id, - number: r.version_number, - rootHash: r.root_hash, - createdAt: r.created_at, - lastObservedAt: r.last_observed_at, - observationCount: Number(r.observation_count) || 1, - source: r.source, - migrationRunId: r.migration_run_id ?? undefined, - authorUserId: r.author_user_id ?? undefined, - author: r.author_user_id - ? emails.get(r.author_user_id) ?? r.author_user_id - : undefined, - name: r.display_name?.trim() || undefined, - description: r.description?.trim() || undefined, - objectCount: Number(r.object_count) || 0, - changeCount: Number(r.change_count) || 0, - revertFromVersionId: r.revert_from_version_id ?? undefined, - revertToVersionId: r.revert_to_version_id ?? undefined, - })); + const emails = await this.emailsFor( + store, + rows.map((r) => r.author_user_id) + ); + return rows.map((r) => this.toVersionSummary(r, emails)); } /** @@ -773,8 +785,14 @@ export class LokeeWeaveStore { ]); } - const versions = await this.listVersions(userId, databaseId, 500); - return versions.find((v) => v.id === versionId) ?? null; + // One row, not a 500-version walk, just to return the record we updated. + const row = await store.get( + `SELECT * FROM lokee_versions WHERE id = ? AND database_id = ?`, + [versionId, databaseId] + ); + if (!row) return null; + const emails = await this.emailsFor(store, [row.author_user_id]); + return this.toVersionSummary(row, emails); } /** Delta rows for a set of versions, batched to stay under the bind limit. */ @@ -1105,7 +1123,9 @@ export class LokeeWeaveStore { blueprint, diff: compare.tables.find((t) => t.tableName === owner) ?? null, history: await this.objectHistory(userId, databaseId, objectKey), - growth: tableLike ? await this.containerGrowth(userId, databaseId, owner) : [], + growth: tableLike + ? await this.containerGrowth(userId, databaseId, owner, versions) + : [], columnMutations: tableLike ? await this.columnMutations(userId, databaseId, owner) : [], script, previousScript, @@ -1374,12 +1394,16 @@ export class LokeeWeaveStore { private async containerGrowth( userId: string, databaseId: string, - owner: string + owner: string, + loadedVersions?: readonly VersionSummary[] ): Promise { // The whole history, not a recent window: a roadmap that starts at v(N-20) // hides the moment a table was created, which is the point a reader looks // for first. Still bounded — listVersions caps at 500. - const versions = await this.listVersions(userId, databaseId, MAX_ROADMAP_VERSIONS); + const versions = + loadedVersions && loadedVersions.length > 0 + ? [...loadedVersions] + : await this.listVersions(userId, databaseId, MAX_ROADMAP_VERSIONS); if (versions.length === 0) return []; const store = await this.store(); const latest = await this.loadLatestIndex(store, databaseId); From 2f74cca0355d33b075d809eb543dbd03cfd44f5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 18:56:21 +0000 Subject: [PATCH 02/17] =?UTF-8?q?feat(ui):=20P2=20one=20compare=20language?= =?UTF-8?q?=20=E2=80=94=20briefing=20chips,=20docked=20Snapshots=20pane,?= =?UTF-8?q?=20SVG=20ticks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync and Snapshots now share + / ~ / − chips. Version list loads ADD/MODIFY/DELETE counts in one grouped query. Timeline click docks the same SchemaBlueprint compare beside the list; React Flow stays opt-in. Co-authored-by: huy.phan9 --- .../web/src/frontend/app/shell/TopToolbar.tsx | 13 +-- .../components/LokeeWeaveView.test.tsx | 29 +++++++ .../lokee-weave/components/LokeeWeaveView.tsx | 25 ++++-- .../components/VersionCompareModal.test.tsx | 19 ++++- .../components/VersionCompareModal.tsx | 44 ++++++---- .../components/VersionTimeline.test.tsx | 44 ++++++++++ .../components/VersionTimeline.tsx | 29 +++---- .../components/DiffBriefingChips.test.tsx | 33 ++++++++ .../components/DiffBriefingChips.tsx | 83 +++++++++++++++++++ .../frontend/features/schema-diff/index.ts | 2 + .../history/lokee-weave.module.test.ts | 15 ++++ .../features/history/lokee-weave.service.ts | 44 +++++++++- packages/shared/src/lokee-wire.ts | 4 + 13 files changed, 335 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.test.tsx create mode 100644 apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.test.tsx create mode 100644 apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.tsx diff --git a/apps/web/src/frontend/app/shell/TopToolbar.tsx b/apps/web/src/frontend/app/shell/TopToolbar.tsx index 22f1a3f3..966cb449 100644 --- a/apps/web/src/frontend/app/shell/TopToolbar.tsx +++ b/apps/web/src/frontend/app/shell/TopToolbar.tsx @@ -21,7 +21,8 @@ import { getSessionPassword, setSessionPassword } from '@/shared/lib/sessionPass import { HistoryCompareBar } from '@/features/lokee-weave'; import { BrowseBar } from '@/features/object-detail'; import { ActivityIndicator } from './ActivityIndicator'; -import { diffBriefing } from '@/features/schema-diff/lib/diffBriefing'; +import { DiffBriefingChips } from '@/features/schema-diff'; +import { diffBriefing } from '@/features/schema-diff'; const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; @@ -541,15 +542,7 @@ export const TopToolbar: React.FC = () => { )} {compareResult && ( -
- +{briefing.added} - ~{briefing.modified} - −{briefing.removed} -
+ )}
@@ -554,6 +564,7 @@ export function LokeeWeaveView({ databaseId={activeId} versionId={comparePair.original} againstVersionId={comparePair.target} + embedded // Reverting restores the live database, so it is only coherent // while Target is the newest version — the modal refuses otherwise. latestVersionId={latestVersionId ?? undefined} diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.test.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.test.tsx index 88886930..41d5873b 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.test.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.test.tsx @@ -79,7 +79,7 @@ describe('VersionCompareModal', () => { expect(screen.getByTestId('lokee-version-compare').getAttribute('data-state')).toBe('ready') ); expect(screen.getByText('Version 4 → Version 5')).toBeTruthy(); - expect(screen.getByTestId('lokee-cmp-summary').textContent).toContain('1 modified'); + expect(screen.getByTestId('lokee-cmp-summary').textContent).toContain('~1'); // The tree is the shared component; the detail pane defaults to the first // changed object so the two panes are never out of step. @@ -119,6 +119,23 @@ describe('VersionCompareModal', () => { expect(screen.getByText(/Version 1 · first capture/)).toBeTruthy(); }); + it('docks as a pane when Snapshots asks for embedded compare', async () => { + compareLokeeVersions.mockResolvedValue({ + from: VERSION(1), + to: VERSION(2), + compare: { summary: { added: 0, removed: 0, modified: 0, unchanged: 3 }, tables: [] }, + }); + render( + undefined} /> + ); + await waitFor(() => + expect(screen.getByTestId('lokee-version-compare').getAttribute('data-embedded')).toBe( + 'true' + ) + ); + expect(screen.getByTestId('lokee-version-compare').getAttribute('aria-modal')).toBe('false'); + }); + // NOTE: an error-path test belongs here and is deliberately absent. The // component does catch (`data-state` goes to `error` in the browser, verified // by hand), and the identical effect pattern passes in an isolated probe diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.tsx index 0a71ad0a..5413baf7 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionCompareModal.tsx @@ -31,6 +31,8 @@ import { SchemaBlueprint } from '@/features/schema-diff'; import { buildMigrationReport, migrationReportFilename } from '@/features/lokee-weave/lib/migrationReport'; import { SchemaDiffTree, orderTablesForDisplay } from '@/features/schema-diff'; import { DetailTabs, type DetailTab } from '@/features/schema-diff'; +import { DiffBriefingChips } from '@/features/schema-diff'; +import { diffBriefing } from '@/features/schema-diff'; import { buildTableDdlDiffLines, DdlDiffLines } from '@/features/schema-diff'; import { GithubScriptDiff } from './GithubScriptDiff'; import { versionDisplayName } from './graphTypes'; @@ -64,6 +66,11 @@ export interface VersionCompareModalProps { /** Put the Target side back on "Current database", so a revert is possible. */ onRetargetToLatest?: () => void; onClose: () => void; + /** + * Dock in the Snapshots workspace (same tree as Sync) instead of a modal. + * Graph still uses the overlay internally. + */ + embedded?: boolean; } @@ -97,6 +104,7 @@ export function VersionCompareModal({ latestVersionId, onRetargetToLatest, onClose, + embedded = false, }: VersionCompareModalProps): React.ReactElement { const [tab, setTab] = useState('DIFF'); const [plan, setPlan] = useState(null); @@ -404,15 +412,26 @@ export function VersionCompareModal({ return (
-
+
@@ -455,20 +474,13 @@ export function VersionCompareModal({ <>
- - {data.compare.summary.added} added - - - {data.compare.summary.modified} modified - - - {data.compare.summary.removed} removed - - - {data.compare.summary.unchanged} unchanged - + {/* Revert reads the Original side and moves the live database to it; the Target picker only frames the diff. Saying so where the sides are chosen beats a disabled button and a tooltip. */} diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.test.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.test.tsx new file mode 100644 index 00000000..4b931ef4 --- /dev/null +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.test.tsx @@ -0,0 +1,44 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { VersionTimeline } from './VersionTimeline'; + +const V = ( + number: number, + extras: { added?: number; modified?: number; removed?: number; changeCount?: number } = {} +) => ({ + id: `v${number}`, + number, + createdAt: '2026-09-07T00:00:00.000Z', + rootHash: `h${number}`, + ...extras, +}); + +describe('VersionTimeline', () => { + it('draws stacked + / ~ / − ticks from the version DTO', () => { + render( + + ); + const ticks = screen.getAllByTestId('lokee-change-ticks'); + expect(ticks).toHaveLength(2); + expect(ticks[0]!.querySelectorAll('rect')).toHaveLength(3); + expect(screen.getByTestId('lokee-timeline-v-2').textContent).toContain('6 changes'); + }); + + it('selects a version without loading the graph', () => { + const onSelect = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('lokee-timeline-v-1')); + expect(onSelect).toHaveBeenCalledWith('v1'); + }); +}); diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.tsx index 801a0856..6ad54530 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionTimeline.tsx @@ -8,20 +8,23 @@ */ import React from 'react'; import type { VersionGraphVersion } from '@foxschema/shared'; +import { DiffBriefingTicks } from '@/features/schema-diff'; + +export type TimelineVersion = VersionGraphVersion & { + changeCount?: number; + added?: number; + modified?: number; + removed?: number; +}; export interface VersionTimelineProps { - versions: readonly (VersionGraphVersion & { changeCount?: number })[]; + versions: readonly TimelineVersion[]; totalVersions: number; selectedId?: string | null; subtitle?: string; onSelect?: (versionId: string) => void; } -function barWidth(changeCount: number, max: number): string { - if (max <= 0 || changeCount <= 0) return '0%'; - return `${Math.max(4, Math.round((changeCount / max) * 100))}%`; -} - export function VersionTimeline({ versions, totalVersions, @@ -29,7 +32,6 @@ export function VersionTimeline({ subtitle, onSelect, }: VersionTimelineProps): React.ReactElement { - const maxChanges = Math.max(0, ...versions.map((v) => v.changeCount ?? 0)); return (
{versions.map((version) => { const selected = version.id === selectedId; + const added = version.added ?? 0; + const modified = version.modified ?? 0; + const removed = version.removed ?? 0; + const changes = added + modified + removed || version.changeCount || 0; return (
  • ); diff --git a/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.test.tsx b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.test.tsx new file mode 100644 index 00000000..d969ce67 --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.test.tsx @@ -0,0 +1,33 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DiffBriefingChips, DiffBriefingTicks } from './DiffBriefingChips'; + +describe('DiffBriefingChips', () => { + it('prints the same + / ~ / − language as the Sync toolbar', () => { + render(); + const el = screen.getByTestId('diff-briefing'); + expect(el.textContent).toContain('+2'); + expect(el.textContent).toContain('~1'); + expect(el.textContent).toContain('−3'); + expect(el.textContent).not.toContain('unchanged'); + }); +}); + +describe('DiffBriefingTicks', () => { + it('renders one rect per non-zero change kind', () => { + const { container } = render(); + expect(screen.getByTestId('lokee-change-ticks').tagName.toLowerCase()).toBe('svg'); + expect(container.querySelectorAll('rect')).toHaveLength(2); + }); + + it('renders an empty track when a version has no delta', () => { + render(); + expect(screen.getByTestId('lokee-change-ticks').tagName.toLowerCase()).toBe('span'); + }); +}); diff --git a/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.tsx b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.tsx new file mode 100644 index 00000000..364c8e47 --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingChips.tsx @@ -0,0 +1,83 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The same + / ~ / − language Sync and Snapshots use. Counts come from a + * compare DTO or a version delta — never a second schema load. + */ +import React from 'react'; +import type { DiffBriefing } from '../lib/diffBriefing'; + +export function DiffBriefingChips({ + briefing, + testId = 'diff-briefing', + showUnchanged = false, +}: { + briefing: DiffBriefing; + testId?: string; + showUnchanged?: boolean; +}): React.ReactElement { + return ( +
    + +{briefing.added} + ~{briefing.modified} + −{briefing.removed} + {showUnchanged && {briefing.unchanged} unchanged} +
    + ); +} + +/** Stacked SVG ticks for a version row. Zero-width segments are omitted. */ +export function DiffBriefingTicks({ + added, + modified, + removed, +}: { + added: number; + modified: number; + removed: number; +}): React.ReactElement { + const total = Math.max(0, added) + Math.max(0, modified) + Math.max(0, removed); + if (total <= 0) { + return ( + + ); + } + const width = 96; + const height = 6; + const segs = [ + { n: Math.max(0, added), fill: '#34d399' }, + { n: Math.max(0, modified), fill: '#fbbf24' }, + { n: Math.max(0, removed), fill: '#fb7185' }, + ]; + let x = 0; + return ( + + {segs.map((seg, i) => { + if (seg.n <= 0) return null; + const w = Math.max(2, Math.round((seg.n / total) * width)); + const el = ( + + ); + x += w; + return el; + })} + + ); +} diff --git a/apps/web/src/frontend/features/schema-diff/index.ts b/apps/web/src/frontend/features/schema-diff/index.ts index 830f29bc..eb07f47f 100644 --- a/apps/web/src/frontend/features/schema-diff/index.ts +++ b/apps/web/src/frontend/features/schema-diff/index.ts @@ -15,3 +15,5 @@ export type { DetailTab } from './components/DetailTabs'; export { SchemaBlueprint } from './components/SchemaBlueprint'; export { DdlDiffLines, buildTableDdlDiffLines, stripSchemaQualifiers } from './components/SchemaDdlDiff'; export { SchemaDiffTree, TYPE_META, TYPE_ORDER, orderTablesForDisplay } from './components/SchemaDiffTree'; +export { DiffBriefingChips, DiffBriefingTicks } from './components/DiffBriefingChips'; +export { diffBriefing, type DiffBriefing } from './lib/diffBriefing'; diff --git a/packages/server/src/features/history/lokee-weave.module.test.ts b/packages/server/src/features/history/lokee-weave.module.test.ts index 82c1bfe0..bbd52591 100644 --- a/packages/server/src/features/history/lokee-weave.module.test.ts +++ b/packages/server/src/features/history/lokee-weave.module.test.ts @@ -127,6 +127,21 @@ describe('capture', () => { expect(delta).toEqual([{ object_key: 'column:CUSTOMER.EMAIL', operation: 'MODIFY' }]); }); + it('lists + / ~ / − briefing counts in one grouped query, not per version', async () => { + const { weave } = await freshStore(); + const v1 = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + const widened = table('customer', [ + ['id', 'integer', false], + ['email', 'varchar(255)'], + ]); + const v2 = await weave.capture(USER, { ...IDENTITY, tables: [widened], source: 'migrate' }); + const listed = await weave.listVersions(USER, v1.databaseId); + const first = listed.find((v) => v.id === v1.versionId); + const second = listed.find((v) => v.id === v2.versionId); + expect(first).toMatchObject({ added: 3, modified: 0, removed: 0 }); + expect(second).toMatchObject({ added: 0, modified: 1, removed: 0 }); + }); + it('stores one body per distinct hash, shared across versions', async () => { const { weave, meta } = await freshStore(); await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); diff --git a/packages/server/src/features/history/lokee-weave.service.ts b/packages/server/src/features/history/lokee-weave.service.ts index 3088a9de..71dc6ad1 100644 --- a/packages/server/src/features/history/lokee-weave.service.ts +++ b/packages/server/src/features/history/lokee-weave.service.ts @@ -719,11 +719,44 @@ export class LokeeWeaveStore { description: row.description?.trim() || undefined, objectCount: Number(row.object_count) || 0, changeCount: Number(row.change_count) || 0, + added: 0, + modified: 0, + removed: 0, revertFromVersionId: row.revert_from_version_id ?? undefined, revertToVersionId: row.revert_to_version_id ?? undefined, }; } + /** + * One grouped scan of `lokee_version_objects` for the listed versions — not + * one query per row. Feeds the timeline + / ~ / − ticks without opening a + * compare for every version. + */ + private async changeBriefings( + store: MetadataStore, + versionIds: readonly string[] + ): Promise> { + const out = new Map(); + if (versionIds.length === 0) return out; + const placeholders = versionIds.map(() => '?').join(', '); + const rows = await store.all<{ version_id: string; operation: string; n: number }>( + `SELECT version_id, operation, COUNT(*) AS n + FROM lokee_version_objects + WHERE version_id IN (${placeholders}) + GROUP BY version_id, operation`, + [...versionIds] + ); + for (const row of rows) { + const cur = out.get(row.version_id) ?? { added: 0, modified: 0, removed: 0 }; + const n = Number(row.n) || 0; + if (row.operation === 'ADD') cur.added = n; + else if (row.operation === 'MODIFY') cur.modified = n; + else if (row.operation === 'DELETE') cur.removed = n; + out.set(row.version_id, cur); + } + return out; + } + private async emailsFor( store: MetadataStore, authorIds: readonly (string | null | undefined)[] @@ -752,7 +785,16 @@ export class LokeeWeaveStore { store, rows.map((r) => r.author_user_id) ); - return rows.map((r) => this.toVersionSummary(r, emails)); + const briefings = await this.changeBriefings( + store, + rows.map((r) => r.id) + ); + return rows.map((r) => { + const summary = this.toVersionSummary(r, emails); + const briefing = briefings.get(r.id); + if (!briefing) return summary; + return { ...summary, ...briefing }; + }); } /** diff --git a/packages/shared/src/lokee-wire.ts b/packages/shared/src/lokee-wire.ts index 9860cba9..1d4a9474 100644 --- a/packages/shared/src/lokee-wire.ts +++ b/packages/shared/src/lokee-wire.ts @@ -81,6 +81,10 @@ export interface VersionSummary { description?: string; objectCount: number; changeCount: number; + /** Object-level ADD / MODIFY / DELETE in this version's delta (one grouped query). */ + added?: number; + modified?: number; + removed?: number; /** * Set only on a version a revert produced: the head the database was at, and * the version that was restored. `source: 'revert'` says an undo happened; From b84c3a6579b9c71448bac1385a093793dfbacff3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 19:07:45 +0000 Subject: [PATCH 03/17] feat(sql): P3 Last Id paging, catalog table insight, cheaper schema warm Next page uses a unique ORDER BY keyset when the last row is in cache; OFFSET remains the fallback. Insight probes read pg_stats / STATISTICS / sqlite_stat1 and fail tests if SQL contains COUNT(DISTINCT). Schema explorer loads TABLE|VIEW|MQT first and fetches routines only when those groups open. Co-authored-by: huy.phan9 --- .../frontend/app/store/useSqlEditorStore.ts | 97 ++++++--- .../components/SqlSchemaExplorer.tsx | 29 ++- .../sql-editor/lib/resultSeek.test.ts | 44 ++++ .../features/sql-editor/lib/resultSeek.ts | 55 +++++ apps/web/src/frontend/shared/api/schemaApi.ts | 43 ++++ apps/web/src/frontend/shared/api/sqlApi.ts | 6 +- packages/server/src/api/http-contract.test.ts | 1 + .../src/features/access/access.routes.ts | 34 ++++ .../features/schema/table-insight.service.ts | 76 +++++++ .../src/features/sql-editor/editor.routes.ts | 13 +- .../sql-editor/sql-execute.service.ts | 21 +- .../sql-editor/sql-page-wrap.service.test.ts | 47 +++++ .../sql-editor/sql-page-wrap.service.ts | 105 ++++++++++ packages/sql/src/index.ts | 23 +++ .../modules/sql-editor/sql-order-by.test.ts | 77 +++++++ .../src/modules/sql-editor/sql-order-by.ts | 189 ++++++++++++++++++ .../utilities/table-insight.registry.ts | 43 ++++ .../modules/utilities/table-insight.test.ts | 42 ++++ .../src/modules/utilities/table-insight.ts | 111 ++++++++++ .../modules/utilities/table-insight.types.ts | 59 ++++++ .../azureSql/azuresql.table-insight.ts | 6 + .../clickHouse/clickhouse.table-insight.ts | 41 ++++ .../cockroachDb/cockroachdb.table-insight.ts | 6 + .../src/providers/db2/db2.table-insight.ts | 44 ++++ .../providers/duckDb/duckdb.table-insight.ts | 6 + .../mariaDb/mariadb.table-insight.ts | 6 + .../providers/mysql/mysql.table-insight.ts | 49 +++++ .../providers/oracle/oracle.table-insight.ts | 45 +++++ .../postgres/postgres.table-insight.ts | 49 +++++ .../redshift/redshift.table-insight.ts | 6 + .../providers/sqlLite/sqlite.table-insight.ts | 45 +++++ .../sqlServer/sqlserver.table-insight.ts | 49 +++++ .../src/providers/tiDb/tidb.table-insight.ts | 6 + .../yugabyteDb/yugabytedb.table-insight.ts | 6 + 34 files changed, 1433 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts create mode 100644 apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts create mode 100644 packages/server/src/features/schema/table-insight.service.ts create mode 100644 packages/sql/src/modules/sql-editor/sql-order-by.test.ts create mode 100644 packages/sql/src/modules/sql-editor/sql-order-by.ts create mode 100644 packages/sql/src/modules/utilities/table-insight.registry.ts create mode 100644 packages/sql/src/modules/utilities/table-insight.test.ts create mode 100644 packages/sql/src/modules/utilities/table-insight.ts create mode 100644 packages/sql/src/modules/utilities/table-insight.types.ts create mode 100644 packages/sql/src/providers/azureSql/azuresql.table-insight.ts create mode 100644 packages/sql/src/providers/clickHouse/clickhouse.table-insight.ts create mode 100644 packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts create mode 100644 packages/sql/src/providers/db2/db2.table-insight.ts create mode 100644 packages/sql/src/providers/duckDb/duckdb.table-insight.ts create mode 100644 packages/sql/src/providers/mariaDb/mariadb.table-insight.ts create mode 100644 packages/sql/src/providers/mysql/mysql.table-insight.ts create mode 100644 packages/sql/src/providers/oracle/oracle.table-insight.ts create mode 100644 packages/sql/src/providers/postgres/postgres.table-insight.ts create mode 100644 packages/sql/src/providers/redshift/redshift.table-insight.ts create mode 100644 packages/sql/src/providers/sqlLite/sqlite.table-insight.ts create mode 100644 packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts create mode 100644 packages/sql/src/providers/tiDb/tidb.table-insight.ts create mode 100644 packages/sql/src/providers/yugabyteDb/yugabytedb.table-insight.ts diff --git a/apps/web/src/frontend/app/store/useSqlEditorStore.ts b/apps/web/src/frontend/app/store/useSqlEditorStore.ts index a4b900a9..856366f0 100644 --- a/apps/web/src/frontend/app/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/app/store/useSqlEditorStore.ts @@ -8,6 +8,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { executeSql, type SqlStatementResult } from '@/shared/api/sqlApi'; +import { seekFromLastRow, tableForOrderBy } from '@/features/sql-editor/lib/resultSeek'; import { supportsDialectFeature } from '@/shared/lib/dialect-features'; import type { SavedConnectionSummary } from '@/shared/api/authApi'; import { resolveAppSecrets } from '@/shared/api/appSecretsApi'; @@ -36,7 +37,7 @@ import { sessionPasswordMap, setSessionPassword, } from '@/shared/lib/sessionPasswords'; -import type { ForeignKeyInfo } from '@/shared/lib/types'; +import type { DbObjectType, ForeignKeyInfo } from '@/shared/lib/types'; function sessionPasswordFor( connectionId: string, @@ -201,6 +202,10 @@ const MAX_PAGES_PER_STATEMENT = 5; const SCHEMA_CACHE_TTL_MS = 15 * 60 * 1000; /** Max connections kept in schemaCache (LRU by loadedAt). */ const SCHEMA_CACHE_MAX = 8; +/** SQL Editor explorer first paint — routines load when those groups open. */ +export const SCHEMA_WARM_SCOPE = ['TABLE', 'VIEW', 'MQT'] as const; +export const SCHEMA_ROUTINE_SCOPE = ['PROCEDURE', 'FUNCTION'] as const; +const schemaLoadInflight = new Map>(); /** Cap persisted tab/bookmark SQL to avoid QuotaExceededError. */ export const MAX_PERSISTED_SQL_CHARS = 256 * 1024; /** Cap persisted bookmark count. */ @@ -296,8 +301,10 @@ export interface DataPeekEntry { orderByClause: string; /** Rows/page for this peek panel (sent as execute page size). */ limit: number; - /** 0-based page for server OFFSET paging. */ + /** 0-based page for server OFFSET / Last Id paging. */ pageIndex: number; + /** Page index the current `result` was loaded for (Last Id Next). */ + resultPageIndex?: number; /** Composed SQL actually executed. */ sql: string; params: unknown[]; @@ -459,7 +466,10 @@ interface SqlEditorState { submitSessionPassword: (password: string) => void; cancelPasswordPrompt: () => void; setMaxRows: (n: number) => void; - ensureSchema: (connectionId: string, opts?: { force?: boolean }) => Promise; + ensureSchema: ( + connectionId: string, + opts?: { force?: boolean; scope?: readonly string[] } + ) => Promise; /** * Re-run SQL. * - `connectionIds` — refresh only those credentials (keeps other panes). @@ -841,30 +851,24 @@ export const useSqlEditorStore = create()( clearRecentQueries: () => set({ recentQueries: [] }), - ensureSchema: async (connectionId, { force = false } = {}) => { - const SQL_EDITOR_SCOPE = ['TABLE', 'VIEW', 'MQT', 'PROCEDURE', 'FUNCTION'] as const; + ensureSchema: async (connectionId, { force = false, scope } = {}) => { + const wanted = [...(scope && scope.length ? scope : SCHEMA_WARM_SCOPE)]; + const inflightKey = `${connectionId}:${wanted.join(',')}`; + const running = schemaLoadInflight.get(inflightKey); + if (running) return running; + + const work = (async () => { const prunedStart = pruneSchemaCache(get().schemaCache); if (Object.keys(prunedStart).length !== Object.keys(get().schemaCache).length) { set({ schemaCache: prunedStart }); } const existing = get().schemaCache[connectionId]; - const scopeKey = SQL_EDITOR_SCOPE.join(','); - const scopeOk = existing?.scope?.join(',') === scopeKey; + const have = new Set(existing?.scope ?? []); + const scopeOk = wanted.every((t) => have.has(t)); const fresh = existing?.status === 'ready' && typeof existing.loadedAt === 'number' && Date.now() - existing.loadedAt < SCHEMA_CACHE_TTL_MS; - /** - * Only a finished load short-circuits. Returning early on `loading` - * looked like de-duplication but was not: `loadSchema` already joins - * concurrent identical calls through `idempotent()`, so one request is - * made either way. What the early return actually did was break the - * promise — a caller that awaited this got control back while the fetch - * was still in flight, read a cache entry with no tables and no error - * yet, and reported an empty result. Index Management said - * "Loaded 0 index(es)" for a database it could not reach, because the - * ECONNREFUSED landed after it had already drawn its answer. - */ if (!force && scopeOk && existing?.status === 'ready' && fresh) { return; } @@ -909,23 +913,26 @@ export const useSqlEditorStore = create()( [connectionId]: { status: 'loading', tables: existing?.tables, - scope: [...SQL_EDITOR_SCOPE], + scope: existing?.scope ?? wanted, }, }), }); try { - const { tables } = await loadSchema( + const { tables: loaded } = await loadSchema( { connectionId, password: sessionPasswordFor(connectionId, sessionPasswords) }, - [...SQL_EDITOR_SCOPE] + wanted as DbObjectType[] ); + const incoming = new Set(wanted); + const kept = (existing?.tables ?? []).filter((t) => !incoming.has(t.objectType)); + const mergedScope = [...new Set([...(existing?.scope ?? []), ...wanted])]; set({ schemaCache: pruneSchemaCache({ ...get().schemaCache, [connectionId]: { status: 'ready', - tables, - scope: [...SQL_EDITOR_SCOPE], + tables: [...kept, ...loaded], + scope: mergedScope, loadedAt: Date.now(), }, }), @@ -937,11 +944,19 @@ export const useSqlEditorStore = create()( [connectionId]: { status: 'error', error: error instanceof Error ? error.message : String(error), - scope: [...SQL_EDITOR_SCOPE], + scope: existing?.scope ?? wanted, }, }), }); } + })(); + + schemaLoadInflight.set(inflightKey, work); + try { + await work; + } finally { + if (schemaLoadInflight.get(inflightKey) === work) schemaLoadInflight.delete(inflightKey); + } }, cancelWriteConfirm: () => set({ pendingWriteConfirm: null }), @@ -1607,12 +1622,25 @@ export const useSqlEditorStore = create()( setMeta({ loading: true }); try { - const offset = pageIndex * pageSize; + const prevCached = current.pageCache?.[pageCacheKey(connectionId, statementIndex, pageIndex - 1)]; + const tables = get().schemaCache[connectionId]?.tables; + const seek = + pageIndex > 0 && prevCached && prevCached.ok && prevCached.rows.length + ? seekFromLastRow({ + sql: sql!, + table: tableForOrderBy(sql!, tables), + resultColumns: prevCached.columns, + lastRow: prevCached.rows[prevCached.rows.length - 1]!, + }) + : null; + const offset = seek ? 0 : pageIndex * pageSize; const { results } = await executeSql( { connectionId, password: sessionPasswordFor(connectionId, sessionPasswords) }, [sql!], pageSize, - offset + offset, + undefined, + seek ? { seek } : undefined ); const one = results[0] ?? { ok: false as const, @@ -2017,7 +2045,17 @@ export const useSqlEditorStore = create()( const params = entry.params; const pageSize = Math.min(5000, Math.max(1, entry.limit || DATA_PEEK_ROWS)); const pageIndex = Math.max(0, entry.pageIndex || 0); - const offset = pageIndex * pageSize; + const resultPage = entry.resultPageIndex ?? (entry.result ? 0 : -1); + const seek = + pageIndex === resultPage + 1 && entry.result?.ok && entry.result.rows.length + ? seekFromLastRow({ + sql, + table: tableForOrderBy(sql, get().schemaCache[peek.connectionId]?.tables), + resultColumns: entry.result.columns, + lastRow: entry.result.rows[entry.result.rows.length - 1]!, + }) + : null; + const offset = seek ? 0 : pageIndex * pageSize; // Mark this generation before awaiting so concurrent runs can detect staleness. set({ dataPeek: { @@ -2048,7 +2086,8 @@ export const useSqlEditorStore = create()( [sql], pageSize, offset, - [params] + [params], + seek ? { seek } : undefined ); const result = results[0]; if (!result) { @@ -2057,7 +2096,7 @@ export const useSqlEditorStore = create()( } patchIfCurrent( result.ok - ? { status: 'ready', result } + ? { status: 'ready', result, resultPageIndex: pageIndex } : { status: 'error', error: result.error, result } ); } catch (error: unknown) { diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx index 95e89e05..0f65eb44 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx @@ -125,8 +125,8 @@ export const SqlSchemaExplorer = forwardRef(function Sq TABLE: true, VIEW: true, MQT: true, - PROCEDURE: true, - FUNCTION: true, + PROCEDURE: false, + FUNCTION: false, }); const [blueprintTable, setBlueprintTable] = useState(null); const [blueprintMode, setBlueprintMode] = useState('edit'); @@ -292,7 +292,10 @@ export const SqlSchemaExplorer = forwardRef(function Sq type="button" title="Reload schema" disabled={!explorerId || entry?.status === 'loading'} - onClick={() => explorerId && void ensureSchema(explorerId, { force: true })} + onClick={() => + explorerId && + void ensureSchema(explorerId, { force: true, scope: entry?.scope }) + } className="p-1.5 rounded text-slate-500 hover:text-slate-200 hover:bg-slate-800/70 disabled:opacity-40 transition" > {entry?.status === 'loading' ? ( @@ -337,16 +340,22 @@ export const SqlSchemaExplorer = forwardRef(function Sq
    {EXPLORER_GROUPS.map((g) => { const items = grouped.get(g.type) ?? []; - if (items.length === 0) return null; - const open = expandedGroup[g.type] !== false; + const isRoutine = g.type === 'PROCEDURE' || g.type === 'FUNCTION'; + const routinesLoaded = (entry?.scope ?? []).includes('PROCEDURE'); + if (items.length === 0 && !isRoutine) return null; + const open = expandedGroup[g.type] === true || (expandedGroup[g.type] !== false && !isRoutine); const meta = TYPE_META[g.type]; return (
    {open && ( diff --git a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts new file mode 100644 index 00000000..dc6a0936 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.test.ts @@ -0,0 +1,44 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { seekFromLastRow } from './resultSeek'; +import type { TableSchema } from '@/shared/lib/types'; + +const TABLE: TableSchema = { + name: 'ORDERS', + objectType: 'TABLE', + columns: [ + { name: 'ID', type: 'int', nullable: false, primaryKey: true }, + { name: 'NAME', type: 'varchar', nullable: true, primaryKey: false }, + ], + indices: [], + foreignKeys: [], + primaryKey: { columns: ['ID'] }, +}; + +describe('seekFromLastRow', () => { + it('builds a Last Id seek when ORDER BY is the PK', () => { + expect( + seekFromLastRow({ + sql: 'SELECT ID, NAME FROM ORDERS ORDER BY ID', + table: TABLE, + resultColumns: ['ID', 'NAME'], + lastRow: [40, 'Ada'], + }) + ).toEqual({ columns: ['ID'], values: [40], descending: [false] }); + }); + + it('returns null when ORDER BY is not unique', () => { + expect( + seekFromLastRow({ + sql: 'SELECT ID, NAME FROM ORDERS ORDER BY NAME', + table: TABLE, + resultColumns: ['ID', 'NAME'], + lastRow: [40, 'Ada'], + }) + ).toBeNull(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts new file mode 100644 index 00000000..6cdb5547 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/lib/resultSeek.ts @@ -0,0 +1,55 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Last Id paging: when ORDER BY is a unique key prefix, Next walks from the + * last row instead of OFFSET. Jumping to an unvisited page N falls back to + * OFFSET (or is refused by the server when a seek is also sent). + */ +import { + parseTopLevelOrderBy, + uniqueKeyCoversOrder, + uniqueKeysFromTable, +} from '@foxschema/sql'; +import type { TableSchema } from '@/shared/lib/types'; + +export type ResultSeek = { + columns: string[]; + values: unknown[]; + descending: boolean[]; +}; + +export function tableForOrderBy( + sql: string, + tables: readonly TableSchema[] | undefined +): TableSchema | undefined { + if (!tables?.length) return undefined; + const names = sql.toLowerCase(); + return tables.find((t) => names.includes(t.name.toLowerCase())); +} + +export function seekFromLastRow(opts: { + sql: string; + table?: TableSchema; + resultColumns: readonly string[]; + lastRow: readonly unknown[]; +}): ResultSeek | null { + const parsed = parseTopLevelOrderBy(opts.sql); + if (!parsed || !opts.table) return null; + const orderCols = parsed.terms.map((t) => t.column); + if (!uniqueKeyCoversOrder(uniqueKeysFromTable(opts.table), orderCols)) return null; + const values: unknown[] = []; + for (const col of orderCols) { + const i = opts.resultColumns.findIndex((c) => c.toLowerCase() === col.toLowerCase()); + if (i < 0) return null; + const v = opts.lastRow[i]; + if (v === null || v === undefined) return null; + values.push(v); + } + return { + columns: orderCols, + values, + descending: parsed.terms.map((t) => t.descending), + }; +} diff --git a/apps/web/src/frontend/shared/api/schemaApi.ts b/apps/web/src/frontend/shared/api/schemaApi.ts index 49a2bc34..8926f9cd 100644 --- a/apps/web/src/frontend/shared/api/schemaApi.ts +++ b/apps/web/src/frontend/shared/api/schemaApi.ts @@ -390,6 +390,49 @@ export async function fetchDbaUtility( return data; } +export type TableInsightResponse = { + table: string; + schema: string; + dialect?: string; + estimatedRows: number | null; + columns: Array<{ name: string; nDistinct: number | null; nullFrac: number | null }>; + mode: 'catalog' | 'unsupported'; + support: { mode: string; query: boolean; hint: string }; + warning?: string; + error?: string; +}; + +const TABLE_INSIGHT_TTL_MS = 60_000; + +export async function fetchTableInsight( + ref: ConnectionRef, + opts: { table: string; schema?: string } +): Promise { + const schema = opts.schema ?? ref.schema; + const key = `table-insight:${cacheKeyForRef({ ...ref, schema })}:${opts.table}`; + return idempotent( + key, + async () => { + const res = await fetch(`${getApiBase()}/schema/table-insight`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...ref, + schema, + table: opts.table, + }), + }); + const data = await parseJsonBody(res); + if (!res.ok) { + throw new Error(data.error || `Table insight failed (${res.status})`); + } + return data; + }, + TABLE_INSIGHT_TTL_MS + ); +} + export type DbAccessResponse = { dialect: string; schema: string; diff --git a/apps/web/src/frontend/shared/api/sqlApi.ts b/apps/web/src/frontend/shared/api/sqlApi.ts index 6c4ac2bc..a0aa6175 100644 --- a/apps/web/src/frontend/shared/api/sqlApi.ts +++ b/apps/web/src/frontend/shared/api/sqlApi.ts @@ -32,7 +32,10 @@ export async function executeSql( * When set, the write came from Data Peek / query-result grid CRUD and the * matching `editor.datagrid.*` permission is required in addition to DML. */ - opts?: { datagridAction?: 'insert' | 'update' | 'delete' } + opts?: { + datagridAction?: 'insert' | 'update' | 'delete'; + seek?: { columns: string[]; values: unknown[]; descending?: boolean | boolean[] }; + } ): Promise<{ results: SqlStatementResult[] }> { const data = await api.post<{ results?: SqlStatementResult[]; error?: string }>(`/sql/execute`, { ...ref, @@ -41,6 +44,7 @@ export async function executeSql( offset, params, ...(opts?.datagridAction ? { datagridAction: opts.datagridAction } : {}), + ...(opts?.seek ? { seek: opts.seek } : {}), }); if (!data.results) throw new Error(data.error || 'Query failed: no results returned.'); return { results: data.results }; diff --git a/packages/server/src/api/http-contract.test.ts b/packages/server/src/api/http-contract.test.ts index 01924301..fa7544d8 100644 --- a/packages/server/src/api/http-contract.test.ts +++ b/packages/server/src/api/http-contract.test.ts @@ -116,6 +116,7 @@ const ROUTES: RouteExpectation[] = [ { method: 'POST', path: '/api/migrations/delete', status: 200 }, { method: 'POST', path: '/api/schema/db-access', status: 400 }, { method: 'POST', path: '/api/schema/dba-utility', status: 400 }, + { method: 'POST', path: '/api/schema/table-insight', status: 400 }, { method: 'POST', path: '/api/schema/index-fragmentation', status: 400 }, { method: 'POST', path: '/api/schema/index-fragmentation-batch', status: 400 }, { method: 'POST', path: '/api/schema/list', status: 400 }, diff --git a/packages/server/src/features/access/access.routes.ts b/packages/server/src/features/access/access.routes.ts index d845ca14..5c626677 100644 --- a/packages/server/src/features/access/access.routes.ts +++ b/packages/server/src/features/access/access.routes.ts @@ -19,6 +19,7 @@ import type { AuthedRequest } from '../auth/auth.routes'; import type { ConnectionRef } from '../../platform/db/resolve'; import { probeTableFragmentation, mapPool } from './index-fragmentation.service'; import { probeDbaUtility } from './dba-utilities.service'; +import { probeTableInsight } from '../schema/table-insight.service'; import type { DbaUtilityKind } from '@foxschema/db'; import { probeDbAccess } from './db-access.service'; import { @@ -195,6 +196,39 @@ export function createAccessRoutes(deps: AccessRouteDeps): Router { } }); + router.post( + '/schema/table-insight', + dbaUtilityLimiter, + requirePermissions('utility.access'), + async (req: AppRequest, res: FastifyReply) => { + const body = req.body as ConnectionRef & { schema?: unknown; table?: unknown }; + const table = typeof body.table === 'string' ? body.table.trim() : ''; + if (!table) { + sendError(res, 'invalid_input', 'table is required.'); + return; + } + try { + const resolved = await deps.resolveRef((req as AuthedRequest).userId, body); + const schema = + (typeof body.schema === 'string' && body.schema.trim()) || resolved.schema || ''; + const probed = await probeTableInsight({ + dialect: resolved.dialect, + option: resolved.option, + schema, + table, + }); + if (!probed.ok) { + const { status, ...rest } = probed.failure; + res.status(status).send(rest); + return; + } + res.send({ ...probed.value, dialect: resolved.dialect }); + } catch (error: unknown) { + sendThrown(res, error, 'Failed to load table insight'); + } + } + ); + router.post( '/schema/db-access', dbAccessLimiter, diff --git a/packages/server/src/features/schema/table-insight.service.ts b/packages/server/src/features/schema/table-insight.service.ts new file mode 100644 index 00000000..b7150c55 --- /dev/null +++ b/packages/server/src/features/schema/table-insight.service.ts @@ -0,0 +1,76 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Catalog-only table insight for Peek / explorer. Never COUNT(DISTINCT). + */ +import { + ConnectionFactory, + buildTableInsightQuery, + dialectSupportsTableInsight, + normalizeTableInsightRows, + type ConnectionOptions, + type TableInsightResult, + type TableInsightSupport, +} from '@foxschema/db'; + +export async function probeTableInsight(opts: { + dialect: string; + option: ConnectionOptions; + schema?: string; + table: string; +}): Promise< + | { ok: true; value: TableInsightResult } + | { ok: false; failure: { status: number; error: string; support: TableInsightSupport } } +> { + const support = dialectSupportsTableInsight(opts.dialect); + if (!support.query) { + return { + ok: false, + failure: { + status: 400, + error: support.hint || 'This dialect does not support table insight.', + support, + }, + }; + } + const built = buildTableInsightQuery({ + dialect: opts.dialect, + schema: opts.schema, + table: opts.table, + }); + if ('error' in built) { + return { ok: false, failure: { status: 400, error: built.error, support } }; + } + try { + const raw = await ConnectionFactory.executeQuery>( + opts.dialect, + opts.option, + built.sql, + built.params + ); + const norm = normalizeTableInsightRows(opts.dialect, raw); + return { + ok: true, + value: { + table: opts.table, + schema: opts.schema ?? '', + estimatedRows: norm.estimatedRows, + columns: norm.columns, + mode: 'catalog', + support, + warning: + norm.estimatedRows == null && norm.columns.length === 0 + ? `${support.hint} No statistics yet.` + : undefined, + }, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Table insight probe failed'; + return { + ok: false, + failure: { status: 500, error: `${message} — ${support.hint}`, support }, + }; + } +} diff --git a/packages/server/src/features/sql-editor/editor.routes.ts b/packages/server/src/features/sql-editor/editor.routes.ts index e2e900c7..ad828f0a 100644 --- a/packages/server/src/features/sql-editor/editor.routes.ts +++ b/packages/server/src/features/sql-editor/editor.routes.ts @@ -24,7 +24,7 @@ import { import { sqlStatementCategories, statementVerb } from '@foxschema/sql'; import { isSingleSqlStatement } from '../../api/single-statement'; import { permissionSatisfied } from '@foxschema/shared'; -import { clampOffset } from './sql-page-wrap.service'; +import { clampOffset, parseSqlSeek } from './sql-page-wrap.service'; import { makeBeamCellQueryRunner, makeCellQueryRunner } from './code-cell-query.service'; import type { CellQueryRunner } from './code-cell-execute.service'; import { parseBeamEndpoints } from '@foxschema/shared'; @@ -74,11 +74,12 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { // called next(), so the request hung with no error and no response. const writeIdempotency = idempotency(); router.post('/sql/execute', sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => { - const { statements, maxRows, offset, params, datagridAction, ...ref } = req.body as ConnectionRef & { + const { statements, maxRows, offset, params, datagridAction, seek, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown; offset?: unknown; params?: unknown; + seek?: unknown; /** Data Peek / query-result grid CRUD — requires editor.datagrid.*. */ datagridAction?: unknown; }; @@ -150,6 +151,11 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { sendError(res, 'invalid_input', error instanceof Error ? error.message : 'Invalid connection'); return; } + const parsedSeek = parseSqlSeek(seek); + if (!parsedSeek.ok) { + sendError(res, 'invalid_input', parsedSeek.error); + return; + } try { // Apply the saved connection's schema (CURRENT SCHEMA / search_path) so // unqualified names like ORDERS resolve to DEMO.ORDERS, not USER.ORDERS. @@ -160,7 +166,8 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { clampMaxRows(maxRows), resolved.schema, clampOffset(offset), - (params as unknown[][] | undefined) ?? [] + (params as unknown[][] | undefined) ?? [], + parsedSeek.value ); res.send({ results }); } catch (error: unknown) { diff --git a/packages/server/src/features/sql-editor/sql-execute.service.ts b/packages/server/src/features/sql-editor/sql-execute.service.ts index 92008e5e..8cdfc0ce 100644 --- a/packages/server/src/features/sql-editor/sql-execute.service.ts +++ b/packages/server/src/features/sql-editor/sql-execute.service.ts @@ -1,6 +1,6 @@ import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/db'; import { autoAliasSelectColumns } from '@foxschema/sql'; -import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap.service'; +import { isPageableStatement, trimPageProbe, wrapSqlForPage, wrapSqlForSeek, type SqlSeek } from './sql-page-wrap.service'; /** * Helpers behind POST /api/sql/execute (SQL Editor). One request = one @@ -123,7 +123,8 @@ export async function runStatements( schema?: string, offset = 0, /** Bind parameters per statement, aligned by index. Missing = no params. */ - paramsList: readonly (readonly unknown[])[] = [] + paramsList: readonly (readonly unknown[])[] = [], + seek?: SqlSeek ): Promise { const schemaName = (schema ?? option.schema)?.trim() || ''; const optionWithSchema: ConnectionOptions = schemaName @@ -148,7 +149,7 @@ export async function runStatements( const sql = autoAliasSelectColumns(original).sql; // Placeholders survive the paging wrap (it only nests the SQL in a // subquery), so the same positional params apply on either path. - const params = paramsList[index] ?? []; + const params = [...(paramsList[index] ?? [])]; const pushErr = (message: string) => { results.push({ ok: false, error: message, durationMs: Date.now() - started }); }; @@ -190,7 +191,19 @@ export async function runStatements( continue; } - const paged = wrapSqlForPage(sql, dialect, offset, maxRows); + let paged = wrapSqlForPage(sql, dialect, offset, maxRows); + if (seek && offset === 0) { + const keyed = wrapSqlForSeek(sql, dialect, seek, maxRows, params.length); + if ('error' in keyed) { + pushErr(keyed.error); + continue; + } + paged = keyed.sql; + params.push(...keyed.seekParams); + } else if (seek && offset > 0) { + pushErr('Last Id paging cannot skip to an unvisited page; walk Next or use OFFSET when ORDER BY is not unique'); + continue; + } try { const positional = ConnectionFactory.executePositional(dialect, connection, paged, params); const shaped = positional diff --git a/packages/server/src/features/sql-editor/sql-page-wrap.service.test.ts b/packages/server/src/features/sql-editor/sql-page-wrap.service.test.ts index 69dfdbb2..8c97f9c3 100644 --- a/packages/server/src/features/sql-editor/sql-page-wrap.service.test.ts +++ b/packages/server/src/features/sql-editor/sql-page-wrap.service.test.ts @@ -4,6 +4,7 @@ import { isPageableStatement, trimPageProbe, wrapSqlForPage, + wrapSqlForSeek, } from './sql-page-wrap.service'; describe('sql-page-wrap', () => { @@ -93,3 +94,49 @@ describe('sql-page-wrap', () => { ).toBe(false); }); }); + +describe('wrapSqlForSeek', () => { + it('uses a keyset predicate instead of OFFSET', () => { + const out = wrapSqlForSeek( + 'SELECT * FROM t ORDER BY id', + 'postgres', + { columns: ['id'], values: [10] }, + 20, + 0 + ); + expect(out).toHaveProperty('sql'); + if ('sql' in out) { + expect(out.sql).toContain('WHERE ("id" > $1)'); + expect(out.sql).toContain('LIMIT 21'); + expect(out.sql).not.toMatch(/OFFSET/i); + expect(out.seekParams).toEqual([10]); + } + }); + + it('expands a composite key without tuple syntax', () => { + const out = wrapSqlForSeek( + 'SELECT * FROM t ORDER BY org_id, id', + 'sqlite', + { columns: ['org_id', 'id'], values: [1, 9] }, + 5, + 0 + ); + expect(out).toHaveProperty('sql'); + if ('sql' in out) { + expect(out.sql).toContain('("org_id" > ?)'); + expect(out.sql).toContain('("org_id" = ? AND "id" > ?)'); + expect(out.seekParams).toEqual([1, 1, 9]); + } + }); + + it('rejects seek columns that do not match ORDER BY', () => { + const out = wrapSqlForSeek( + 'SELECT * FROM t ORDER BY id', + 'postgres', + { columns: ['name'], values: ['x'] }, + 10, + 0 + ); + expect(out).toEqual({ error: 'seek.columns must match the ORDER BY prefix' }); + }); +}); diff --git a/packages/server/src/features/sql-editor/sql-page-wrap.service.ts b/packages/server/src/features/sql-editor/sql-page-wrap.service.ts index 930133be..356d0a7d 100644 --- a/packages/server/src/features/sql-editor/sql-page-wrap.service.ts +++ b/packages/server/src/features/sql-editor/sql-page-wrap.service.ts @@ -11,6 +11,13 @@ */ import { isPageableStatement } from '@foxschema/db'; +import { + isSafeSeekColumn, + parseTopLevelOrderBy, + placeholderStyleFor, + quoteSqlIdentifier, + renderPlaceholder, +} from '@foxschema/sql'; export { isPageableStatement }; @@ -146,6 +153,104 @@ export function wrapSqlForPage( return `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} LIMIT ${fetchLimit} OFFSET ${offset}`; } +export interface SqlSeek { + columns: string[]; + values: unknown[]; + descending?: boolean | boolean[]; +} + +export function parseSqlSeek(raw: unknown): { ok: true; value?: SqlSeek } | { ok: false; error: string } { + if (raw == null) return { ok: true, value: undefined }; + if (typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, error: 'seek must be an object with columns and values' }; + } + const o = raw as { columns?: unknown; values?: unknown; descending?: unknown }; + if (!Array.isArray(o.columns) || !Array.isArray(o.values)) { + return { ok: false, error: 'seek.columns and seek.values must be arrays' }; + } + if (o.columns.some((c) => typeof c !== 'string')) { + return { ok: false, error: 'seek.columns must be strings' }; + } + let descending: boolean | boolean[] | undefined; + if (typeof o.descending === 'boolean') descending = o.descending; + else if (Array.isArray(o.descending) && o.descending.every((d) => typeof d === 'boolean')) { + descending = o.descending; + } else if (o.descending != null) { + return { ok: false, error: 'seek.descending must be a boolean or boolean[]' }; + } + return { + ok: true, + value: { columns: o.columns as string[], values: o.values, descending }, + }; +} + +function descAt(seek: SqlSeek, i: number): boolean { + if (Array.isArray(seek.descending)) return Boolean(seek.descending[i]); + return Boolean(seek.descending); +} + +/** Last-Id / keyset wrap. OFFSET is not used; +1 probe row still applies. */ +export function wrapSqlForSeek( + sql: string, + dialect: string, + seek: SqlSeek, + limit: number, + existingParamCount: number +): { sql: string; seekParams: unknown[] } | { error: string } { + if (!seek.columns.length || seek.columns.length !== seek.values.length) { + return { error: 'seek.columns and seek.values must be the same non-empty length' }; + } + if (!seek.columns.every(isSafeSeekColumn)) { + return { error: 'seek.columns must be plain identifiers' }; + } + const parsed = parseTopLevelOrderBy(sql); + if (!parsed) { + return { error: 'Last Id paging requires a top-level ORDER BY on columns' }; + } + const orderCols = parsed.terms.map((t) => t.column.toLowerCase()); + const seekCols = seek.columns.map((c) => c.toLowerCase()); + if (seekCols.length > orderCols.length || seekCols.some((c, i) => c !== orderCols[i])) { + return { error: 'seek.columns must match the ORDER BY prefix' }; + } + const d = dialect.toLowerCase(); + const style = placeholderStyleFor(d); + const inner = sql.trim().replace(/;+\s*$/, ''); + const fetchLimit = limit + 1; + const clauses: string[] = []; + const seekParams: unknown[] = []; + const nextPh = (): string => { + const idx = existingParamCount + seekParams.length; + if (TSQL_DIALECTS.has(d)) return `@p${idx}`; + return renderPlaceholder(style, idx + 1); + }; + for (let i = 0; i < seek.columns.length; i++) { + const parts: string[] = []; + for (let j = 0; j < i; j++) { + parts.push(`${quoteSqlIdentifier(seek.columns[j]!, dialect)} = ${nextPh()}`); + seekParams.push(seek.values[j]); + } + const cmp = descAt(seek, i) ? '<' : '>'; + parts.push(`${quoteSqlIdentifier(seek.columns[i]!, dialect)} ${cmp} ${nextPh()}`); + seekParams.push(seek.values[i]); + clauses.push(`(${parts.join(' AND ')})`); + } + const pred = clauses.join(' OR '); + const orderSql = parsed.terms + .map((t) => `${quoteSqlIdentifier(t.column, dialect)}${t.descending ? ' DESC' : ''}`) + .join(', '); + let wrapped: string; + if (TSQL_DIALECTS.has(d)) { + wrapped = `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} WHERE ${pred} ORDER BY ${orderSql} OFFSET 0 ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`; + } else if (d === 'oracle') { + wrapped = `SELECT * FROM (${inner}) ${PAGE_ALIAS} WHERE ${pred} ORDER BY ${orderSql} OFFSET 0 ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`; + } else if (d === 'db2') { + wrapped = `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} WHERE ${pred} ORDER BY ${orderSql} OFFSET 0 ROWS FETCH FIRST ${fetchLimit} ROWS ONLY`; + } else { + wrapped = `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} WHERE ${pred} ORDER BY ${orderSql} LIMIT ${fetchLimit}`; + } + return { sql: wrapped, seekParams }; +} + /** After shaping, drop the probe row and set truncated/hasNext. */ export function trimPageProbe( shaped: T, diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts index 01e96489..6cc9896e 100644 --- a/packages/sql/src/index.ts +++ b/packages/sql/src/index.ts @@ -139,6 +139,29 @@ export type { IndexFragmentationSeverity, IndexUsageQuery, } from './modules/utilities/index-fragmentation.js'; +export { + dialectSupportsTableInsight, + buildTableInsightQuery, + normalizeTableInsightRows, + parseSqliteStat1, + tableInsightDialectIds, +} from './modules/utilities/table-insight.js'; +export type { + TableInsightMode, + TableInsightSupport, + TableInsightQuery, + TableInsightColumn, + TableInsightResult, + TableInsightTarget, +} from './modules/utilities/table-insight.js'; +export { + parseTopLevelOrderBy, + findTopLevelOrderByIndex, + uniqueKeyCoversOrder, + uniqueKeysFromTable, + isSafeSeekColumn, +} from './modules/sql-editor/sql-order-by.js'; +export type { OrderByTerm, ParsedOrderBy } from './modules/sql-editor/sql-order-by.js'; export { dialectSupportsDbaUtility, buildDbaUtilityQuery, diff --git a/packages/sql/src/modules/sql-editor/sql-order-by.test.ts b/packages/sql/src/modules/sql-editor/sql-order-by.test.ts new file mode 100644 index 00000000..82802c7f --- /dev/null +++ b/packages/sql/src/modules/sql-editor/sql-order-by.test.ts @@ -0,0 +1,77 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + isSafeSeekColumn, + parseTopLevelOrderBy, + uniqueKeyCoversOrder, + uniqueKeysFromTable, +} from './sql-order-by'; + +describe('parseTopLevelOrderBy', () => { + it('reads a single column', () => { + expect(parseTopLevelOrderBy('SELECT * FROM t ORDER BY id')).toEqual({ + terms: [{ column: 'id', descending: false }], + }); + }); + + it('reads composite DESC terms', () => { + expect(parseTopLevelOrderBy('SELECT * FROM t ORDER BY org_id ASC, id DESC')).toEqual({ + terms: [ + { column: 'org_id', descending: false }, + { column: 'id', descending: true }, + ], + }); + }); + + it('strips alias qualifiers and quotes', () => { + expect(parseTopLevelOrderBy('SELECT * FROM t a ORDER BY a."Id"')).toEqual({ + terms: [{ column: 'Id', descending: false }], + }); + }); + + it('rejects ordinals and expressions', () => { + expect(parseTopLevelOrderBy('SELECT * FROM t ORDER BY 1')).toBeNull(); + expect(parseTopLevelOrderBy('SELECT * FROM t ORDER BY id + 1')).toBeNull(); + }); + + it('does not confuse ORDERS with ORDER BY', () => { + expect(parseTopLevelOrderBy('SELECT * FROM ORDERS ORDER BY id')).toEqual({ + terms: [{ column: 'id', descending: false }], + }); + }); +}); + +describe('uniqueKeyCoversOrder', () => { + it('accepts PK prefix of ORDER BY', () => { + expect(uniqueKeyCoversOrder([['id']], ['id'])).toBe(true); + expect(uniqueKeyCoversOrder([['org', 'id']], ['org', 'id', 'name'])).toBe(true); + }); + + it('rejects a leading subset of a composite key', () => { + expect(uniqueKeyCoversOrder([['org', 'id']], ['org'])).toBe(false); + }); +}); + +describe('uniqueKeysFromTable', () => { + it('includes PK and unique indexes', () => { + const keys = uniqueKeysFromTable({ + primaryKey: { columns: ['id'] }, + columns: [{ name: 'id', primaryKey: true }, { name: 'email' }], + indices: [{ name: 'u_email', unique: true, columns: ['email'] }], + }); + expect(keys).toEqual([['id'], ['email']]); + }); +}); + +describe('isSafeSeekColumn', () => { + it('allows plain identifiers only', () => { + expect(isSafeSeekColumn('id')).toBe(true); + expect(isSafeSeekColumn('org_id')).toBe(true); + expect(isSafeSeekColumn('id;drop')).toBe(false); + expect(isSafeSeekColumn('a.b')).toBe(false); + }); +}); diff --git a/packages/sql/src/modules/sql-editor/sql-order-by.ts b/packages/sql/src/modules/sql-editor/sql-order-by.ts new file mode 100644 index 00000000..c5f7b92b --- /dev/null +++ b/packages/sql/src/modules/sql-editor/sql-order-by.ts @@ -0,0 +1,189 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Top-level ORDER BY parsing and uniqueness checks for Last Id paging. + * OFFSET remains the fallback when the order key is not unique. + */ +import type { IndexInfo, PrimaryKeyInfo } from '../../interfaces/schema.interface.js'; + +export interface OrderByTerm { + /** Unquoted identifier; last segment if `schema.col` / `alias.col`. */ + column: string; + descending: boolean; +} + +export interface ParsedOrderBy { + terms: OrderByTerm[]; +} + +const IDENT = /^(?:(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*))(?:\.(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*)))?)$/; + +function unquoteIdent(raw: string): string | null { + const t = raw.trim(); + const m = IDENT.exec(t); + if (!m) return null; + const col = m[5] ?? m[6] ?? m[7] ?? m[8] ?? m[1] ?? m[2] ?? m[3] ?? m[4]; + return col || null; +} + +/** + * True when `sql` has a top-level `ORDER BY` (paren depth 0), ignoring + * strings and comments. Same rules as the page wrap. + */ +export function findTopLevelOrderByIndex(sql: string): number { + let depth = 0; + let i = 0; + const n = sql.length; + while (i < n) { + const ch = sql[i]!; + const next = sql[i + 1] ?? ''; + if (ch === '-' && next === '-') { + i += 2; + while (i < n && sql[i] !== '\n') i++; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < n - 1 && !(sql[i] === '*' && sql[i + 1] === '/')) i++; + i = Math.min(n, i + 2); + continue; + } + if (ch === "'") { + i++; + while (i < n) { + if (sql[i] === "'" && sql[i + 1] === "'") { + i += 2; + continue; + } + if (sql[i] === "'") { + i++; + break; + } + i++; + } + continue; + } + if (ch === '"') { + i++; + while (i < n) { + if (sql[i] === '"' && sql[i + 1] === '"') { + i += 2; + continue; + } + if (sql[i] === '"') { + i++; + break; + } + i++; + } + continue; + } + if (ch === '[') { + i++; + while (i < n && sql[i] !== ']') i++; + i = Math.min(n, i + 1); + continue; + } + if (ch === '(') { + depth++; + i++; + continue; + } + if (ch === ')') { + depth = Math.max(0, depth - 1); + i++; + continue; + } + if (depth === 0 && (ch === 'o' || ch === 'O')) { + const before = i === 0 ? ' ' : sql[i - 1]!; + if (!/[A-Za-z0-9_]/.test(before) && /^order\s+by\b/i.test(sql.slice(i))) return i; + } + i++; + } + return -1; +} + +/** Parse top-level ORDER BY into simple column terms. Expressions / ordinals → null. */ +export function parseTopLevelOrderBy(sql: string): ParsedOrderBy | null { + const at = findTopLevelOrderByIndex(sql); + if (at < 0) return null; + const afterKw = sql.slice(at).replace(/^order\s+by\s+/i, ''); + const terms: OrderByTerm[] = []; + let buf = ''; + let depth = 0; + let i = 0; + const flush = (): boolean => { + const raw = buf.trim().replace(/,+\s*$/, ''); + buf = ''; + if (!raw) return true; + const desc = /\s+desc\s*$/i.test(raw); + const asc = /\s+asc\s*$/i.test(raw); + const ident = raw.replace(/\s+(asc|desc)\s*$/i, '').trim(); + if (/^\d+$/.test(ident)) return false; + const column = unquoteIdent(ident); + if (!column) return false; + terms.push({ column, descending: desc && !asc ? true : desc }); + return true; + }; + while (i < afterKw.length) { + const ch = afterKw[i]!; + if (ch === '(') depth++; + if (ch === ')') depth = Math.max(0, depth - 1); + if (depth === 0 && ch === ',') { + if (!flush()) return null; + i++; + continue; + } + if (depth === 0 && ch === ';') break; + buf += ch; + i++; + } + if (!flush()) return null; + if (terms.length === 0) return null; + return { terms }; +} + +export function uniqueKeysFromTable(table: { + primaryKey?: PrimaryKeyInfo; + columns: readonly { name: string; primaryKey?: boolean }[]; + indices: readonly IndexInfo[]; +}): string[][] { + const keys: string[][] = []; + const pk = + table.primaryKey?.columns?.filter(Boolean) ?? + table.columns.filter((c) => c.primaryKey).map((c) => c.name); + if (pk.length > 0) keys.push(pk); + for (const idx of table.indices) { + if (!idx.unique || !idx.columns?.length) continue; + keys.push(idx.columns); + } + return keys; +} + +/** + * A unique key covers ORDER BY when the key columns are a prefix of the order + * list (same names, same order, case-insensitive). ORDER BY extra columns after + * a unique prefix is still unique. + */ +export function uniqueKeyCoversOrder( + uniqueKeys: readonly string[][], + orderColumns: readonly string[] +): boolean { + if (orderColumns.length === 0) return false; + const order = orderColumns.map((c) => c.toLowerCase()); + for (const key of uniqueKeys) { + if (key.length === 0 || key.length > order.length) continue; + const ok = key.every((col, i) => col.toLowerCase() === order[i]); + if (ok) return true; + } + return false; +} + +const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/; + +/** Reject names the wrap would otherwise quote from a hostile client. */ +export function isSafeSeekColumn(name: string): boolean { + return SAFE_IDENT.test(name) && name.length <= 128; +} diff --git a/packages/sql/src/modules/utilities/table-insight.registry.ts b/packages/sql/src/modules/utilities/table-insight.registry.ts new file mode 100644 index 00000000..58670a09 --- /dev/null +++ b/packages/sql/src/modules/utilities/table-insight.registry.ts @@ -0,0 +1,43 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Maps a dialect id to its catalog-only table-insight probe. + */ +import type { TableInsightDialect } from './table-insight.types.js'; +import { postgresTableInsight } from '../../providers/postgres/postgres.table-insight.js'; +import { cockroachDbTableInsight } from '../../providers/cockroachDb/cockroachdb.table-insight.js'; +import { yugabyteDbTableInsight } from '../../providers/yugabyteDb/yugabytedb.table-insight.js'; +import { mysqlTableInsight } from '../../providers/mysql/mysql.table-insight.js'; +import { mariaDbTableInsight } from '../../providers/mariaDb/mariadb.table-insight.js'; +import { tiDbTableInsight } from '../../providers/tiDb/tidb.table-insight.js'; +import { sqlServerTableInsight } from '../../providers/sqlServer/sqlserver.table-insight.js'; +import { azureSqlTableInsight } from '../../providers/azureSql/azuresql.table-insight.js'; +import { oracleTableInsight } from '../../providers/oracle/oracle.table-insight.js'; +import { db2TableInsight } from '../../providers/db2/db2.table-insight.js'; +import { sqliteTableInsight } from '../../providers/sqlLite/sqlite.table-insight.js'; +import { duckDbTableInsight } from '../../providers/duckDb/duckdb.table-insight.js'; +import { clickHouseTableInsight } from '../../providers/clickHouse/clickhouse.table-insight.js'; +import { redshiftTableInsight } from '../../providers/redshift/redshift.table-insight.js'; + +export const TABLE_INSIGHT_MAP: Record = { + postgres: postgresTableInsight, + cockroachdb: cockroachDbTableInsight, + yugabytedb: yugabyteDbTableInsight, + mysql: mysqlTableInsight, + mariadb: mariaDbTableInsight, + tidb: tiDbTableInsight, + sqlserver: sqlServerTableInsight, + azuresql: azureSqlTableInsight, + oracle: oracleTableInsight, + db2: db2TableInsight, + sqlite: sqliteTableInsight, + duckdb: duckDbTableInsight, + clickhouse: clickHouseTableInsight, + redshift: redshiftTableInsight, +}; + +export function resolveTableInsight(dialect: string): TableInsightDialect | undefined { + return TABLE_INSIGHT_MAP[(dialect || '').toLowerCase()]; +} diff --git a/packages/sql/src/modules/utilities/table-insight.test.ts b/packages/sql/src/modules/utilities/table-insight.test.ts new file mode 100644 index 00000000..b886bbca --- /dev/null +++ b/packages/sql/src/modules/utilities/table-insight.test.ts @@ -0,0 +1,42 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + buildTableInsightQuery, + dialectSupportsTableInsight, + tableInsightDialectIds, +} from './table-insight'; + +describe('table insight probes', () => { + it('offers a catalog probe for every registered dialect', () => { + const ids = tableInsightDialectIds(); + expect(ids.length).toBeGreaterThan(8); + for (const dialect of ids) { + const support = dialectSupportsTableInsight(dialect); + expect(support.query).toBe(true); + const q = buildTableInsightQuery({ dialect, schema: 'public', table: 'orders' }); + expect(q).toHaveProperty('sql'); + if ('sql' in q) { + expect(q.sql).not.toMatch(/count\s*\(\s*distinct/i); + expect(q.sql.toLowerCase()).not.toContain('count(distinct'); + } + } + }); + + it('fails closed without a table name', () => { + expect(buildTableInsightQuery({ dialect: 'postgres', table: ' ' })).toEqual({ + error: 'table is required.', + }); + }); + + it('uses pg_stats on postgres and sqlite_stat1 on sqlite', () => { + const pg = buildTableInsightQuery({ dialect: 'postgres', schema: 'app', table: 't' }); + expect(pg).toMatchObject({ params: ['app', 't'] }); + if ('sql' in pg) expect(pg.sql.toLowerCase()).toContain('pg_stats'); + const lite = buildTableInsightQuery({ dialect: 'sqlite', table: 't' }); + if ('sql' in lite) expect(lite.sql.toLowerCase()).toContain('sqlite_stat1'); + }); +}); diff --git a/packages/sql/src/modules/utilities/table-insight.ts b/packages/sql/src/modules/utilities/table-insight.ts new file mode 100644 index 00000000..9bbb4c7d --- /dev/null +++ b/packages/sql/src/modules/utilities/table-insight.ts @@ -0,0 +1,111 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Catalog-only table insight facade. Dialects know catalog names; this file + * does not. + */ +export type { + TableInsightMode, + TableInsightSupport, + TableInsightQuery, + TableInsightColumn, + TableInsightResult, + TableInsightTarget, + TableInsightDialect, +} from './table-insight.types.js'; +import type { + TableInsightQuery, + TableInsightSupport, + TableInsightColumn, +} from './table-insight.types.js'; +import { resolveTableInsight, TABLE_INSIGHT_MAP } from './table-insight.registry.js'; + +const UNSUPPORTED: TableInsightSupport = { + mode: 'unsupported', + query: false, + hint: 'This dialect does not expose catalog table statistics.', +}; + +export function dialectSupportsTableInsight(dialect: string): TableInsightSupport { + return resolveTableInsight(dialect)?.support ?? UNSUPPORTED; +} + +export function buildTableInsightQuery(opts: { + dialect: string; + schema?: string; + table: string; +}): TableInsightQuery | { error: string } { + const impl = resolveTableInsight(opts.dialect); + const support = impl?.support ?? UNSUPPORTED; + if (!impl || !support.query) { + return { error: support.hint || 'Unsupported dialect for table insight.' }; + } + const table = (opts.table || '').trim(); + if (!table) return { error: 'table is required.' }; + return impl.probe({ schema: (opts.schema || '').trim(), table }); +} + +export function tableInsightDialectIds(): string[] { + return Object.keys(TABLE_INSIGHT_MAP); +} + +function num(v: unknown): number | null { + if (v == null || v === '') return null; + const n = typeof v === 'number' ? v : Number(String(v).split(/\s+/)[0]); + return Number.isFinite(n) ? n : null; +} + +function str(v: unknown): string | null { + if (v == null) return null; + const s = String(v).trim(); + return s ? s : null; +} + +/** sqlite_stat1.stat is "N D1 D2…" — N is estimated rows, D1 is n_distinct of first col. */ +export function parseSqliteStat1(stat: unknown): { estimatedRows: number | null; nDistinct: number | null } { + const parts = String(stat ?? '') + .trim() + .split(/\s+/) + .map((p) => Number(p)) + .filter((n) => Number.isFinite(n)); + return { + estimatedRows: parts[0] ?? null, + nDistinct: parts[1] ?? null, + }; +} + +export function normalizeTableInsightRows( + dialect: string, + raw: unknown +): { estimatedRows: number | null; columns: TableInsightColumn[] } { + const rows: Record[] = Array.isArray(raw) ? raw : []; + const d = dialect.toLowerCase(); + const columns: TableInsightColumn[] = []; + let estimatedRows: number | null = null; + const seen = new Set(); + for (const row of rows) { + if (estimatedRows == null) { + if (d === 'sqlite' || d === 'duckdb') { + estimatedRows = parseSqliteStat1(row.n_distinct ?? row.stat).estimatedRows; + } else { + estimatedRows = num(row.estimated_rows ?? row.TABLE_ROWS ?? row.num_rows); + } + } + const name = str(row.column_name ?? row.idx); + if (!name || seen.has(name.toLowerCase())) continue; + seen.add(name.toLowerCase()); + if (d === 'sqlite' || d === 'duckdb') { + const parsed = parseSqliteStat1(row.n_distinct ?? row.stat); + columns.push({ name, nDistinct: parsed.nDistinct, nullFrac: null }); + } else { + columns.push({ + name, + nDistinct: num(row.n_distinct), + nullFrac: num(row.null_frac), + }); + } + } + return { estimatedRows, columns }; +} diff --git a/packages/sql/src/modules/utilities/table-insight.types.ts b/packages/sql/src/modules/utilities/table-insight.types.ts new file mode 100644 index 00000000..a56aab17 --- /dev/null +++ b/packages/sql/src/modules/utilities/table-insight.types.ts @@ -0,0 +1,59 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Catalog-only table insight. Never scan live rows; never COUNT(DISTINCT). + */ +import { quoteSqlIdentifier } from '../sql-text/sql-template.js'; + +export type TableInsightMode = 'catalog' | 'unsupported'; + +export interface TableInsightSupport { + mode: TableInsightMode; + query: boolean; + hint: string; +} + +export interface TableInsightQuery { + sql: string; + params: unknown[]; + mode: 'catalog'; +} + +export interface TableInsightColumn { + name: string; + nDistinct: number | null; + nullFrac: number | null; +} + +export interface TableInsightResult { + table: string; + schema: string; + estimatedRows: number | null; + columns: TableInsightColumn[]; + mode: TableInsightMode; + support: TableInsightSupport; + warning?: string; +} + +export interface TableInsightTarget { + schema: string; + table: string; +} + +export interface TableInsightDialect { + readonly id: string; + support: TableInsightSupport; + probe(target: TableInsightTarget): TableInsightQuery | { error: string }; +} + +export function quotedInsightTarget(dialect: string, target: TableInsightTarget): { + schema: string; + table: string; +} { + return { + schema: quoteSqlIdentifier(target.schema || '', dialect), + table: quoteSqlIdentifier(target.table, dialect), + }; +} diff --git a/packages/sql/src/providers/azureSql/azuresql.table-insight.ts b/packages/sql/src/providers/azureSql/azuresql.table-insight.ts new file mode 100644 index 00000000..f9d7710a --- /dev/null +++ b/packages/sql/src/providers/azureSql/azuresql.table-insight.ts @@ -0,0 +1,6 @@ +import { makeSqlServerTableInsight } from '../sqlServer/sqlserver.table-insight.js'; + +export const azureSqlTableInsight = makeSqlServerTableInsight( + 'azuresql', + 'Azure SQL: sys.dm_db_partition_stats row counts (catalog).' +); diff --git a/packages/sql/src/providers/clickHouse/clickhouse.table-insight.ts b/packages/sql/src/providers/clickHouse/clickhouse.table-insight.ts new file mode 100644 index 00000000..168eacdd --- /dev/null +++ b/packages/sql/src/providers/clickHouse/clickhouse.table-insight.ts @@ -0,0 +1,41 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * ClickHouse: system.tables.total_rows. No live COUNT(DISTINCT). + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'ClickHouse: system.tables.total_rows.', +}; + +const SQL = ` +SELECT + name AS column_name, + NULL AS n_distinct, + NULL AS null_frac, + total_rows AS estimated_rows +FROM system.tables +WHERE database = $1 AND name = $2 +`.trim(); + +export const clickHouseTableInsight: TableInsightDialect = { + id: 'clickhouse', + support: SUPPORT, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [target.schema, target.table], + }; + }, +}; diff --git a/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts b/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts new file mode 100644 index 00000000..b3ecf768 --- /dev/null +++ b/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts @@ -0,0 +1,6 @@ +import { makePostgresTableInsight } from '../postgres/postgres.table-insight.js'; + +export const cockroachDbTableInsight = makePostgresTableInsight( + 'cockroachdb', + 'CockroachDB: pg_stats / table statistics (estimated).' +); diff --git a/packages/sql/src/providers/db2/db2.table-insight.ts b/packages/sql/src/providers/db2/db2.table-insight.ts new file mode 100644 index 00000000..79149378 --- /dev/null +++ b/packages/sql/src/providers/db2/db2.table-insight.ts @@ -0,0 +1,44 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Db2: SYSCAT.COLUMNS.COLCARD / SYSCAT.TABLES.CARD. No live COUNT(DISTINCT). + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'Db2: SYSCAT.COLUMNS.COLCARD and SYSCAT.TABLES.CARD.', +}; + +const SQL = ` +SELECT + c.COLNAME AS column_name, + c.COLCARD AS n_distinct, + NULL AS null_frac, + t.CARD AS estimated_rows +FROM SYSCAT.COLUMNS c +JOIN SYSCAT.TABLES t + ON t.TABSCHEMA = c.TABSCHEMA AND t.TABNAME = c.TABNAME +WHERE c.TABSCHEMA = ? AND c.TABNAME = ? +ORDER BY c.COLNO +`.trim(); + +export const db2TableInsight: TableInsightDialect = { + id: 'db2', + support: SUPPORT, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [(target.schema || '').toUpperCase(), target.table.toUpperCase()], + }; + }, +}; diff --git a/packages/sql/src/providers/duckDb/duckdb.table-insight.ts b/packages/sql/src/providers/duckDb/duckdb.table-insight.ts new file mode 100644 index 00000000..9f32b874 --- /dev/null +++ b/packages/sql/src/providers/duckDb/duckdb.table-insight.ts @@ -0,0 +1,6 @@ +import { makeSqliteTableInsight } from '../sqlLite/sqlite.table-insight.js'; + +export const duckDbTableInsight = makeSqliteTableInsight( + 'duckdb', + 'DuckDB: sqlite_stat1-style catalog stats when ANALYZE has run.' +); diff --git a/packages/sql/src/providers/mariaDb/mariadb.table-insight.ts b/packages/sql/src/providers/mariaDb/mariadb.table-insight.ts new file mode 100644 index 00000000..eb0ff88e --- /dev/null +++ b/packages/sql/src/providers/mariaDb/mariadb.table-insight.ts @@ -0,0 +1,6 @@ +import { makeMysqlTableInsight } from '../mysql/mysql.table-insight.js'; + +export const mariaDbTableInsight = makeMysqlTableInsight( + 'mariadb', + 'MariaDB: information_schema.TABLES / STATISTICS.' +); diff --git a/packages/sql/src/providers/mysql/mysql.table-insight.ts b/packages/sql/src/providers/mysql/mysql.table-insight.ts new file mode 100644 index 00000000..7525131e --- /dev/null +++ b/packages/sql/src/providers/mysql/mysql.table-insight.ts @@ -0,0 +1,49 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * MySQL: information_schema.TABLES.TABLE_ROWS + STATISTICS.CARDINALITY. + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'MySQL: information_schema.TABLES / STATISTICS (SHOW TABLE STATUS equivalent).', +}; + +const SQL = ` +SELECT + s.COLUMN_NAME AS column_name, + s.CARDINALITY AS n_distinct, + NULL AS null_frac, + t.TABLE_ROWS AS estimated_rows +FROM information_schema.TABLES t +LEFT JOIN information_schema.STATISTICS s + ON s.TABLE_SCHEMA = t.TABLE_SCHEMA AND s.TABLE_NAME = t.TABLE_NAME +WHERE t.TABLE_SCHEMA = ? + AND t.TABLE_NAME = ? +ORDER BY s.SEQ_IN_INDEX, s.COLUMN_NAME +`.trim(); + +export function makeMysqlTableInsight(id: string, hint = SUPPORT.hint): TableInsightDialect { + return { + id, + support: { ...SUPPORT, hint }, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [target.schema, target.table], + }; + }, + }; +} + +export const mysqlTableInsight = makeMysqlTableInsight('mysql'); diff --git a/packages/sql/src/providers/oracle/oracle.table-insight.ts b/packages/sql/src/providers/oracle/oracle.table-insight.ts new file mode 100644 index 00000000..693f35bc --- /dev/null +++ b/packages/sql/src/providers/oracle/oracle.table-insight.ts @@ -0,0 +1,45 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Oracle: ALL_TAB_COL_STATISTICS / ALL_TAB_STATISTICS. No live COUNT(DISTINCT). + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'Oracle: ALL_TAB_COL_STATISTICS (NUM_DISTINCT) and NUM_ROWS.', +}; + +const SQL = ` +SELECT + c.column_name AS column_name, + c.num_distinct AS n_distinct, + CASE WHEN t.num_rows IS NULL OR t.num_rows = 0 THEN NULL + ELSE c.num_nulls / t.num_rows END AS null_frac, + t.num_rows AS estimated_rows +FROM all_tab_col_statistics c +JOIN all_tab_statistics t + ON t.owner = c.owner AND t.table_name = c.table_name +WHERE c.owner = :1 AND c.table_name = :2 +ORDER BY c.column_name +`.trim(); + +export const oracleTableInsight: TableInsightDialect = { + id: 'oracle', + support: SUPPORT, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [(target.schema || '').toUpperCase(), target.table.toUpperCase()], + }; + }, +}; diff --git a/packages/sql/src/providers/postgres/postgres.table-insight.ts b/packages/sql/src/providers/postgres/postgres.table-insight.ts new file mode 100644 index 00000000..3758c7ac --- /dev/null +++ b/packages/sql/src/providers/postgres/postgres.table-insight.ts @@ -0,0 +1,49 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * PostgreSQL: pg_stats + pg_class.reltuples. No live COUNT(DISTINCT). + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'PostgreSQL: pg_stats.n_distinct and pg_class.reltuples (ANALYZE).', +}; + +const SQL = ` +SELECT + s.attname AS column_name, + s.n_distinct AS n_distinct, + s.null_frac AS null_frac, + c.reltuples::bigint AS estimated_rows +FROM pg_stats s +JOIN pg_namespace n ON n.nspname = s.schemaname +JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = s.tablename +WHERE s.schemaname = $1 + AND s.tablename = $2 +ORDER BY s.attname +`.trim(); + +export function makePostgresTableInsight(id: string, hint = SUPPORT.hint): TableInsightDialect { + return { + id, + support: { ...SUPPORT, hint }, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [target.schema || 'public', target.table], + }; + }, + }; +} + +export const postgresTableInsight = makePostgresTableInsight('postgres'); diff --git a/packages/sql/src/providers/redshift/redshift.table-insight.ts b/packages/sql/src/providers/redshift/redshift.table-insight.ts new file mode 100644 index 00000000..2859a425 --- /dev/null +++ b/packages/sql/src/providers/redshift/redshift.table-insight.ts @@ -0,0 +1,6 @@ +import { makePostgresTableInsight } from '../postgres/postgres.table-insight.js'; + +export const redshiftTableInsight = makePostgresTableInsight( + 'redshift', + 'Redshift: pg_stats-style catalog estimates.' +); diff --git a/packages/sql/src/providers/sqlLite/sqlite.table-insight.ts b/packages/sql/src/providers/sqlLite/sqlite.table-insight.ts new file mode 100644 index 00000000..4e4fb547 --- /dev/null +++ b/packages/sql/src/providers/sqlLite/sqlite.table-insight.ts @@ -0,0 +1,45 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * SQLite: sqlite_stat1 after ANALYZE. No live COUNT(DISTINCT). + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'SQLite: sqlite_stat1 (run ANALYZE). Empty until statistics exist.', +}; + +const SQL = ` +SELECT + idx AS column_name, + stat AS n_distinct, + NULL AS null_frac, + CAST(NULL AS INTEGER) AS estimated_rows +FROM sqlite_stat1 +WHERE tbl = ? +`.trim(); + +export function makeSqliteTableInsight(id: string, hint = SUPPORT.hint): TableInsightDialect { + return { + id, + support: { ...SUPPORT, hint }, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [target.table], + }; + }, + }; +} + +export const sqliteTableInsight = makeSqliteTableInsight('sqlite'); diff --git a/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts b/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts new file mode 100644 index 00000000..07bd8892 --- /dev/null +++ b/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts @@ -0,0 +1,49 @@ +/** + * Fox Schema (@foxschema/sql) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * SQL Server: partition row counts. No COUNT(DISTINCT) on live tables. + */ +import type { + TableInsightDialect, + TableInsightQuery, + TableInsightSupport, + TableInsightTarget, +} from '../../modules/utilities/table-insight.types.js'; + +const SUPPORT: TableInsightSupport = { + mode: 'catalog', + query: true, + hint: 'SQL Server: sys.dm_db_partition_stats row counts (catalog).', +}; + +const SQL = ` +SELECT + c.name AS column_name, + NULL AS n_distinct, + NULL AS null_frac, + SUM(p.row_count) OVER () AS estimated_rows +FROM sys.tables t +INNER JOIN sys.schemas s ON s.schema_id = t.schema_id +INNER JOIN sys.dm_db_partition_stats p + ON p.object_id = t.object_id AND p.index_id IN (0, 1) +LEFT JOIN sys.columns c ON c.object_id = t.object_id +WHERE s.name = @p0 AND t.name = @p1 +`.trim(); + +export function makeSqlServerTableInsight(id: string, hint = SUPPORT.hint): TableInsightDialect { + return { + id, + support: { ...SUPPORT, hint }, + probe(target: TableInsightTarget): TableInsightQuery { + return { + mode: 'catalog', + sql: SQL, + params: [target.schema || 'dbo', target.table], + }; + }, + }; +} + +export const sqlServerTableInsight = makeSqlServerTableInsight('sqlserver'); diff --git a/packages/sql/src/providers/tiDb/tidb.table-insight.ts b/packages/sql/src/providers/tiDb/tidb.table-insight.ts new file mode 100644 index 00000000..f54254fe --- /dev/null +++ b/packages/sql/src/providers/tiDb/tidb.table-insight.ts @@ -0,0 +1,6 @@ +import { makeMysqlTableInsight } from '../mysql/mysql.table-insight.js'; + +export const tiDbTableInsight = makeMysqlTableInsight( + 'tidb', + 'TiDB: information_schema.TABLES / STATISTICS.' +); diff --git a/packages/sql/src/providers/yugabyteDb/yugabytedb.table-insight.ts b/packages/sql/src/providers/yugabyteDb/yugabytedb.table-insight.ts new file mode 100644 index 00000000..70926401 --- /dev/null +++ b/packages/sql/src/providers/yugabyteDb/yugabytedb.table-insight.ts @@ -0,0 +1,6 @@ +import { makePostgresTableInsight } from '../postgres/postgres.table-insight.js'; + +export const yugabyteDbTableInsight = makePostgresTableInsight( + 'yugabytedb', + 'YugabyteDB: pg_stats / table statistics (estimated).' +); From 61c311ea784b9eb29545823fb764cb0daf3897bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 19:15:38 +0000 Subject: [PATCH 04/17] =?UTF-8?q?feat(access):=20P4=20one=20session=20?= =?UTF-8?q?=E2=80=94=20principal=20tree,=20Account/Grants/Effective,=20gen?= =?UTF-8?q?erate-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Access → Permission is now one cached catalog: a principal tree plus Account, Grants, and Effective stages. Confirm copies SQL or opens the SQL Editor; GRANT/REVOKE execute stays in Database Access. Co-authored-by: huy.phan9 --- .../components/AccessPermissionPanel.test.tsx | 206 ++++++++ .../components/AccessPermissionPanel.tsx | 496 +++++++++++++----- .../access/components/AccessView.test.tsx | 11 + .../features/access/components/AccessView.tsx | 9 +- .../components/DbAccessPermissionSections.tsx | 16 +- .../components/PermissionInspector.test.tsx | 62 +++ .../access/components/PermissionInspector.tsx | 131 +++-- 7 files changed, 739 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx create mode 100644 apps/web/src/frontend/features/access/components/PermissionInspector.test.tsx diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx new file mode 100644 index 00000000..0b83849c --- /dev/null +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx @@ -0,0 +1,206 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DEFAULT_ROLE_PERMISSIONS } from '@foxschema/shared'; +import { useAuthStore } from '@/app/store/authStore'; + +const fetchDbAccess = vi.fn(); +const fetchSchemaList = vi.fn(); +const loadSchema = vi.fn(); +const runAccessSql = vi.fn(); +const setSql = vi.fn(); +const ensureConnectionSelected = vi.fn(); +const setActiveView = vi.fn(); + +vi.mock('@/app/store/useSyncStore', () => { + const state = { + connections: [ + { id: 'c1', name: 'Demo PG', dialect: 'postgres', database: 'app', schema: 'public' }, + ], + }; + return { + useSyncStore: (sel: (s: typeof state) => unknown) => sel(state), + }; +}); + +vi.mock('@/app/store/useSqlEditorStore', () => { + const state = { + sessionPasswords: {} as Record, + setSql: (...args: unknown[]) => setSql(...args), + ensureConnectionSelected: (...args: unknown[]) => ensureConnectionSelected(...args), + schemaCache: { c1: { status: 'ready', tables: [] as [] } }, + ensureSchema: vi.fn(), + }; + return { + useSqlEditorStore: (sel: (s: typeof state) => unknown) => sel(state), + }; +}); + +vi.mock('@/app/store/uiStore', () => ({ + useUiStore: (sel: (s: { setActiveView: typeof setActiveView }) => unknown) => + sel({ setActiveView }), +})); + +vi.mock('@/shared/api/schemaApi', () => ({ + fetchDbAccess: (...args: unknown[]) => fetchDbAccess(...args), + fetchSchemaList: (...args: unknown[]) => fetchSchemaList(...args), + loadSchema: (...args: unknown[]) => loadSchema(...args), +})); + +vi.mock('@/shared/api/accessSql', () => ({ + runAccessSql: (...args: unknown[]) => runAccessSql(...args), +})); + +import { AccessPermissionPanel } from './AccessPermissionPanel'; + +const catalog = { + dialect: 'postgres', + schema: 'public', + mode: 'native' as const, + support: { mode: 'native', query: true, grant: true, hint: 'PostgreSQL catalog' }, + principals: [ + { + name: 'alice', + kind: 'user' as const, + canLogin: true, + memberOf: ['readonly'], + members: [], + }, + { + name: 'readonly', + kind: 'role' as const, + canLogin: false, + memberOf: [], + members: ['alice'], + }, + ], + privileges: [ + { + grantee: 'alice', + privilege: 'SELECT', + objectType: 'TABLE' as const, + objectSchema: 'public', + objectName: 'orders', + grantable: false, + grantor: null, + state: 'grant' as const, + }, + ], +}; + +beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + fetchDbAccess.mockReset(); + fetchSchemaList.mockReset(); + loadSchema.mockReset(); + runAccessSql.mockReset(); + setSql.mockReset(); + ensureConnectionSelected.mockReset(); + setActiveView.mockReset(); + fetchDbAccess.mockResolvedValue(catalog); + fetchSchemaList.mockResolvedValue(['public']); + loadSchema.mockResolvedValue({ + tables: [{ name: 'orders', objectType: 'TABLE' }], + }); + useAuthStore.setState({ + user: { + id: 'owner', + email: 'owner@example.com', + onboardingCompleted: true, + role: 'owner', + permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], + }, + status: 'ready', + localSingleUser: false, + error: null, + busy: false, + refreshMe: vi.fn(async () => {}), + }); +}); + +describe('AccessPermissionPanel — one session', () => { + it('loads a principal tree and Account / Grants / Effective on one catalog', async () => { + render(); + fireEvent.change(screen.getByTestId('access-permission-connection'), { + target: { value: 'c1' }, + }); + await waitFor(() => + expect(screen.getByTestId('access-permission-principal').textContent).toMatch(/alice/) + ); + expect(screen.getByTestId('access-permission-principal').textContent).toMatch(/readonly/); + expect(fetchDbAccess).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId('access-permission-row-alice')); + fireEvent.click(screen.getByTestId('access-permission-stage-account')); + expect(screen.getByTestId('access-permission-account-kind').textContent).toMatch(/user/i); + expect(screen.getByTestId('access-permission-account-login').textContent).toMatch(/Can log in/); + expect(screen.getByTestId('access-permission-account-memberof').textContent).toMatch(/readonly/); + + fireEvent.click(screen.getByTestId('access-permission-row-readonly')); + expect(screen.getByTestId('access-permission-account-members').textContent).toMatch(/alice/); + + fireEvent.click(screen.getByTestId('access-permission-stage-grants')); + expect(screen.getByTestId('db-access-permission-sections')).toBeTruthy(); + + fireEvent.click(screen.getByTestId('access-permission-stage-effective')); + await waitFor(() => expect(screen.getByTestId('permission-inspector')).toBeTruthy()); + expect(screen.queryByTestId('inspector-load')).toBeNull(); + expect(fetchDbAccess).toHaveBeenCalledTimes(1); + }); + + it('copies GRANT SQL instead of executing it', async () => { + render(); + fireEvent.change(screen.getByTestId('access-permission-connection'), { + target: { value: 'c1' }, + }); + await waitFor(() => expect(screen.getByTestId('access-permission-row-alice')).toBeTruthy()); + fireEvent.click(screen.getByTestId('access-permission-row-alice')); + + fireEvent.click(screen.getByTestId('db-access-expand-table')); + await waitFor(() => expect(screen.getByTestId('db-access-obj-public-orders')).toBeTruthy()); + fireEvent.click(screen.getByTestId('db-access-edit-orders')); + await waitFor(() => expect(screen.getByTestId('db-access-object-editor')).toBeTruthy()); + fireEvent.click(screen.getByTestId('db-access-preview-sql')); + await waitFor(() => expect(screen.getByTestId('db-access-sql-modal')).toBeTruthy()); + expect(screen.getByTestId('db-access-grant').textContent).toMatch(/Use this SQL/); + fireEvent.click(screen.getByTestId('db-access-grant')); + + await waitFor(() => expect(screen.getByTestId('access-permission-confirm')).toBeTruthy()); + fireEvent.click(screen.getByTestId('access-permission-confirm-run')); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringMatching(/GRANT/i)) + ); + expect(runAccessSql).not.toHaveBeenCalled(); + expect(screen.getByTestId('access-permission-status').textContent).toMatch(/Copied/); + }); + + it('opens generated SQL in the SQL Editor without executing', async () => { + render(); + fireEvent.change(screen.getByTestId('access-permission-connection'), { + target: { value: 'c1' }, + }); + await waitFor(() => expect(screen.getByTestId('access-permission-row-alice')).toBeTruthy()); + fireEvent.click(screen.getByTestId('access-permission-row-alice')); + + fireEvent.click(screen.getByTestId('db-access-expand-table')); + await waitFor(() => expect(screen.getByTestId('db-access-obj-public-orders')).toBeTruthy()); + fireEvent.click(screen.getByTestId('db-access-edit-orders')); + await waitFor(() => expect(screen.getByTestId('db-access-object-editor')).toBeTruthy()); + fireEvent.click(screen.getByTestId('db-access-preview-sql')); + await waitFor(() => expect(screen.getByTestId('db-access-sql-modal')).toBeTruthy()); + fireEvent.click(screen.getByTestId('db-access-grant')); + await waitFor(() => expect(screen.getByTestId('access-permission-open-sql')).toBeTruthy()); + fireEvent.click(screen.getByTestId('access-permission-open-sql')); + + expect(setSql).toHaveBeenCalledWith(expect.stringMatching(/GRANT/i)); + expect(ensureConnectionSelected).toHaveBeenCalledWith('c1'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + expect(runAccessSql).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx index 5d7f165c..9b865bdf 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -3,14 +3,22 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Access → Permission: live grant/revoke against a saved connection. + * Access → Permission: one session on the cached GRANT catalog. * - * Uses the same dialect-aware sectioned UI as Database Access - * (`DbAccessPermissionSections`) — not a mock prototype. + * Principal tree + Account | Grants | Effective. Access workspace is + * generate-only — confirm copies SQL or opens the SQL Editor; it never + * executes GRANT/REVOKE (Database Access still does). */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + Copy, + FileCode2, + Loader2, + RefreshCw, +} from 'lucide-react'; import { dialectSupportsDbAccess, privilegesForPrincipal, @@ -18,30 +26,51 @@ import { type DbPrivilege, } from '@foxschema/sql'; import { fetchDbAccess } from '@/shared/api/schemaApi'; -import { runAccessSql } from '@/shared/api/accessSql'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { useUiStore } from '@/app/store/uiStore'; import { useAuthStore } from '@/app/store/authStore'; -import { EmptyState, inputCls, labelCls } from './controls'; +import { EmptyState, Segmented, inputCls, labelCls } from './controls'; import { DbAccessPermissionSections, type DbAccessConfirmRequest, } from './DbAccessPermissionSections'; +import { PermissionInspector } from './PermissionInspector'; +import type { AccessPrincipalDraft } from '../lib/access-draft'; + +type PermissionStage = 'account' | 'grants' | 'effective'; -export const AccessPermissionPanel: React.FC = () => { +const KIND_GROUPS: { kind: DbPrincipal['kind']; label: string }[] = [ + { kind: 'user', label: 'Users' }, + { kind: 'role', label: 'Roles' }, + { kind: 'group', label: 'Groups' }, +]; + +export const AccessPermissionPanel: React.FC<{ + initialDraft?: AccessPrincipalDraft | null; +}> = ({ initialDraft = null }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); + const setSql = useSqlEditorStore((s) => s.setSql); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const setActiveView = useUiStore((s) => s.setActiveView); const canGrant = useAuthStore((s) => s.can('editor.grant')); - const [connectionId, setConnectionId] = useState(''); - const [principalName, setPrincipalName] = useState(''); + const [connectionId, setConnectionId] = useState(initialDraft?.connectionId ?? ''); + const [principalName, setPrincipalName] = useState(initialDraft?.principalName ?? ''); const [principals, setPrincipals] = useState([]); const [privileges, setPrivileges] = useState([]); + const [hint, setHint] = useState(undefined); const [loading, setLoading] = useState(false); - const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(null); const [confirm, setConfirm] = useState(null); + const [copied, setCopied] = useState(false); + const [stage, setStage] = useState('grants'); + const [filter, setFilter] = useState(''); + const [expandedKinds, setExpandedKinds] = useState>( + () => new Set(['user', 'role', 'group']) + ); const loadToken = useRef(0); const conn = connections.find((c) => c.id === connectionId) || null; @@ -61,6 +90,22 @@ export const AccessPermissionPanel: React.FC = () => { [privileges, selected] ); + useEffect(() => { + if (!initialDraft) return; + if (initialDraft.connectionId !== connectionId) { + ++loadToken.current; + setConnectionId(initialDraft.connectionId); + setPrincipals([]); + setPrivileges([]); + setHint(undefined); + setError(null); + setStatus(null); + } + setPrincipalName(initialDraft.principalName); + // Draft identity is the handoff payload, not every parent render. + // eslint-disable-next-line react-hooks/exhaustive-deps -- apply when User Management hands off + }, [initialDraft?.connectionId, initialDraft?.principalName]); + const load = useCallback(async () => { if (!connectionId) return; const mine = ++loadToken.current; @@ -73,31 +118,30 @@ export const AccessPermissionPanel: React.FC = () => { { schema: conn?.schema } ); if (loadToken.current !== mine) return; - setPrincipals(data.principals ?? []); + const next = data.principals ?? []; + setPrincipals(next); setPrivileges(data.privileges ?? []); - if (!principalName && data.principals?.[0]) { - setPrincipalName(data.principals[0].name); - } else if ( - principalName && - data.principals && - !data.principals.some((p) => p.name === principalName) - ) { - setPrincipalName(data.principals[0]?.name ?? ''); - } + setHint(data.support?.hint); + setPrincipalName((current) => { + if (current && next.some((p) => p.name === current)) return current; + return next[0]?.name ?? ''; + }); } catch (err: unknown) { if (loadToken.current !== mine) return; setError(err instanceof Error ? err.message : String(err)); setPrincipals([]); setPrivileges([]); + setHint(undefined); } finally { if (loadToken.current === mine) setLoading(false); } - }, [connectionId, sessionPasswords, conn?.schema, principalName]); + }, [connectionId, sessionPasswords, conn?.schema]); useEffect(() => { if (!connectionId) { setPrincipals([]); setPrivileges([]); + setHint(undefined); setPrincipalName(''); return; } @@ -106,140 +150,242 @@ export const AccessPermissionPanel: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: connection-driven reload }, [connectionId]); - const runSql = async (sql: string, kind: 'grant' | 'revoke') => { - if (!connectionId || !canGrant) return; - setRunning(true); - setError(null); - setStatus(null); + const copyConfirmSql = async () => { + if (!confirm) return; try { - const outcome = await runAccessSql( - { connectionId, password: sessionPasswords[connectionId] || undefined }, - sql - ); - if (!outcome.ok) { - setError(outcome.error); - } else { - setStatus(kind === 'grant' ? 'Granted.' : 'Revoked.'); - await load(); - } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setRunning(false); - setConfirm(null); + await navigator.clipboard.writeText(confirm.sql); + setCopied(true); + setStatus('Copied to clipboard.'); + } catch { + setError('Could not copy — select the SQL manually'); } }; + const openInSqlEditor = () => { + if (!confirm || !connectionId) return; + setSql?.(confirm.sql); + ensureConnectionSelected?.(connectionId); + setActiveView('sqlEditor'); + setStatus('Opened in SQL Editor.'); + setConfirm(null); + }; + + const grouped = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return KIND_GROUPS.map((group) => ({ + ...group, + principals: principals.filter((p) => { + if (p.kind !== group.kind) return false; + if (!needle) return true; + return ( + p.name.toLowerCase().includes(needle) || + p.memberOf.some((m) => m.toLowerCase().includes(needle)) || + p.members.some((m) => m.toLowerCase().includes(needle)) + ); + }), + allOfKind: principals.filter((p) => p.kind === group.kind), + })).filter((g) => g.allOfKind.length > 0); + }, [principals, filter]); + return ( -
    -
    -

    Permission

    -

    - Grant and revoke with dialect-correct SQL. Same sectioned UI as Database Access — - General CREATE plus tables, views, procedures, and functions. -

    -
    +
    +
    +
    +

    Permission

    +

    + One catalog, three stages. Access generates GRANT/REVOKE SQL — copy it or open the + SQL Editor. Execution stays in Database Access. +

    +
    -
    - + + {loading ? : } + Reload + +
    {!connectionId && ( - +
    + +
    )} {connectionId && error && ( -

    +

    {error}

    )} {connectionId && status && ( -

    +

    {status}

    )} {connectionId && !error && ( - <> -
    +
    + {loading && principals.length === 0 && ( +

    Loading…

    )} - - + {!loading && principals.length === 0 && ( +

    No principals found

    + )} + {grouped.map((group) => { + const open = expandedKinds.has(group.kind); + const Chevron = open ? ChevronDown : ChevronRight; + return ( +
    + + {group.allOfKind.map((p) => { + const visible = open && group.principals.some((x) => x.name === p.name); + return ( + + ); + })} +
    + ); + })} +
    + - {selected && ( - setConfirm(req)} - onError={(msg) => setError(msg)} - /> - )} - +
    +
    + setStage(v as PermissionStage)} + options={[ + { value: 'account', label: 'Account' }, + { value: 'grants', label: 'Grants' }, + { value: 'effective', label: 'Effective' }, + ]} + /> +
    +
    + {!selected && ( +

    + {loading ? 'Loading principals…' : 'Select a user or role.'} +

    + )} + {selected && stage === 'account' && ( + + )} + {selected && stage === 'grants' && ( + { + setCopied(false); + setConfirm(req); + }} + onError={(msg) => setError(msg)} + /> + )} + {selected && stage === 'effective' && ( + + )} +
    +
    +
    )} {confirm && @@ -254,10 +400,13 @@ export const AccessPermissionPanel: React.FC = () => { data-testid="access-permission-confirm" >

    {confirm.title}

    +

    + Access does not execute this SQL. Copy it, or open it in the SQL Editor. +

                     {confirm.sql}
                   
    -
    +
    +
    @@ -287,3 +444,68 @@ export const AccessPermissionPanel: React.FC = () => {
    ); }; + +const AccountStage: React.FC<{ principal: DbPrincipal }> = ({ principal }) => { + const login = + principal.canLogin === true + ? 'Can log in' + : principal.canLogin === false + ? 'Cannot log in' + : 'Login unknown'; + return ( +
    +
    +
    Name
    +
    + {principal.name} +
    +
    Kind
    +
    + {principal.kind} +
    +
    Login
    +
    + {login} +
    +
    +
    +

    + Member of +

    + {principal.memberOf.length === 0 ? ( +

    Not a member of any role.

    + ) : ( +
      + {principal.memberOf.map((name) => ( +
    • + {name} +
    • + ))} +
    + )} +
    +
    +

    + Members +

    + {principal.members.length === 0 ? ( +

    No members.

    + ) : ( +
      + {principal.members.map((name) => ( +
    • + {name} +
    • + ))} +
    + )} +
    +
    + ); +}; diff --git a/apps/web/src/frontend/features/access/components/AccessView.test.tsx b/apps/web/src/frontend/features/access/components/AccessView.test.tsx index c08a6142..b485ae98 100644 --- a/apps/web/src/frontend/features/access/components/AccessView.test.tsx +++ b/apps/web/src/frontend/features/access/components/AccessView.test.tsx @@ -24,6 +24,8 @@ vi.mock('@/app/store/useSqlEditorStore', () => { const state = { sessionPasswords: {} as Record, ensureSchema: vi.fn().mockResolvedValue(undefined), + setSql: vi.fn(), + ensureConnectionSelected: vi.fn(), schemaCache: { c1: { status: 'ready', tables: [] as [] }, c2: { status: 'ready', tables: [] as [] }, @@ -99,6 +101,9 @@ describe('AccessView — User Management list + Builder handoff', () => { expect(screen.getByTestId('access-permission-panel')).toBeTruthy(); expect(screen.getByTestId('access-tab-permission').getAttribute('aria-current')).toBe('page'); + expect((screen.getByTestId('access-permission-connection') as HTMLSelectElement).value).toBe( + 'c1' + ); }); it('previews DROP SQL when dropping a listed user', async () => { @@ -224,6 +229,12 @@ describe('AccessView — User Management list + Builder handoff', () => { expect(screen.getByTestId('access-permission-panel')).toBeTruthy(); expect(screen.getByTestId('access-tab-permission').getAttribute('aria-current')).toBe('page'); + expect((screen.getByTestId('access-permission-connection') as HTMLSelectElement).value).toBe( + 'c1' + ); + await waitFor(() => + expect(screen.getByTestId('access-permission-principal').textContent).toMatch(/alice/) + ); }); it('warns on Drop when the account has privileges or role membership', async () => { diff --git a/apps/web/src/frontend/features/access/components/AccessView.tsx b/apps/web/src/frontend/features/access/components/AccessView.tsx index 57eb1e48..4ef787b7 100644 --- a/apps/web/src/frontend/features/access/components/AccessView.tsx +++ b/apps/web/src/frontend/features/access/components/AccessView.tsx @@ -15,6 +15,7 @@ import { UserCog, GitCompare, ShieldCheck } from 'lucide-react'; import { PermissionDiff } from './PermissionDiff'; import { UserManagement } from './UserManagement'; import { AccessPermissionPanel } from './AccessPermissionPanel'; +import type { AccessPrincipalDraft } from '../lib/access-draft'; export type AccessSection = 'users' | 'permission' | 'diff'; @@ -32,8 +33,10 @@ export const AccessView: React.FC = () => { // Default Users so AccessView tests that expect user-management on paint keep // passing. const [section, setSection] = useState('users'); + const [grantDraft, setGrantDraft] = useState(null); - const openPermission = () => { + const openPermission = (draft?: AccessPrincipalDraft) => { + if (draft) setGrantDraft(draft); setSection('permission'); }; @@ -70,8 +73,8 @@ export const AccessView: React.FC = () => {
    - {section === 'users' && openPermission()} />} - {section === 'permission' && } + {section === 'users' && openPermission(draft)} />} + {section === 'permission' && } {section === 'diff' && }
    diff --git a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx index 27308970..294ee06a 100644 --- a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx +++ b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx @@ -202,6 +202,11 @@ interface Props { canGrant: boolean; grantSupported: boolean; running?: boolean; + /** + * Access workspace is generate-only: preview still builds GRANT/REVOKE SQL, + * but the parent copies it or opens the SQL Editor instead of executing. + */ + generateOnly?: boolean; onConfirm: (req: DbAccessConfirmRequest) => void; onError?: (message: string) => void; } @@ -220,6 +225,7 @@ export const DbAccessPermissionSections: React.FC = ({ canGrant, grantSupported, running = false, + generateOnly = false, onConfirm, onError, }) => { @@ -858,7 +864,7 @@ export const DbAccessPermissionSections: React.FC = ({
    diff --git a/apps/web/src/frontend/features/access/components/PermissionInspector.test.tsx b/apps/web/src/frontend/features/access/components/PermissionInspector.test.tsx new file mode 100644 index 00000000..faf0fc7b --- /dev/null +++ b/apps/web/src/frontend/features/access/components/PermissionInspector.test.tsx @@ -0,0 +1,62 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { DbPrincipal, DbPrivilege } from '@foxschema/sql'; +import { PermissionInspector } from './PermissionInspector'; + +const fetchDbAccess = vi.fn(); +vi.mock('@/shared/api/schemaApi', () => ({ + fetchDbAccess: (...args: unknown[]) => fetchDbAccess(...args), +})); + +vi.mock('@/app/store/useSyncStore', () => { + const state = { connections: [{ id: 'c1', name: 'Demo', dialect: 'postgres', schema: 'public' }] }; + return { useSyncStore: (sel: (s: typeof state) => unknown) => sel(state) }; +}); + +vi.mock('@/app/store/useSqlEditorStore', () => { + const state = { sessionPasswords: {} as Record }; + return { useSqlEditorStore: (sel: (s: typeof state) => unknown) => sel(state) }; +}); + +const principals: DbPrincipal[] = [ + { name: 'alice', kind: 'user', canLogin: true, memberOf: ['readonly'], members: [] }, + { name: 'readonly', kind: 'role', canLogin: false, memberOf: [], members: ['alice'] }, +]; + +const privileges: DbPrivilege[] = [ + { + grantee: 'readonly', + privilege: 'SELECT', + objectType: 'TABLE', + objectSchema: 'public', + objectName: 'orders', + grantable: false, + grantor: null, + state: 'grant', + }, +]; + +describe('PermissionInspector catalog reuse', () => { + it('resolves effective access from the parent catalog without fetching', () => { + render( + + ); + expect(fetchDbAccess).not.toHaveBeenCalled(); + expect(screen.queryByTestId('inspector-load')).toBeNull(); + expect(screen.getByTestId('inspector-summary-read')).toBeTruthy(); + expect(screen.getByTestId('inspector-chain').textContent).toMatch(/readonly/); + }); +}); diff --git a/apps/web/src/frontend/features/access/components/PermissionInspector.tsx b/apps/web/src/frontend/features/access/components/PermissionInspector.tsx index 2e791404..b12d8860 100644 --- a/apps/web/src/frontend/features/access/components/PermissionInspector.tsx +++ b/apps/web/src/frontend/features/access/components/PermissionInspector.tsx @@ -30,8 +30,18 @@ export const PermissionInspector: React.FC<{ * Given these it hides its own pickers and reads for what that panel already * knows — which is what lets the builder show, in one window, both what a * principal has now and what the reader is about to grant it. + * + * When `principals` and `privileges` are passed, the inspector uses that + * catalog and does not fetch again (Access Permission already loaded it). */ - embedded?: { connectionId: string; principalName: string; schema?: string }; + embedded?: { + connectionId: string; + principalName: string; + schema?: string; + principals?: DbPrincipal[]; + privileges?: DbPrivilege[]; + hint?: string; + }; }> = ({ embedded }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); @@ -54,19 +64,35 @@ export const PermissionInspector: React.FC<{ const embeddedConnection = embedded?.connectionId; const embeddedPrincipal = embedded?.principalName; const embeddedSchema = embedded?.schema; + const catalogPrincipals = embedded?.principals; + const catalogPrivileges = embedded?.privileges; + const catalogHint = embedded?.hint; + const hasCatalog = catalogPrincipals != null && catalogPrivileges != null; React.useEffect(() => { if (embeddedConnection === undefined) return; loadToken.current++; setConnectionId(embeddedConnection); setSchema(embeddedSchema ?? ''); - setData(null); + if (!hasCatalog) { + setData(null); + } setError(null); setLoading(false); - }, [embeddedConnection, embeddedSchema]); + }, [embeddedConnection, embeddedSchema, hasCatalog]); React.useEffect(() => { if (embeddedPrincipal === undefined) return; setPrincipalName(embeddedPrincipal); }, [embeddedPrincipal]); + React.useEffect(() => { + if (!hasCatalog) return; + setData({ + principals: catalogPrincipals, + privileges: catalogPrivileges, + hint: catalogHint, + }); + setError(null); + setLoading(false); + }, [hasCatalog, catalogPrincipals, catalogPrivileges, catalogHint]); const load = async () => { if (!connectionId) return; @@ -106,7 +132,10 @@ export const PermissionInspector: React.FC<{ }, [data, principalName, schema]); return ( -
    +
    {!embedded && (

    Permission Inspector

    @@ -117,51 +146,53 @@ export const PermissionInspector: React.FC<{
    )} -
    - + + -
    + + {loading ? 'Reading…' : 'Read permissions'} + +
    + )} {error && (
    )} - {!data && !error && ( + {!data && !error && !hasCatalog && (

    Choose a connection and read its permissions. Fox Schema only reads — it changes nothing.

    )} - {data && ( + {data && !hasCatalog && (
    ); diff --git a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx new file mode 100644 index 00000000..c114611c --- /dev/null +++ b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx @@ -0,0 +1,39 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DEFAULT_ROLE_PERMISSIONS } from '@foxschema/shared'; +import { useAuthStore } from '@/app/store/authStore'; +import { useUiStore } from '@/app/store/uiStore'; +import { ActivityRail } from './ActivityRail'; + +describe('ActivityRail', () => { + it('keeps workspace testids and opens Home from the logo', () => { + useAuthStore.setState({ + user: { + id: 'owner', + email: 'o@x', + onboardingCompleted: true, + role: 'owner', + permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], + }, + status: 'ready', + localSingleUser: true, + error: null, + busy: false, + refreshMe: vi.fn(async () => {}), + }); + useUiStore.setState({ activeView: 'sync' }); + render(); + expect(screen.getByTestId('workspace-switcher')).toBeTruthy(); + expect(screen.getByTestId('view-sync-btn')).toBeTruthy(); + expect(screen.getByTestId('view-sql-editor-btn')).toBeTruthy(); + expect(screen.getByTestId('view-access-btn')).toBeTruthy(); + expect(screen.getByTestId('sync-pane-history-btn')).toBeTruthy(); + fireEvent.click(screen.getByTestId('home-open-btn')); + expect(useUiStore.getState().activeView).toBe('home'); + }); +}); diff --git a/apps/web/src/frontend/app/shell/ActivityRail.tsx b/apps/web/src/frontend/app/shell/ActivityRail.tsx index b5a86f82..98d1fd43 100644 --- a/apps/web/src/frontend/app/shell/ActivityRail.tsx +++ b/apps/web/src/frontend/app/shell/ActivityRail.tsx @@ -72,9 +72,21 @@ export function ActivityRail(): React.ReactElement | null { aria-label="Workspace" className="flex w-14 shrink-0 flex-col items-center gap-1 border-r border-slate-800 bg-slate-900/90 py-2" > -
    +
    + {visible.map((item) => { const on = activeView === item.view; return ( diff --git a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx new file mode 100644 index 00000000..3753ce2a --- /dev/null +++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx @@ -0,0 +1,77 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DEFAULT_ROLE_PERMISSIONS } from '@foxschema/shared'; +import { useAuthStore } from '@/app/store/authStore'; +import { CommandPalette } from './CommandPalette'; + +const setActiveView = vi.fn(); +const openRecentQuery = vi.fn(); +const ensureConnectionSelected = vi.fn(); +const ensureSchema = vi.fn(); + +vi.mock('@/app/store/uiStore', () => ({ + useUiStore: (sel: (s: Record) => unknown) => sel({ setActiveView }), +})); + +vi.mock('@/app/store/useSyncStore', () => ({ + useSyncStore: (sel: (s: Record) => unknown) => + sel({ connections: [{ id: 'c1', name: 'Demo PG', dialect: 'postgres' }] }), +})); + +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: Record) => unknown) => + sel({ + recentQueries: [{ id: 'r1', sql: 'SELECT 1', title: 'Ping', ranAt: 1 }], + openRecentQuery, + ensureConnectionSelected, + ensureSchema, + }), +})); + +describe('CommandPalette', () => { + beforeEach(() => { + setActiveView.mockReset(); + openRecentQuery.mockReset(); + ensureConnectionSelected.mockReset(); + ensureSchema.mockReset(); + useAuthStore.setState({ + user: { + id: 'owner', + email: 'o@x', + onboardingCompleted: true, + role: 'owner', + permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], + }, + status: 'ready', + localSingleUser: true, + error: null, + busy: false, + refreshMe: vi.fn(async () => {}), + }); + }); + + it('opens on ⌘K and jumps to a workspace without introspecting', () => { + render(); + expect(screen.queryByTestId('command-palette')).toBeNull(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + expect(screen.getByTestId('command-palette')).toBeTruthy(); + fireEvent.click(screen.getByTestId('command-palette-item-ws-home')); + expect(setActiveView).toHaveBeenCalledWith('home'); + expect(ensureSchema).not.toHaveBeenCalled(); + }); + + it('filters recents and opens them in the SQL Editor', () => { + render(); + fireEvent.keyDown(window, { key: 'k', ctrlKey: true }); + fireEvent.change(screen.getByTestId('command-palette-input'), { target: { value: 'Ping' } }); + fireEvent.click(screen.getByTestId('command-palette-item-recent-r1')); + expect(openRecentQuery).toHaveBeenCalledWith('r1'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + expect(ensureSchema).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/app/shell/CommandPalette.tsx b/apps/web/src/frontend/app/shell/CommandPalette.tsx new file mode 100644 index 00000000..966dab91 --- /dev/null +++ b/apps/web/src/frontend/app/shell/CommandPalette.tsx @@ -0,0 +1,209 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Local ⌘K palette: workspaces, saved connections, recent queries. + * Nothing here hits the network or introspects a schema. + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useAuthStore } from '@/app/store/authStore'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { useSyncStore } from '@/app/store/useSyncStore'; +import { useUiStore, type ActiveView } from '@/app/store/uiStore'; + +interface PaletteItem { + id: string; + group: string; + label: string; + hint?: string; + run: () => void; +} + +export const CommandPalette: React.FC = () => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const setActiveView = useUiStore((s) => s.setActiveView); + const connections = useSyncStore((s) => s.connections); + const recentQueries = useSqlEditorStore((s) => s.recentQueries); + const openRecentQuery = useSqlEditorStore((s) => s.openRecentQuery); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const canSchemaBrowse = useAuthStore((s) => s.can('schema.browse')); + const canSchemaCompare = useAuthStore((s) => s.can('schema.compare')); + const canEditorAccess = useAuthStore((s) => s.can('editor.access')); + + const go = (view: ActiveView) => { + setActiveView(view); + setOpen(false); + }; + + const items = useMemo((): PaletteItem[] => { + const out: PaletteItem[] = [ + { id: 'ws-home', group: 'Workspace', label: 'Home', run: () => go('home') }, + ]; + if (canSchemaBrowse || canSchemaCompare) { + out.push({ id: 'ws-sync', group: 'Workspace', label: 'Sync', run: () => go('sync') }); + } + if (canEditorAccess) { + out.push({ + id: 'ws-sql', + group: 'Workspace', + label: 'SQL Editor', + run: () => go('sqlEditor'), + }); + } + out.push({ id: 'ws-access', group: 'Workspace', label: 'Access', run: () => go('access') }); + if (canSchemaBrowse) { + out.push({ + id: 'ws-snapshots', + group: 'Workspace', + label: 'Snapshots', + run: () => go('snapshots'), + }); + } + for (const c of connections) { + out.push({ + id: `conn-${c.id}`, + group: 'Connection', + label: c.name, + hint: c.dialect, + run: () => { + ensureConnectionSelected(c.id); + go('sqlEditor'); + }, + }); + } + for (const r of recentQueries.slice(0, 12)) { + out.push({ + id: `recent-${r.id}`, + group: 'Recent', + label: r.title?.trim() || 'Query', + hint: r.sql.trim().split('\n')[0], + run: () => { + openRecentQuery(r.id); + go('sqlEditor'); + }, + }); + } + return out; + // go closes over setActiveView; items rebuild when catalogs change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + canSchemaBrowse, + canSchemaCompare, + canEditorAccess, + connections, + recentQueries, + ensureConnectionSelected, + openRecentQuery, + setActiveView, + ]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter( + (it) => + it.label.toLowerCase().includes(q) || + it.group.toLowerCase().includes(q) || + (it.hint ?? '').toLowerCase().includes(q) + ); + }, [items, query]); + + useEffect(() => { + setActive(0); + }, [query, open]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + setOpen((v) => !v); + setQuery(''); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive((i) => Math.min(filtered.length - 1, i + 1)); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => Math.max(0, i - 1)); + } + if (e.key === 'Enter') { + e.preventDefault(); + filtered[active]?.run(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, filtered, active]); + + if (!open) return null; + + return createPortal( +
    setOpen(false)} + > +
    e.stopPropagation()} + > + setQuery(e.target.value)} + placeholder="Go to a workspace, connection, or recent query…" + className="w-full bg-slate-950 px-3 py-2.5 text-sm text-slate-100 outline-none border-b border-slate-800" + /> +
      + {filtered.length === 0 && ( +
    • No matches.
    • + )} + {filtered.map((it, i) => ( +
    • + +
    • + ))} +
    +
    +
    , + document.body + ); +}; diff --git a/apps/web/src/frontend/app/shell/HomeView.test.tsx b/apps/web/src/frontend/app/shell/HomeView.test.tsx new file mode 100644 index 00000000..9bb7d81b --- /dev/null +++ b/apps/web/src/frontend/app/shell/HomeView.test.tsx @@ -0,0 +1,62 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { HomeView } from './HomeView'; + +const openRecentQuery = vi.fn(); +const ensureConnectionSelected = vi.fn(); +const ensureSchema = vi.fn(); +const setActiveView = vi.fn(); + +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: Record) => unknown) => + sel({ + recentQueries: [ + { id: 'r1', sql: 'SELECT 1', title: 'Ping', ranAt: Date.now() }, + ], + openRecentQuery, + ensureConnectionSelected, + ensureSchema, + }), +})); + +vi.mock('@/app/store/useSyncStore', () => ({ + useSyncStore: (sel: (s: Record) => unknown) => + sel({ + connections: [{ id: 'c1', name: 'Demo SQLite', dialect: 'sqlite' }], + }), +})); + +vi.mock('@/app/store/uiStore', () => ({ + useUiStore: (sel: (s: Record) => unknown) => sel({ setActiveView }), +})); + +describe('HomeView', () => { + beforeEach(() => { + openRecentQuery.mockReset(); + ensureConnectionSelected.mockReset(); + ensureSchema.mockReset(); + setActiveView.mockReset(); + }); + + it('opens a recent query in the SQL Editor without introspecting', () => { + render(); + expect(screen.getByTestId('home-view')).toBeTruthy(); + fireEvent.click(screen.getByTestId('home-recent-r1')); + expect(openRecentQuery).toHaveBeenCalledWith('r1'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + expect(ensureSchema).not.toHaveBeenCalled(); + }); + + it('opens a saved connection as a SQL destination without introspecting', () => { + render(); + fireEvent.click(screen.getByTestId('home-connection-c1')); + expect(ensureConnectionSelected).toHaveBeenCalledWith('c1'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + expect(ensureSchema).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/app/shell/HomeView.tsx b/apps/web/src/frontend/app/shell/HomeView.tsx new file mode 100644 index 00000000..94abe030 --- /dev/null +++ b/apps/web/src/frontend/app/shell/HomeView.tsx @@ -0,0 +1,108 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Home: recents and saved connections from Zustand only — no schema introspect. + */ +import React from 'react'; +import { Database, Terminal } from 'lucide-react'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { useSyncStore } from '@/app/store/useSyncStore'; +import { useUiStore } from '@/app/store/uiStore'; +import { formatRelativeDay } from '@/features/sql-editor/lib/relativeTime'; + +function previewSql(sql: string): string { + const line = sql.trim().split('\n')[0] ?? ''; + return line.length > 88 ? `${line.slice(0, 87)}…` : line; +} + +export const HomeView: React.FC = () => { + const recentQueries = useSqlEditorStore((s) => s.recentQueries); + const openRecentQuery = useSqlEditorStore((s) => s.openRecentQuery); + const connections = useSyncStore((s) => s.connections); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const setActiveView = useUiStore((s) => s.setActiveView); + + const openRecent = (id: string) => { + openRecentQuery(id); + setActiveView('sqlEditor'); + }; + + const openConnection = (id: string) => { + ensureConnectionSelected(id); + setActiveView('sqlEditor'); + }; + + return ( +
    +

    Home

    +

    + Recents and connections already on this device. Opening one does not introspect a database. +

    + +
    +

    + Recent queries +

    + {recentQueries.length === 0 ? ( +

    + Run a query in the SQL Editor to see it here. +

    + ) : ( +
      + {recentQueries.slice(0, 12).map((r) => ( +
    • + +
    • + ))} +
    + )} +
    + +
    +

    + Connections +

    + {connections.length === 0 ? ( +

    Save a connection from Schema Sync to see it here.

    + ) : ( +
      + {connections.map((c) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + ); +}; diff --git a/apps/web/src/frontend/app/store/uiStore.test.ts b/apps/web/src/frontend/app/store/uiStore.test.ts index 28c43706..37c824d8 100644 --- a/apps/web/src/frontend/app/store/uiStore.test.ts +++ b/apps/web/src/frontend/app/store/uiStore.test.ts @@ -26,6 +26,13 @@ describe('migrateUiPersist', () => { expect(next.syncPane).toBe('compare'); }); + it('keeps Home as a persisted workspace', () => { + const next = migrateUiPersist({ activeView: 'home', syncPane: 'compare' }, 2) as { + activeView: string; + }; + expect(next.activeView).toBe('home'); + }); + it('leaves Compare and Browse on Schema Sync', () => { const compare = migrateUiPersist({ activeView: 'sync', syncPane: 'compare' }, 2) as { activeView: string; diff --git a/apps/web/src/frontend/app/store/uiStore.ts b/apps/web/src/frontend/app/store/uiStore.ts index 060b578e..d1c76cb6 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -176,7 +176,7 @@ function applyToDocument(themeMode: ThemeMode, tone: ToneId, fontSize: FontSize, } /** Top-level workspace views. Snapshots (Lokee) is its own view, not a Sync pane. */ -export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots'; +export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots' | 'home'; /** * Browse is its own pane, not a mode hiding inside Compare. It answers a * different question — "what is in this one database?" rather than "how do @@ -241,7 +241,7 @@ export function migrateUiPersist(persisted: unknown, _version: number): unknown state.activeView = 'snapshots'; state.syncPane = 'compare'; } - if (!['sync', 'sqlEditor', 'access', 'snapshots'].includes(state.activeView as string)) { + if (!['sync', 'sqlEditor', 'access', 'snapshots', 'home'].includes(state.activeView as string)) { state.activeView = 'sync'; } if (!['compare', 'browse'].includes(state.syncPane as string)) { diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx new file mode 100644 index 00000000..2079dafa --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx @@ -0,0 +1,77 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { DataPeekPanel } from './DataPeekPanel'; + +const fetchTableInsight = vi.fn(); +vi.mock('@/shared/api/schemaApi', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchTableInsight: (...args: unknown[]) => fetchTableInsight(...args), + }; +}); + +const entry = { + id: 'e1', + title: 'public.orders', + tableName: 'public.orders', + baseSql: 'SELECT * FROM public.orders', + baseParams: [] as unknown[], + whereClause: '', + orderByClause: '', + limit: 50, + pageIndex: 0, + sql: 'SELECT * FROM public.orders', + params: [] as unknown[], + status: 'ready' as const, + result: { + ok: true as const, + columns: ['id'], + rows: [[1]], + rowCount: 1, + truncated: false, + durationMs: 1, + }, +}; + +describe('DataPeekPanel Insight tab', () => { + beforeEach(() => { + fetchTableInsight.mockReset(); + fetchTableInsight.mockResolvedValue({ + table: 'orders', + schema: 'public', + estimatedRows: 10, + columns: [{ name: 'id', nDistinct: 10, nullFrac: 0 }], + mode: 'catalog', + support: { mode: 'catalog', query: true, hint: '' }, + }); + useSqlEditorStore.setState({ + dataPeek: { + connectionId: 'c1', + dialect: 'postgres', + entries: [entry], + }, + }); + }); + + afterEach(() => { + useSqlEditorStore.setState({ dataPeek: null }); + }); + + it('does not fetch catalog insight until the Insight tab is selected', async () => { + render(); + expect(screen.getByTestId('data-peek')).toBeTruthy(); + expect(screen.queryByTestId('data-peek-insight')).toBeNull(); + expect(fetchTableInsight).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('data-peek-tab-insight-e1')); + await waitFor(() => expect(fetchTableInsight).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('data-peek-insight')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx index bf4be7e9..7e28e243 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx @@ -19,8 +19,10 @@ import { useSqlEditorStore, type DataPeekEntry } from '@/app/store/useSqlEditorS import { foreignKeyLinksFor, fkDrillTableName, peekBaseFilterLabel } from '@/shared/lib/tablePreview'; import { DataGrid } from './DataGrid'; import { usePeekGridCrud } from './usePeekGridCrud'; +import { PeekInsight } from './PeekInsight'; import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; import type { TableSchema } from '@/shared/lib/types'; +import { useSyncStore } from '@/app/store/useSyncStore'; const DEFAULT_HEIGHT_ROOT = 360; /** FK drill panels stack full-width; main body scrolls when many are open. */ @@ -325,6 +327,13 @@ const PeekGrid: React.FC<{ const pageDataPeekEntry = useSqlEditorStore((s) => s.pageDataPeekEntry); const runDataPeekEntry = useSqlEditorStore((s) => s.runDataPeekEntry); const clearDataPeekBaseFilter = useSqlEditorStore((s) => s.clearDataPeekBaseFilter); + const connectionSchema = useSyncStore( + (s) => s.connections.find((c) => c.id === connectionId)?.schema + ); + const [tab, setTab] = useState<'rows' | 'insight'>('rows'); + useEffect(() => { + setTab('rows'); + }, [entry.id]); const table = useMemo(() => { if (!tables) return undefined; @@ -460,25 +469,59 @@ const PeekGrid: React.FC<{ )}
    +
    + + +
    + {/* Keep filters editable while a page reloads so clearing WHERE can apply. */} - + {tab === 'rows' && } {crud.writeErrorBanner} - {entry.status === 'loading' && !entry.result && ( + {tab === 'insight' && ( + + )} + + {tab === 'rows' && entry.status === 'loading' && !entry.result && (
    Loading {entry.title}…
    )} - {(entry.status === 'error' || (entry.status !== 'loading' && !entry.result)) && ( + {tab === 'rows' && (entry.status === 'error' || (entry.status !== 'loading' && !entry.result)) && (
    {entry.error ?? 'Preview failed'}
    )} - {entry.result?.ok && ( + {tab === 'rows' && entry.result?.ok && (
    + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { PeekInsight } from './PeekInsight'; + +const fetchTableInsight = vi.fn(); +vi.mock('@/shared/api/schemaApi', () => ({ + fetchTableInsight: (...args: unknown[]) => fetchTableInsight(...args), +})); + +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: { sessionPasswords: Record }) => unknown) => + sel({ sessionPasswords: {} }), +})); + +describe('PeekInsight', () => { + beforeEach(() => { + fetchTableInsight.mockReset(); + fetchTableInsight.mockResolvedValue({ + table: 'orders', + schema: 'public', + estimatedRows: 1200, + columns: [{ name: 'id', nDistinct: 1200, nullFrac: 0 }], + mode: 'catalog', + support: { mode: 'catalog', query: true, hint: 'pg_stats' }, + }); + }); + + it('fetches catalog stats when mounted (Insight tab selected)', async () => { + render(); + await waitFor(() => expect(fetchTableInsight).toHaveBeenCalledTimes(1)); + expect(fetchTableInsight).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'c1' }), + expect.objectContaining({ table: 'orders', schema: 'public' }) + ); + await waitFor(() => + expect(screen.getByTestId('data-peek-insight-rows').textContent).toMatch(/1,200/) + ); + expect(screen.getByTestId('data-peek-insight-col-id')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/PeekInsight.tsx b/apps/web/src/frontend/features/sql-editor/components/PeekInsight.tsx new file mode 100644 index 00000000..e9fa5bd4 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/PeekInsight.tsx @@ -0,0 +1,113 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Catalog-only table insight for Data Peek. Mounted only when the Insight tab + * is selected — opening Peek must not fetch this. + */ +import React, { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { fetchTableInsight, type TableInsightResponse } from '@/shared/api/schemaApi'; +import { tableNameParts } from '@/shared/lib/tablePreview'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; + +function tableRef(tableName: string, fallbackSchema?: string): { table: string; schema?: string } { + const parts = tableNameParts(tableName); + if (parts.length > 1) { + return { schema: parts[0], table: parts[parts.length - 1]! }; + } + return { table: tableName, schema: fallbackSchema }; +} + +export const PeekInsight: React.FC<{ + connectionId: string; + tableName: string; + schema?: string; +}> = ({ connectionId, tableName, schema }) => { + const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const ref = tableRef(tableName, schema); + setLoading(true); + setError(null); + void fetchTableInsight( + { connectionId, password: sessionPasswords[connectionId] || undefined }, + { table: ref.table, schema: ref.schema } + ) + .then((res) => { + if (cancelled) return; + setData(res); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + setData(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [connectionId, tableName, schema, sessionPasswords]); + + return ( +
    + {loading && ( +

    + + Reading catalog stats… +

    + )} + {error && ( +

    + {error} +

    + )} + {data && ( + <> +

    + Estimated rows:{' '} + + {data.estimatedRows == null ? '—' : data.estimatedRows.toLocaleString()} + + {data.support?.hint ? ( + {data.support.hint} + ) : null} +

    + {data.columns.length === 0 ? ( +

    No column stats in the catalog.

    + ) : ( + + + + + + + + + + {data.columns.map((c) => ( + + + + + + ))} + +
    ColumnnDistinctnull %
    {c.name} + {c.nDistinct == null ? '—' : c.nDistinct} + + {c.nullFrac == null ? '—' : `${Math.round(c.nullFrac * 1000) / 10}%`} +
    + )} + + )} +
    + ); +}; diff --git a/apps/web/src/frontend/features/sql-editor/components/ResultChart.tsx b/apps/web/src/frontend/features/sql-editor/components/ResultChart.tsx new file mode 100644 index 00000000..f2406672 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/ResultChart.tsx @@ -0,0 +1,99 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Opt-in SVG bar chart for a label + number result grid. No chart library. + */ +import React, { useMemo } from 'react'; +import type { ChartSeries } from '../lib/resultChart'; + +const WIDTH = 640; +const HEIGHT = 180; +const PAD_L = 36; +const PAD_R = 12; +const PAD_T = 12; +const PAD_B = 36; + +export const ResultChart: React.FC<{ + series: ChartSeries; + testId?: string; +}> = ({ series, testId = 'sql-result-chart' }) => { + const max = useMemo( + () => Math.max(...series.points.map((p) => p.value), 0) || 1, + [series.points] + ); + const innerW = WIDTH - PAD_L - PAD_R; + const innerH = HEIGHT - PAD_T - PAD_B; + const gap = series.points.length > 1 ? innerW / series.points.length : innerW; + const barW = Math.max(4, Math.min(28, gap * 0.62)); + + return ( +
    +

    + {series.valueColumn} + + {' '} + by {series.labelColumn} + +

    + + + + {series.points.map((p, i) => { + const h = (p.value / max) * innerH; + const x = PAD_L + gap * i + (gap - barW) / 2; + const y = PAD_T + innerH - h; + return ( + + + + {p.label}: {p.value} + + + + {p.label.length > 10 ? `${p.label.slice(0, 9)}…` : p.label} + + + ); + })} + +
    + ); +}; diff --git a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.test.tsx b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.test.tsx index 6bce7f28..54f3906c 100644 --- a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.test.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.test.tsx @@ -11,7 +11,7 @@ * query and a MutationObserver to catch a window of tens of milliseconds; * here it is a direct assertion. */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { ResultsPanel } from './ResultsPanel'; import type { CredentialRun } from '@/app/store/useSqlEditorStore'; @@ -94,3 +94,39 @@ describe('ResultsPanel — a dispatched run must look like something is happenin expect(screen.getAllByTestId('sql-results-running').length).toBeGreaterThan(0); }); }); + +describe('ResultsPanel — opt-in SVG chart', () => { + it('draws bars only after Chart is checked on a label + number grid', () => { + const run: CredentialRun = { + connectionId: 'c1', + name: 'Primary', + dialect: 'postgres', + status: 'done', + results: [ + { + ok: true, + columns: ['region', 'n'], + rows: [ + ['east', 12], + ['west', 7], + ], + rowCount: 2, + truncated: false, + durationMs: 1, + }, + ], + }; + render( + + ); + expect(screen.queryByTestId('sql-result-chart-0')).toBeNull(); + fireEvent.click(screen.getByTestId('sql-result-chart-toggle-0')); + expect(screen.getByTestId('sql-result-chart-0')).toBeTruthy(); + expect(screen.getByTestId('sql-result-chart-0-bar-0')).toBeTruthy(); + expect(screen.getByTestId('sql-result-chart-0-bar-1')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx index 6bce019a..17dc730c 100644 --- a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx @@ -21,11 +21,14 @@ import { Maximize2, X, Download, + BarChart2, } from 'lucide-react'; import { useSqlEditorStore, type CredentialRun } from '@/app/store/useSqlEditorStore'; import { useSyncStore } from '@/app/store/useSyncStore'; import type { ResultsLayout } from '@/app/store/sqlEditorTabLogic'; import { DataGrid, PANE_DEFAULT_H_PX, PANE_DEFAULT_PX, PANE_MIN_H_PX, PANE_MIN_PX } from './DataGrid'; +import { ResultChart } from './ResultChart'; +import { chartableSeries } from '../lib/resultChart'; import type { SqlStatementResult } from '@/shared/api/sqlApi'; import { detectCodeCell } from '@/features/sql-editor/lib/codeCellRunner'; import { CODE_CELL_KIND_LABEL } from '@/shared/lib/sql-splitter'; @@ -372,6 +375,8 @@ const ResultGridPane: React.FC<{ const columns = item.result.ok ? item.result.columns : []; const rows = item.result.ok ? item.result.rows : []; + const chartSeries = item.result.ok ? chartableSeries(item.result.columns, item.result.rows) : null; + const [chartOn, setChartOn] = useState(false); const crud = usePeekGridCrud({ connectionId: item.connectionId, @@ -403,6 +408,22 @@ const ResultGridPane: React.FC<{ const toolbarExtra = ( <> + {chartSeries && ( + + )} {crud.crudButtons} {collapsed && ( {crud.writeErrorBanner} + {chartOn && chartSeries && ( + + )} + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { MAX_CHART_POINTS, chartableSeries } from './resultChart'; + +describe('chartableSeries', () => { + it('maps a label + number grid', () => { + const series = chartableSeries( + ['region', 'orders'], + [ + ['east', 12], + ['west', 7], + ] + ); + expect(series).toEqual({ + labelColumn: 'region', + valueColumn: 'orders', + points: [ + { label: 'east', value: 12 }, + { label: 'west', value: 7 }, + ], + }); + }); + + it('uses the first column as label when every column is numeric', () => { + const series = chartableSeries( + ['year', 'revenue'], + [ + [2024, 100], + [2025, 140], + ] + ); + expect(series?.labelColumn).toBe('year'); + expect(series?.valueColumn).toBe('revenue'); + expect(series?.points[0]).toEqual({ label: '2024', value: 100 }); + }); + + it('rejects a single-column grid and a string-only grid', () => { + expect(chartableSeries(['name'], [['a']])).toBeNull(); + expect( + chartableSeries( + ['a', 'b'], + [ + ['x', 'y'], + ['p', 'q'], + ] + ) + ).toBeNull(); + }); + + it('caps points so a large page stays a cheap SVG', () => { + const rows = Array.from({ length: MAX_CHART_POINTS + 15 }, (_, i) => [`n${i}`, i]); + const series = chartableSeries(['name', 'n'], rows); + expect(series?.points).toHaveLength(MAX_CHART_POINTS); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/lib/resultChart.ts b/apps/web/src/frontend/features/sql-editor/lib/resultChart.ts new file mode 100644 index 00000000..aa4dc4ae --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/lib/resultChart.ts @@ -0,0 +1,81 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Detect a label + number grid that can be drawn as an opt-in SVG bar chart. + * No chart library; pies are out of scope. + */ + +export const MAX_CHART_POINTS = 40; + +export interface ChartPoint { + label: string; + value: number; +} + +export interface ChartSeries { + labelColumn: string; + valueColumn: string; + points: ChartPoint[]; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'bigint') { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isNumericColumn(rows: unknown[][], col: number): boolean { + let seen = 0; + for (const row of rows) { + const value = row[col]; + if (value == null || value === '') continue; + if (asNumber(value) == null) return false; + seen += 1; + if (seen >= 8) break; + } + return seen > 0; +} + +function cellLabel(value: unknown): string { + if (value == null) return '∅'; + if (typeof value === 'string') return value || '∅'; + return String(value); +} + +/** First label column + first numeric measure, or null when the grid is not chartable. */ +export function chartableSeries(columns: string[], rows: unknown[][]): ChartSeries | null { + if (columns.length < 2 || rows.length === 0) return null; + const numeric = columns.map((_, i) => isNumericColumn(rows, i)); + const numericIdx = numeric.findIndex(Boolean); + if (numericIdx < 0) return null; + const nonNumericIdx = numeric.findIndex((n) => !n); + const labelIdx = nonNumericIdx >= 0 ? nonNumericIdx : 0; + const valueIdx = + nonNumericIdx >= 0 + ? numericIdx + : numeric.findIndex((n, i) => n && i !== labelIdx); + if (valueIdx < 0 || valueIdx === labelIdx) return null; + + const points: ChartPoint[] = []; + for (const row of rows) { + if (points.length >= MAX_CHART_POINTS) break; + const value = asNumber(row[valueIdx]); + if (value == null) continue; + points.push({ label: cellLabel(row[labelIdx]), value }); + } + if (points.length === 0) return null; + return { + labelColumn: columns[labelIdx] ?? 'label', + valueColumn: columns[valueIdx] ?? 'value', + points, + }; +} From 2d153f9c08adbaad1e774f6625263893bcf47b9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 23:31:50 +0000 Subject: [PATCH 06/17] feat(ui): ship the v3 chrome the proposal actually described MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stacked Original/Target cards with connection chips, land Compare on Diff Briefing, turn the SQL sidebar into a 48px section rail with Run · N destination chips, open Home first, and add Snapshots briefing plus a stacked change chart. Co-authored-by: huy.phan9 --- apps/web/src/frontend/App.tsx | 4 +- .../app/shell/CommandPalette.test.tsx | 6 + .../src/frontend/app/shell/CommandPalette.tsx | 11 +- .../frontend/app/shell/ConnectionChips.tsx | 140 +++++ .../src/frontend/app/shell/HomeView.test.tsx | 9 + apps/web/src/frontend/app/shell/HomeView.tsx | 90 ++- .../src/frontend/app/shell/ProfileMenu.tsx | 2 +- .../web/src/frontend/app/shell/TopToolbar.tsx | 592 ++++++------------ .../src/frontend/app/shell/commandPalette.ts | 13 + .../src/frontend/app/store/uiStore.test.ts | 11 +- apps/web/src/frontend/app/store/uiStore.ts | 9 +- .../components/HistoryCompareBar.tsx | 8 +- .../lokee-weave/components/LokeeWeaveView.tsx | 28 +- .../components/VersionChangeChart.test.tsx | 24 + .../components/VersionChangeChart.tsx | 123 ++++ .../components/ObjectDetailPanel.tsx | 5 +- .../components/DiffBriefingPanel.test.tsx | 61 ++ .../components/DiffBriefingPanel.tsx | 208 ++++++ .../frontend/features/schema-diff/index.ts | 1 + .../components/ConnectionChecklist.tsx | 170 +++-- .../sql-editor/components/SchemaTreePanel.tsx | 31 +- .../sql-editor/components/SqlEditorView.tsx | 197 ++++-- .../SqlSidebarSection.order.test.ts | 15 + .../components/SqlSidebarSection.tsx | 62 +- 24 files changed, 1295 insertions(+), 525 deletions(-) create mode 100644 apps/web/src/frontend/app/shell/ConnectionChips.tsx create mode 100644 apps/web/src/frontend/app/shell/commandPalette.ts create mode 100644 apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.test.tsx create mode 100644 apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.tsx create mode 100644 apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.test.tsx create mode 100644 apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.tsx diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 86f6691e..6bf207ac 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -37,10 +37,10 @@ const Workspace: React.FC = () => { useEffect(() => { if (activeView === 'sqlEditor' && !canEditorAccess) { - setActiveView('sync'); + setActiveView(canSchemaBrowse || canSchemaCompare ? 'sync' : 'home'); } if (activeView === 'snapshots' && !canSchemaBrowse) { - setActiveView('sync'); + setActiveView('home'); } if ( activeView === 'sync' && diff --git a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx index 3753ce2a..ec07f837 100644 --- a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx +++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx @@ -74,4 +74,10 @@ describe('CommandPalette', () => { expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); expect(ensureSchema).not.toHaveBeenCalled(); }); + + it('opens from the custom event the TopBar button fires', () => { + render(); + fireEvent(window, new Event('foxschema-command-palette')); + expect(screen.getByTestId('command-palette')).toBeTruthy(); + }); }); diff --git a/apps/web/src/frontend/app/shell/CommandPalette.tsx b/apps/web/src/frontend/app/shell/CommandPalette.tsx index 966dab91..436982bd 100644 --- a/apps/web/src/frontend/app/shell/CommandPalette.tsx +++ b/apps/web/src/frontend/app/shell/CommandPalette.tsx @@ -12,6 +12,7 @@ import { useAuthStore } from '@/app/store/authStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useUiStore, type ActiveView } from '@/app/store/uiStore'; +import { COMMAND_PALETTE_EVENT } from './commandPalette'; interface PaletteItem { id: string; @@ -125,8 +126,16 @@ export const CommandPalette: React.FC = () => { setQuery(''); } }; + const onOpen = () => { + setOpen(true); + setQuery(''); + }; window.addEventListener('keydown', onKey, true); - return () => window.removeEventListener('keydown', onKey, true); + window.addEventListener(COMMAND_PALETTE_EVENT, onOpen); + return () => { + window.removeEventListener('keydown', onKey, true); + window.removeEventListener(COMMAND_PALETTE_EVENT, onOpen); + }; }, []); useEffect(() => { diff --git a/apps/web/src/frontend/app/shell/ConnectionChips.tsx b/apps/web/src/frontend/app/shell/ConnectionChips.tsx new file mode 100644 index 00000000..82fa88fc --- /dev/null +++ b/apps/web/src/frontend/app/shell/ConnectionChips.tsx @@ -0,0 +1,140 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * One-line Original / Target chips for the Sync TopBar. Same saved-connection + * pickers and connect/edit actions as the old stacked cards — compact chrome, + * not a second connection model. + */ +import React from 'react'; +import { CheckCircle2, RefreshCw, Settings } from 'lucide-react'; + +export interface ConnectionChipOption { + id: string; + name: string; + dialect: string; +} + +export interface ConnectionChipProps { + side: 'source' | 'target'; + label: string; + connections: readonly ConnectionChipOption[]; + selectedId: string | null | undefined; + summary: string | null; + connected: boolean; + connecting: boolean; + onSelect: (id: string) => void; + onEdit: () => void; + onConnect: () => void; +} + +const TONE: Record< + ConnectionChipProps['side'], + { ring: string; label: string; summary: string; empty: string } +> = { + source: { + ring: 'border-cyan-500/30 bg-cyan-950/20', + label: 'text-cyan-400', + summary: 'text-cyan-200', + empty: 'text-cyan-200/80', + }, + target: { + ring: 'border-purple-500/30 bg-purple-950/20', + label: 'text-purple-400', + summary: 'text-purple-200', + empty: 'text-purple-200/80', + }, +}; + +export function ConnectionChip({ + side, + label, + connections, + selectedId, + summary, + connected, + connecting, + onSelect, + onEdit, + onConnect, +}: ConnectionChipProps): React.ReactElement { + const tone = TONE[side]; + const savedTestId = side === 'source' ? 'source-saved-select' : 'target-saved-select'; + const editTestId = side === 'source' ? 'source-config-btn' : 'target-config-btn'; + const connectedTestId = side === 'source' ? 'source-connected-btn' : 'target-connected-btn'; + const connectTestId = side === 'source' ? 'source-connect-btn' : 'target-connect-btn'; + + return ( +
    + + {label} + + {connections.length > 0 && ( + + )} + + {summary ?? 'Add a connection'} + + + {connecting ? ( + + + + ) : connected ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/web/src/frontend/app/shell/HomeView.test.tsx b/apps/web/src/frontend/app/shell/HomeView.test.tsx index 9bb7d81b..d7991df1 100644 --- a/apps/web/src/frontend/app/shell/HomeView.test.tsx +++ b/apps/web/src/frontend/app/shell/HomeView.test.tsx @@ -28,6 +28,9 @@ vi.mock('@/app/store/useSyncStore', () => ({ useSyncStore: (sel: (s: Record) => unknown) => sel({ connections: [{ id: 'c1', name: 'Demo SQLite', dialect: 'sqlite' }], + compareResult: null, + sourceConfig: { option: {} }, + targetConfig: { option: {} }, }), })); @@ -59,4 +62,10 @@ describe('HomeView', () => { expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); expect(ensureSchema).not.toHaveBeenCalled(); }); + + it('continues into Snapshots from the home cards', () => { + render(); + fireEvent.click(screen.getByTestId('home-continue-snapshots')); + expect(setActiveView).toHaveBeenCalledWith('snapshots'); + }); }); diff --git a/apps/web/src/frontend/app/shell/HomeView.tsx b/apps/web/src/frontend/app/shell/HomeView.tsx index 94abe030..59ce136c 100644 --- a/apps/web/src/frontend/app/shell/HomeView.tsx +++ b/apps/web/src/frontend/app/shell/HomeView.tsx @@ -3,14 +3,17 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Home: recents and saved connections from Zustand only — no schema introspect. + * Home: continue last work, recents, and saved connections from Zustand only — + * no schema introspect. */ import React from 'react'; -import { Database, Terminal } from 'lucide-react'; +import { Camera, Database, GitCompareArrows, Search, Terminal } from 'lucide-react'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useUiStore } from '@/app/store/uiStore'; import { formatRelativeDay } from '@/features/sql-editor/lib/relativeTime'; +import { openCommandPalette } from './commandPalette'; +import { diffBriefing } from '@/features/schema-diff'; function previewSql(sql: string): string { const line = sql.trim().split('\n')[0] ?? ''; @@ -21,6 +24,9 @@ export const HomeView: React.FC = () => { const recentQueries = useSqlEditorStore((s) => s.recentQueries); const openRecentQuery = useSqlEditorStore((s) => s.openRecentQuery); const connections = useSyncStore((s) => s.connections); + const compareResult = useSyncStore((s) => s.compareResult); + const sourceConfig = useSyncStore((s) => s.sourceConfig); + const targetConfig = useSyncStore((s) => s.targetConfig); const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); const setActiveView = useUiStore((s) => s.setActiveView); @@ -34,12 +40,84 @@ export const HomeView: React.FC = () => { setActiveView('sqlEditor'); }; + const briefing = diffBriefing(compareResult?.tables); + const lastQuery = recentQueries[0]; + return (
    -

    Home

    -

    - Recents and connections already on this device. Opening one does not introspect a database. -

    +
    +
    +

    Home

    +

    + Continue where you left off. Opening a recent or connection does not introspect a database. +

    +
    + +
    + +
    + + + +

    diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 5d6863b6..09f7efca 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -124,7 +124,7 @@ export function ProfileMenu(): React.ReactElement | null { }} className="w-full flex items-center gap-2 px-4 py-3 text-sm font-semibold text-slate-300 hover:text-slate-100 hover:bg-slate-800/60 transition cursor-pointer" > - Access control + App users & roles )} diff --git a/apps/web/src/frontend/app/shell/TopToolbar.tsx b/apps/web/src/frontend/app/shell/TopToolbar.tsx index 966cb449..72684071 100644 --- a/apps/web/src/frontend/app/shell/TopToolbar.tsx +++ b/apps/web/src/frontend/app/shell/TopToolbar.tsx @@ -2,8 +2,7 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useUiStore } from '@/app/store/uiStore'; -import { ArrowRight, ArrowLeftRight, RefreshCw, AlertCircle, CheckCircle2, Zap, Settings, KeyRound, History, X, Layers, Camera } from 'lucide-react'; -import { Brand } from './Brand'; +import { ArrowRight, ArrowLeftRight, RefreshCw, AlertCircle, Zap, Settings, KeyRound, History, X, Layers, Camera, Search } from 'lucide-react'; // Support both default and named exports (avoids blank-page Vite/HMR mismatches). import ProfileMenuDefault, { ProfileMenu as ProfileMenuNamed } from './ProfileMenu'; import { CredentialManager } from '@/features/connections'; @@ -23,15 +22,26 @@ import { BrowseBar } from '@/features/object-detail'; import { ActivityIndicator } from './ActivityIndicator'; import { DiffBriefingChips } from '@/features/schema-diff'; import { diffBriefing } from '@/features/schema-diff'; +import { ConnectionChip } from './ConnectionChips'; +import { openCommandPalette } from './commandPalette'; const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; +function connectionSummary(config: { + schema: string; + option: { host?: string; database?: string }; +}): string | null { + if (!config.option.database) return null; + return `${config.option.host ?? 'localhost'} / ${config.option.database}${ + config.schema ? ` / ${config.schema}` : '' + }`; +} + export const TopToolbar: React.FC = () => { const { sourceConfig, targetConfig, - setSourceConfig, - setTargetConfig, + setShowConnectionModal, isTestingSource, isTestingTarget, sourceConnected, @@ -48,7 +58,6 @@ export const TopToolbar: React.FC = () => { toggleTypeFilter, clearTypeFilter, showConnectionModal, - setShowConnectionModal, addConnection, connections, selectedSourceConnectionId, @@ -72,27 +81,16 @@ export const TopToolbar: React.FC = () => { * recognise, so comparing a Redis or MongoDB connection produced Db2 DDL * with nothing to say it had. * - * This gates the Compare button and nothing else. It first disabled the - * Schema Sync tab, which was the wrong control twice over: `activeView` - * already defaults to `sync`, so nobody has to press it, and that tab also - * owns Browse, History and both connection pickers — none of which need a - * SQL dialect. Disabling it stranded the reader in another workspace with no - * way back and no way to change the connection that blocked them. + * This gates the Compare button and nothing else. */ const compareBlockedBy = schemaCompareBlocker(sourceConfig.dialect, targetConfig.dialect); - // A saved connection created without a stored password ("Save password" left - // unticked) has no password to apply automatically — selecting it from either - // dropdown must prompt for a session-only password instead of connecting with none. const [pendingPassword, setPendingPassword] = useState<{ side: 'source' | 'target'; id: string; name: string } | null>(null); const [pendingPasswordValue, setPendingPasswordValue] = useState(''); const selectSavedConnection = (side: 'source' | 'target', id: string) => { const conn = connections.find((c) => c.id === id); - // A file dialect has no password to be missing. Prompting for one left the - // picker snapping back to "— Saved —" and no target selected at all. if (conn && !conn.hasPassword && connectionNeedsSecret(conn.dialect, conn.authMethod)) { - // Reuse a password already typed this session (SQL Editor or prior Sync pick). const cfg = side === 'source' ? sourceConfig : targetConfig; const existing = getSessionPassword(id) || @@ -147,24 +145,12 @@ export const TopToolbar: React.FC = () => { } }; - // Same dialect + server + database + schema means you'd be comparing a schema - // with itself (everything UNCHANGED) — almost always a misconfiguration const sameConfig = sourceConfig.dialect === targetConfig.dialect && (sourceConfig.option.host ?? '') === (targetConfig.option.host ?? '') && (sourceConfig.option.database ?? '') === (targetConfig.option.database ?? '') && sourceConfig.schema.trim().toUpperCase() === targetConfig.schema.trim().toUpperCase(); - /** - * Everything that makes Compare unavailable except being mid-run. - * - * One expression because there were two: `disabled` and the className each - * repeated the list, and adding the engine check to only the first left a - * blocked button still painted as the live call to action. - * - * `isComparing` stays out deliberately — the button keeps its accent while - * the spinner runs, and only the `disabled` attribute adds it. - */ const compareUnavailable = !canSchemaCompare || Boolean(compareBlockedBy) || @@ -199,398 +185,240 @@ export const TopToolbar: React.FC = () => { ]; return ( -
    - {/* One chrome row: brand is on the rail; this bar is actions only. */} -
    - - -
    - {/* Only renders while something is actually running. */} - - - - {compareResult && activeView === 'sync' && syncPane === 'compare' && ( + - )} -
    -
    -
    -
    + )} + + {activeView === 'sync' && syncPane === 'compare' && ( + <> + selectSavedConnection('source', id)} + onEdit={() => { + setActiveModalTarget('source'); + setShowConnectionModal(true); + }} + onConnect={testSourceConnection} + /> + + selectSavedConnection('target', id)} + onEdit={() => { + setActiveModalTarget('target'); + setShowConnectionModal(true); + }} + onConnect={testTargetConnection} + /> + {sameConfig && ( + + Same DB + + )} + {compareResult && } + + + )} - {/* Sync-only controls — the SQL Editor view brings its own left panel. */} - {activeView === 'sync' && ( - <> - {canSchemaBrowse && ( -
    - - Schema - - - + {activeView === 'sync' && syncPane === 'browse' && ( +
    + +
    + )} + + {activeView === 'sync' && canSchemaBrowse && ( -
    - )} - {syncPane === 'browse' && } - {syncPane === 'compare' && ( -
    - {/* Source Configuration — left side is the Original Server (read / compare from). */} -
    -
    - Original Server -
    - {/* Label + Add/Edit Connection + status, all inline */} -
    - {connections.length > 0 && ( - - )} + )} - - {sourceConfig.option.database - ? `${sourceConfig.option.host ?? 'localhost'} / ${sourceConfig.option.database}${sourceConfig.schema ? ` / ${sourceConfig.schema}` : ''}` - : 'Configure credentials via Params'} - - - - - {isTestingSource ? ( - - Connecting... - - ) : sourceConnected ? ( - - ) : ( - - )} + {activeView === 'snapshots' && ( +
    +
    -
    + )} - {/* Direction / Swap control — migration always flows Original Server → Target */} -
    +
    + -
    - - {/* Target Configuration */} -
    -
    - Target -
    - {/* Label + Add/Edit Connection + status, all inline */} -
    - {connections.length > 0 && ( - - )} - - - {targetConfig.option.database - ? `${targetConfig.option.host ?? 'localhost'} / ${targetConfig.option.database}${targetConfig.schema ? ` / ${targetConfig.schema}` : ''}` - : 'Configure credentials via Params'} - - + + + {compareResult && activeView === 'sync' && syncPane === 'compare' && ( - - {isTestingTarget ? ( - - Connecting... - - ) : targetConnected ? ( - - ) : ( - - )} + )} +
    +
    - )} - {syncPane === 'compare' && ( -
    - {/* Scope Config Controls — two always-separate rows: which object types - get compared (top), and which of the results are shown (bottom, - once a compare has run). Each is its own flex-wrap line so the - label always stays attached to its own pills. */} -
    -
    - - Comparison Scope: + {activeView === 'sync' && syncPane === 'compare' && ( +
    +
    + + Scope -
    - {objectScopeOptions.map((opt) => { - const active = selectedObjectTypes.includes(opt.type); + {objectScopeOptions.map((opt) => { + const active = selectedObjectTypes.includes(opt.type); + return ( + + ); + })} +
    + {compareResult && ( +
    + + Viewing + + + {TYPE_ORDER.map((type) => { + const active = typeFilter.includes(type); return ( ); })}
    -
    - - {/* Results type filter — narrows the compare-results tree (SchemaTreePanel) - to one or more object types (multi-select, like Comparison Scope above). - Lives here rather than in that panel because this bar spans the full - page width; the panel's 280-640px resizable width kept clipping the - pill row (esp. with 9 types + counts). */} - {compareResult && ( -
    - - Viewing: - -
    - - {TYPE_ORDER.map((type) => { - const active = typeFilter.includes(type); - return ( - - ); - })} -
    -
    )}
    - -
    - {sameConfig && ( - - Original Server and Target are the same - - )} - - {compareResult && ( - - )} - - -
    -
    - )} - - )} - - {activeView === 'snapshots' && ( - <> - {canSchemaBrowse && ( -
    - -
    - )} - - )} { setActiveModalTarget(null); }} onSaveCredential={async (input) => { - // Same credential form as the Credentials manager: save it (encrypted, - // server-side) then bind it to this side by id. const side = activeModalTarget === 'target' ? 'target' : 'source'; const saved = await addConnection(input); - // If the password wasn't persisted, keep it in-memory for this session so the - // just-bound connection can be used without re-entering it. const sessionPw = saved.hasPassword ? undefined : input.option.password; if (sessionPw) setSessionPassword(saved.id, sessionPw); applySavedConnection(side, saved.id, sessionPw); diff --git a/apps/web/src/frontend/app/shell/commandPalette.ts b/apps/web/src/frontend/app/shell/commandPalette.ts new file mode 100644 index 00000000..39ce01a7 --- /dev/null +++ b/apps/web/src/frontend/app/shell/commandPalette.ts @@ -0,0 +1,13 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The palette listens for this event so the TopBar ⌘K button and the + * keyboard shortcut share one opener. + */ +export const COMMAND_PALETTE_EVENT = 'foxschema-command-palette'; + +export function openCommandPalette(): void { + window.dispatchEvent(new Event(COMMAND_PALETTE_EVENT)); +} diff --git a/apps/web/src/frontend/app/store/uiStore.test.ts b/apps/web/src/frontend/app/store/uiStore.test.ts index 37c824d8..5fbde10b 100644 --- a/apps/web/src/frontend/app/store/uiStore.test.ts +++ b/apps/web/src/frontend/app/store/uiStore.test.ts @@ -34,15 +34,22 @@ describe('migrateUiPersist', () => { }); it('leaves Compare and Browse on Schema Sync', () => { - const compare = migrateUiPersist({ activeView: 'sync', syncPane: 'compare' }, 2) as { + const compare = migrateUiPersist({ activeView: 'sync', syncPane: 'compare' }, 3) as { activeView: string; syncPane: string; }; expect(compare).toMatchObject({ activeView: 'sync', syncPane: 'compare' }); - const browse = migrateUiPersist({ activeView: 'sync', syncPane: 'browse' }, 2) as { + const browse = migrateUiPersist({ activeView: 'sync', syncPane: 'browse' }, 3) as { activeView: string; syncPane: string; }; expect(browse).toMatchObject({ activeView: 'sync', syncPane: 'browse' }); }); + + it('opens Home on first paint when upgrading from the old Sync default', () => { + const next = migrateUiPersist({ activeView: 'sync', syncPane: 'compare' }, 2) as { + activeView: string; + }; + expect(next.activeView).toBe('home'); + }); }); diff --git a/apps/web/src/frontend/app/store/uiStore.ts b/apps/web/src/frontend/app/store/uiStore.ts index d1c76cb6..943a8dee 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -242,7 +242,10 @@ export function migrateUiPersist(persisted: unknown, _version: number): unknown state.syncPane = 'compare'; } if (!['sync', 'sqlEditor', 'access', 'snapshots', 'home'].includes(state.activeView as string)) { - state.activeView = 'sync'; + state.activeView = 'home'; + } + if (_version < 3 && state.activeView === 'sync') { + state.activeView = 'home'; } if (!['compare', 'browse'].includes(state.syncPane as string)) { state.syncPane = 'compare'; @@ -267,7 +270,7 @@ export const useUiStore = create()( return { ...DEFAULTS, resolvedMode: 'dark', - activeView: 'sync' as ActiveView, + activeView: 'home' as ActiveView, syncPane: 'compare' as SyncPane, lokeeEpoch: 0, @@ -310,7 +313,7 @@ export const useUiStore = create()( }, }; }, - { name: 'schema-sync-ui', version: 2, migrate: migrateUiPersist } + { name: 'schema-sync-ui', version: 3, migrate: migrateUiPersist } ) ); diff --git a/apps/web/src/frontend/features/lokee-weave/components/HistoryCompareBar.tsx b/apps/web/src/frontend/features/lokee-weave/components/HistoryCompareBar.tsx index 33efc71e..48d7fab3 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/HistoryCompareBar.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/HistoryCompareBar.tsx @@ -46,8 +46,8 @@ export function HistoryCompareBar(): React.ReactElement { Boolean(resolved.original && resolved.target && resolved.original.id === resolved.target.id); return ( -
    -
    +
    +
    Original @@ -114,7 +114,7 @@ export function HistoryCompareBar(): React.ReactElement {
    -
    +
    Target @@ -171,7 +171,7 @@ export function HistoryCompareBar(): React.ReactElement { {/* Capture lives on this row too. It used to sit on a second bar with its own credential picker, which read as a *third* connection control next to the two above it — three pickers for two ideas. */} -
    +
    Capture diff --git a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx index 6f9fb1ae..e6242efe 100644 --- a/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx +++ b/apps/web/src/frontend/features/lokee-weave/components/LokeeWeaveView.tsx @@ -15,6 +15,7 @@ import { LokeeWeavePage } from './LokeeWeavePage'; import { VersionCompareModal } from './VersionCompareModal'; import { LokeeObjectInspector } from './LokeeObjectInspector'; import { VersionTimeline } from './VersionTimeline'; +import { VersionChangeChart, VersionBriefing } from './VersionChangeChart'; import type { SchemaObjectNodeData, VersionGraphDTO } from './graphTypes'; import { captureSchema, @@ -547,16 +548,23 @@ export function LokeeWeaveView({ onSaveVersionMeta={saveVersionMeta} /> ) : ( - { - useLokeeHistoryStore.getState().setOriginalVersionId(id); - setComparePair({ original: id }); - }} - /> + <> + + + { + useLokeeHistoryStore.getState().setOriginalVersionId(id); + setComparePair({ original: id }); + }} + /> + )}
    {comparePair && activeId && ( diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.test.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.test.tsx new file mode 100644 index 00000000..dcf0a030 --- /dev/null +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.test.tsx @@ -0,0 +1,24 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { VersionChangeChart } from './VersionChangeChart'; + +describe('VersionChangeChart', () => { + it('draws stacked bars from version + / ~ / − already on the DTO', () => { + const { container } = render( + + ); + expect(screen.getByTestId('lokee-change-chart')).toBeTruthy(); + expect(container.querySelectorAll('rect').length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.tsx b/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.tsx new file mode 100644 index 00000000..4d3fe1bf --- /dev/null +++ b/apps/web/src/frontend/features/lokee-weave/components/VersionChangeChart.tsx @@ -0,0 +1,123 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Stacked + / ~ / − bars for captured versions. Counts already on the + * timeline DTO — no extra schema load. + */ +import React from 'react'; +import type { TimelineVersion } from './VersionTimeline'; + +const CHART_H = 180; +const BAR_W = 18; +const GAP = 10; +const PAD_X = 8; +const PAD_Y = 12; + +export function VersionChangeChart({ + versions, +}: { + versions: readonly TimelineVersion[]; +}): React.ReactElement { + const rows = [...versions].slice(0, 16).reverse(); + const max = Math.max( + 1, + ...rows.map((v) => (v.added ?? 0) + (v.modified ?? 0) + (v.removed ?? 0) || v.changeCount || 0) + ); + const innerH = CHART_H - PAD_Y * 2 - 18; + const width = Math.max(160, PAD_X * 2 + rows.length * (BAR_W + GAP) - GAP); + + return ( +
    +

    + Change volume +

    + {rows.length === 0 ? ( +

    No versions yet.

    + ) : ( + + {rows.map((v, i) => { + const added = v.added ?? 0; + const modified = v.modified ?? 0; + const removed = v.removed ?? 0; + const total = added + modified + removed || v.changeCount || 0; + const x = PAD_X + i * (BAR_W + GAP); + const h = Math.max(total > 0 ? 4 : 0, Math.round((total / max) * innerH)); + const yBase = PAD_Y + innerH; + const hAdd = total ? (added / total) * h : 0; + const hMod = total ? (modified / total) * h : 0; + const hRem = total ? (removed / total) * h : 0; + let y = yBase - h; + return ( + + {hAdd > 0 && ( + + )} + {hAdd > 0 && (y += hAdd)} + {hMod > 0 && ( + + )} + {hMod > 0 && (y += hMod)} + {hRem > 0 && ( + + )} + + v{v.number} + + + ); + })} + + )} +
    + ); +} + +export function VersionBriefing({ + versions, + selectedId, +}: { + versions: readonly TimelineVersion[]; + selectedId?: string | null; +}): React.ReactElement { + const selected = + versions.find((v) => v.id === selectedId) ?? versions[0] ?? null; + if (!selected) { + return ( +
    + Select a version in the timeline. +
    + ); + } + const added = selected.added ?? 0; + const modified = selected.modified ?? 0; + const removed = selected.removed ?? 0; + return ( +
    + v{selected.number} + + {selected.name || selected.source || 'Snapshot'} + + +{added} + ~{modified} + −{removed} +
    + ); +} diff --git a/apps/web/src/frontend/features/object-detail/components/ObjectDetailPanel.tsx b/apps/web/src/frontend/features/object-detail/components/ObjectDetailPanel.tsx index 62c4e2ae..e0edf4ff 100644 --- a/apps/web/src/frontend/features/object-detail/components/ObjectDetailPanel.tsx +++ b/apps/web/src/frontend/features/object-detail/components/ObjectDetailPanel.tsx @@ -7,7 +7,7 @@ import { findDropDependencies } from '@/features/object-detail/lib/dependency-sc import { findMissingFkTargets, findNarrowingTypeChanges, extractReviewNotices, resolveDialect } from '@/shared/lib/migration-validation'; import { buildIncludedDiffs, applySelectionsForScan, buildMapping } from '@/app/store/sync-helpers'; import { formatSql } from '@/shared/utils/formatSql'; -import { SchemaBlueprint } from '@/features/schema-diff'; +import { SchemaBlueprint, DiffBriefingPanel } from '@/features/schema-diff'; import { DetailTabs, type DetailTab } from '@/features/schema-diff'; import { buildTableDdlDiffLines, @@ -241,6 +241,9 @@ export const ObjectDetailPanel: React.FC = () => { }, [selectedTable, expandedTriggers, sourceConfig.dialect, targetConfig.dialect]); if (!selectedTable) { + if (compareResult && !browseMode) { + return ; + } // In Browse the left pane filters one database's objects, so this side // should say *which* database that is. Comparing has two connections named // in the toolbar already; browsing has one, and it was nowhere on screen. diff --git a/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.test.tsx b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.test.tsx new file mode 100644 index 00000000..96c8220f --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.test.tsx @@ -0,0 +1,61 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useSyncStore } from '@/app/store/useSyncStore'; +import { DiffBriefingPanel } from './DiffBriefingPanel'; +import type { TableDiff } from '@/shared/lib/types'; + +function table(name: string, status: TableDiff['status']): TableDiff { + return { + tableName: name, + objectType: 'TABLE', + status, + columnDiffs: [], + indexDiffs: [], + foreignKeyDiffs: [], + } as TableDiff; +} + +describe('DiffBriefingPanel', () => { + beforeEach(() => { + useSyncStore.setState({ + compareResult: { + tables: [ + table('orders', 'ADDED'), + table('customers', 'MODIFIED'), + table('legacy', 'REMOVED'), + table('ok', 'UNCHANGED'), + ], + summary: { added: 1, modified: 1, removed: 1, unchanged: 1 }, + } as never, + syncSelection: { orders: true, customers: true, legacy: false }, + filterStatus: 'ALL', + selectedTable: null, + sourceConfig: { + dialect: 'sqlite', + schema: 'main', + option: { host: '', database: '/tmp/a.db' }, + } as never, + targetConfig: { + dialect: 'sqlite', + schema: 'main', + option: { host: '', database: '/tmp/b.db' }, + } as never, + }); + }); + + it('lands on + / ~ / − from the compare DTO and opens an object without a second query', () => { + render(); + expect(screen.getByTestId('diff-briefing-panel')).toBeTruthy(); + expect(screen.getByTestId('diff-briefing-added').textContent).toContain('1'); + expect(screen.getByTestId('diff-briefing-modified').textContent).toContain('1'); + expect(screen.getByTestId('diff-briefing-removed').textContent).toContain('1'); + fireEvent.click(screen.getByTestId('diff-briefing-row-customers')); + expect(useSyncStore.getState().selectedTable?.tableName).toBe('customers'); + }); +}); diff --git a/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.tsx b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.tsx new file mode 100644 index 00000000..66e3c7e6 --- /dev/null +++ b/apps/web/src/frontend/features/schema-diff/components/DiffBriefingPanel.tsx @@ -0,0 +1,208 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Sync landing after Compare: counts and changed objects already in the + * compare DTO. Clicking a row selects that object in the tree — no extra query. + */ +import React, { useMemo } from 'react'; +import { ArrowRight, Layers } from 'lucide-react'; +import type { TableDiff } from '@/shared/lib/types'; +import { useSyncStore } from '@/app/store/useSyncStore'; +import { diffBriefing } from '../lib/diffBriefing'; +import { TYPE_META } from './SchemaDiffTree'; + +const STATUS_TONE: Record = { + ADDED: 'text-emerald-400', + REMOVED: 'text-rose-400', + MODIFIED: 'text-amber-400', +}; + +export function DiffBriefingPanel(): React.ReactElement { + const compareResult = useSyncStore((s) => s.compareResult); + const setSelectedTable = useSyncStore((s) => s.setSelectedTable); + const syncSelection = useSyncStore((s) => s.syncSelection); + const filterStatus = useSyncStore((s) => s.filterStatus); + const setFilterStatus = useSyncStore((s) => s.setFilterStatus); + const sourceConfig = useSyncStore((s) => s.sourceConfig); + const targetConfig = useSyncStore((s) => s.targetConfig); + + const tables = compareResult?.tables ?? []; + const briefing = diffBriefing(tables); + const changed = useMemo( + () => tables.filter((t) => t.status !== 'UNCHANGED'), + [tables] + ); + const included = changed.filter((t) => syncSelection[t.tableName]).length; + const drops = briefing.removed; + const original = formatEndpoint(sourceConfig); + const target = formatEndpoint(targetConfig); + + const show = (status: 'ALL' | 'ADDED' | 'REMOVED' | 'MODIFIED') => { + setFilterStatus(status); + }; + + return ( +
    +

    Diff briefing

    +

    What changed

    +

    + {original} + + {target} +

    + +
    + show('ADDED')} + /> + show('MODIFIED')} + /> + show('REMOVED')} + /> + show('ALL')} + /> +
    + +
    +

    + Deploy checklist +

    +
      +
    • + {included} of {changed.length} changed objects are ticked for the script. +
    • +
    • 0 ? 'text-rose-300' : 'text-slate-500'}> + {drops > 0 + ? `${drops} object(s) will DROP on Target if included — review before Execute.` + : 'No removals in this compare.'} +
    • +
    • + Pick an object in the list to inspect columns and generated DDL. The tree stays on the + left. +
    • +
    +
    + +
    +

    + Changed objects +

    + {changed.length === 0 ? ( +

    + Original and Target match for the current scope. +

    + ) : ( +
      + {changed.slice(0, 40).map((table) => ( + + ))} +
    + )} + {changed.length > 40 && ( +

    + Showing 40 of {changed.length}. Use the tree to reach the rest. +

    + )} +
    +
    + ); +} + +function ChangedRow({ + table, + onOpen, +}: { + table: TableDiff; + onOpen: (table: TableDiff) => void; +}): React.ReactElement { + const meta = TYPE_META[table.objectType]; + return ( +
  • + +
  • + ); +} + +function BriefingStat({ + testId, + label, + count, + tone, + active, + onClick, +}: { + testId: string; + label: string; + count: number; + tone: string; + active: boolean; + onClick: () => void; +}): React.ReactElement { + return ( + + ); +} + +function formatEndpoint(config: { + dialect: string; + schema: string; + option: { host?: string; database?: string }; +}): string { + const host = config.option.host ?? 'localhost'; + const db = config.option.database ?? '?'; + const schema = config.schema ? ` / ${config.schema}` : ''; + return `${config.dialect} · ${host} / ${db}${schema}`; +} diff --git a/apps/web/src/frontend/features/schema-diff/index.ts b/apps/web/src/frontend/features/schema-diff/index.ts index eb07f47f..4c47059e 100644 --- a/apps/web/src/frontend/features/schema-diff/index.ts +++ b/apps/web/src/frontend/features/schema-diff/index.ts @@ -16,4 +16,5 @@ export { SchemaBlueprint } from './components/SchemaBlueprint'; export { DdlDiffLines, buildTableDdlDiffLines, stripSchemaQualifiers } from './components/SchemaDdlDiff'; export { SchemaDiffTree, TYPE_META, TYPE_ORDER, orderTablesForDisplay } from './components/SchemaDiffTree'; export { DiffBriefingChips, DiffBriefingTicks } from './components/DiffBriefingChips'; +export { DiffBriefingPanel } from './components/DiffBriefingPanel'; export { diffBriefing, type DiffBriefing } from './lib/diffBriefing'; diff --git a/apps/web/src/frontend/features/sql-editor/components/ConnectionChecklist.tsx b/apps/web/src/frontend/features/sql-editor/components/ConnectionChecklist.tsx index 59e3d15b..52140e7e 100644 --- a/apps/web/src/frontend/features/sql-editor/components/ConnectionChecklist.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/ConnectionChecklist.tsx @@ -10,7 +10,9 @@ import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; * Destination-server checklist. When "Same servers for all queries" is on, * checks update the shared list; otherwise each tab keeps its own selection. */ -export const ConnectionChecklist: React.FC = () => { +export const ConnectionChecklist: React.FC<{ variant?: 'list' | 'chips' }> = ({ + variant = 'list', +}) => { const connections = useSyncStore((s) => s.connections); const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); @@ -37,6 +39,116 @@ export const ConnectionChecklist: React.FC = () => { setPendingValue(''); }; + const pendingModal = pendingPassword ? ( + createPortal( +
    { + cancelPasswordPrompt(); + setPendingValue(''); + }} + > +
    e.stopPropagation()} + > +

    + Password for “{pendingPassword.name}” +

    +

    + This connection was saved without a password. Enter it for this session only — it is + never stored. + {pendingPassword.resumeExecute ? ' Run will continue after you confirm.' : ''} +

    + setPendingValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && confirmPending()} + className="bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-xs outline-none" + placeholder="••••••••" + /> +
    + + +
    +
    +
    , + document.body + ) + ) : null; + + if (variant === 'chips') { + return ( +
    + + {!connectionsLoaded ? ( + Loading… + ) : connections.length === 0 ? ( + No saved connections + ) : ( +
    + {connections.map((c) => { + const on = selectedConnectionIds.includes(c.id); + return ( + + ); + })} +
    + )} + {pendingModal} +
    + ); + } + return (
    )} - - {pendingPassword && - createPortal( -
    { - cancelPasswordPrompt(); - setPendingValue(''); - }} - > -
    e.stopPropagation()} - > -

    - Password for “{pendingPassword.name}” -

    -

    - This connection was saved without a password. Enter it for this session only — it is - never stored. - {pendingPassword.resumeExecute ? ' Run will continue after you confirm.' : ''} -

    - setPendingValue(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && confirmPending()} - className="bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-xs outline-none" - placeholder="••••••••" - /> -
    - - -
    -
    -
    , - document.body - )}
    ); }; diff --git a/apps/web/src/frontend/features/sql-editor/components/SchemaTreePanel.tsx b/apps/web/src/frontend/features/sql-editor/components/SchemaTreePanel.tsx index cd68d301..e06579e2 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SchemaTreePanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SchemaTreePanel.tsx @@ -94,17 +94,14 @@ export const SchemaTreePanel: React.FC = () => { return filterStatus === 'ALL' || table.status === filterStatus; }); - // When search/filters hide the current selection (e.g. Browse + "PRODUCT" with - // no hits), drop or retarget selection so the detail panel doesn't show a - // stale object next to "No matching schema objects". + // After Compare, leave selection empty so Diff Briefing is the landing view. + // Only clear a selection that filters have hidden — do not pick the first row. useEffect(() => { if (!compareResult) return; - const stillVisible = - !!selectedTable && filteredTables.some((t) => t.tableName === selectedTable.tableName); + if (!selectedTable) return; + const stillVisible = filteredTables.some((t) => t.tableName === selectedTable.tableName); if (stillVisible) return; - const next = filteredTables[0] ?? null; - if ((selectedTable?.tableName ?? null) === (next?.tableName ?? null)) return; - setSelectedTable(next); + setSelectedTable(null); }, [compareResult, filteredTables, selectedTable, setSelectedTable]); if (!compareResult) { @@ -118,12 +115,12 @@ export const SchemaTreePanel: React.FC = () => { >

    - {browsing ? 'Nothing loaded' : 'No Comparison Active'} + {browsing ? 'Nothing loaded' : 'No comparison yet'}

    {browsing ? 'Pick a connection above and click "Browse" to read its objects.' - : 'Connect and click "Compare Schemas" to view the difference tree.'} + : 'Pick Original and Target in the chips, then Compare. The briefing opens here.'}

    ); @@ -179,7 +176,7 @@ export const SchemaTreePanel: React.FC = () => { > {/* Overview Stats Dashboard */}
    -
    +

    {browseMode ? ( <>Browsing {browseSchemaName} @@ -187,9 +184,21 @@ export const SchemaTreePanel: React.FC = () => { 'Compare Results' )}

    +
    + {!browseMode && selectedTable && ( + + )} {compareResult.tables.length} objects +
    {/* Stat cards double as the filter: All/Added/Removed/Modified are a diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index 6221634b..c9c93c77 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -163,9 +163,10 @@ export const SqlEditorView: React.FC = () => { const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const splitRef = useRef(null); - const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); + const [sidebarOpen, , selectSidebar] = useSidebarSectionsOpen(); const [sectionHeights, setSectionHeight] = useSidebarSectionHeights(); const [sectionOrder, moveSection] = useSidebarSectionOrder(); + const prevDestCount = useRef(liveSelectedIds.length); const [dragFrom, setDragFrom] = useState(null); const [dragOver, setDragOver] = useState(null); const secretsPanelRef = useRef(null); @@ -203,6 +204,14 @@ export const SqlEditorView: React.FC = () => { } }, [sidebarCollapsed]); + useEffect(() => { + const n = liveSelectedIds.length; + if (n >= 2 && prevDestCount.current < 2 && tab.layout !== 'sideBySide') { + setLayout('sideBySide'); + } + prevDestCount.current = n; + }, [liveSelectedIds.length, setLayout, tab.layout]); + // Completion provider reads active SQL + checked schemas + variables via this getter. useEffect(() => { setCompletionContextGetter(() => { @@ -387,11 +396,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorDestinations) return null; return ( } open={sidebarOpen.destinations} - onToggle={() => toggleSidebar('destinations')} + onToggle={() => selectSidebar('destinations')} height={sectionHeights.destinations} onResizeHeight={(h) => setSectionHeight('destinations', h)} {...drag} @@ -403,11 +413,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorBookmarks) return null; return ( } open={sidebarOpen.bookmarks} - onToggle={() => toggleSidebar('bookmarks')} + onToggle={() => selectSidebar('bookmarks')} height={sectionHeights.bookmarks} onResizeHeight={(h) => setSectionHeight('bookmarks', h)} actions={ @@ -431,11 +442,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorVariables || !canVariablesRead) return null; return ( } open={sidebarOpen.variables} - onToggle={() => toggleSidebar('variables')} + onToggle={() => selectSidebar('variables')} height={sectionHeights.variables} onResizeHeight={(h) => setSectionHeight('variables', h)} {...drag} @@ -447,11 +459,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorSecrets || !canSecretsView) return null; return ( } open={sidebarOpen.vault} - onToggle={() => toggleSidebar('vault')} + onToggle={() => selectSidebar('vault')} height={sectionHeights.vault} onResizeHeight={(h) => setSectionHeight('vault', h)} actions={ @@ -479,11 +492,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorUtilities || !canUtilityAccess) return null; return ( } open={sidebarOpen.utilities} - onToggle={() => toggleSidebar('utilities')} + onToggle={() => selectSidebar('utilities')} {...drag} >
    @@ -566,11 +580,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorUtilities || !canUtilityAccess) return null; return ( } open={sidebarOpen.files} - onToggle={() => toggleSidebar('files')} + onToggle={() => selectSidebar('files')} height={sectionHeights.files} onResizeHeight={(h) => setSectionHeight('files', h)} {...drag} @@ -585,11 +600,12 @@ export const SqlEditorView: React.FC = () => { if (!canEditorSchema) return null; return ( } open={sidebarOpen.schema} - onToggle={() => toggleSidebar('schema')} + onToggle={() => selectSidebar('schema')} grow height={sectionHeights.schema} onResizeHeight={(h) => setSectionHeight('schema', h)} @@ -625,7 +641,7 @@ export const SqlEditorView: React.FC = () => { canUtilityAccess, canEditorSchema, sidebarOpen, - toggleSidebar, + selectSidebar, sectionHeights, setSectionHeight, tab.sql, @@ -637,47 +653,135 @@ export const SqlEditorView: React.FC = () => { ] ); + const railIcons: { + id: SidebarSectionId; + title: string; + visible: boolean; + icon: React.ReactNode; + }[] = [ + { + id: 'schema', + title: 'Schema', + visible: canEditorSchema, + icon: , + }, + { + id: 'destinations', + title: 'Destinations', + visible: canEditorDestinations, + icon: , + }, + { + id: 'bookmarks', + title: 'Bookmarks', + visible: canEditorBookmarks, + icon: , + }, + { + id: 'variables', + title: 'Variables', + visible: canEditorVariables && canVariablesRead, + icon: , + }, + { + id: 'vault', + title: 'Secrets', + visible: canEditorSecrets && canSecretsView, + icon: , + }, + { + id: 'utilities', + title: 'Utilities', + visible: canEditorUtilities && canUtilityAccess, + icon: , + }, + { + id: 'files', + title: 'Files', + visible: canEditorUtilities && canUtilityAccess, + icon: , + }, + ]; + const orderedRail = sectionOrder + .map((id) => railIcons.find((r) => r.id === id)) + .filter((r): r is NonNullable => Boolean(r?.visible)); + const openRailId = orderedRail.find((r) => sidebarOpen[r.id])?.id ?? orderedRail[0]?.id ?? null; + return (
    - {sidebarCollapsed ? ( - - ) : ( - - )} + )} +
    { />
    + {canEditorDestinations && ( +
    + +
    + )}
    -
    +
    +
    diff --git a/apps/web/src/frontend/app/store/uiStore.test.ts b/apps/web/src/frontend/app/store/uiStore.test.ts index 5fbde10b..8a34e26c 100644 --- a/apps/web/src/frontend/app/store/uiStore.test.ts +++ b/apps/web/src/frontend/app/store/uiStore.test.ts @@ -52,4 +52,11 @@ describe('migrateUiPersist', () => { }; expect(next.activeView).toBe('home'); }); + + it('keeps the Utilities workspace when already persisted', () => { + const next = migrateUiPersist({ activeView: 'utilities', syncPane: 'compare' }, 3) as { + activeView: string; + }; + expect(next.activeView).toBe('utilities'); + }); }); diff --git a/apps/web/src/frontend/app/store/uiStore.ts b/apps/web/src/frontend/app/store/uiStore.ts index 943a8dee..0d62acfe 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -176,7 +176,7 @@ function applyToDocument(themeMode: ThemeMode, tone: ToneId, fontSize: FontSize, } /** Top-level workspace views. Snapshots (Lokee) is its own view, not a Sync pane. */ -export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots' | 'home'; +export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots' | 'home' | 'utilities'; /** * Browse is its own pane, not a mode hiding inside Compare. It answers a * different question — "what is in this one database?" rather than "how do @@ -241,7 +241,11 @@ export function migrateUiPersist(persisted: unknown, _version: number): unknown state.activeView = 'snapshots'; state.syncPane = 'compare'; } - if (!['sync', 'sqlEditor', 'access', 'snapshots', 'home'].includes(state.activeView as string)) { + if ( + !['sync', 'sqlEditor', 'access', 'snapshots', 'home', 'utilities'].includes( + state.activeView as string + ) + ) { state.activeView = 'home'; } if (_version < 3 && state.activeView === 'sync') { diff --git a/apps/web/src/frontend/features/sql-editor/components/FileImportsPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/FileImportsPanel.tsx index a0d4530d..3231e9fd 100644 --- a/apps/web/src/frontend/features/sql-editor/components/FileImportsPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/FileImportsPanel.tsx @@ -15,13 +15,19 @@ import { formatFileImportWhen, importCreatedAtMs } from '../lib/fileImportsTime' type Props = { refreshKey?: number; onImportClick: () => void; + /** After picking a table, jump to the SQL Editor (Utilities workspace). */ + onUseInEditor?: () => void; }; /** * Sidebar manager for reusable Query-files imports (temp SQLite credentials). * Click a table to check that file DB and load a sample SELECT. */ -export const FileImportsPanel: React.FC = ({ refreshKey = 0, onImportClick }) => { +export const FileImportsPanel: React.FC = ({ + refreshKey = 0, + onImportClick, + onUseInEditor, +}) => { const loadConnections = useSyncStore((s) => s.loadConnections); const [imports, setImports] = useState([]); const [loading, setLoading] = useState(false); @@ -228,6 +234,7 @@ export const FileImportsPanel: React.FC = ({ refreshKey = 0, onImportClic title: 'Ready to query', body: `"${imp.name}" checked — run the sample SELECT.`, }); + onUseInEditor?.(); }} > {tableName} diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index c9c93c77..35edb710 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -17,13 +17,6 @@ import { PanelLeftClose, PanelLeftOpen, Plus, - Copy, - Wrench, - Users, - Cpu, - HardDrive, - Activity, - FileSpreadsheet, } from 'lucide-react'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; @@ -55,12 +48,6 @@ import { type SidebarSectionId, } from './SqlSidebarSection'; import { WriteConfirmDialog } from './WriteConfirmDialog'; -import { IndexManagementModal } from '@/features/utilities'; -import { CloneTableModal } from '@/features/utilities'; -import { FileQueryModal } from '@/features/utilities'; -import { ServerInsightsModal, type ServerInsightsTab } from '@/features/utilities'; -import { DatabaseAccessModal } from '@/features/utilities'; -import { FileImportsPanel } from './FileImportsPanel'; import type { RevealRequest } from './SqlEditorPane'; const SqlEditorPane = lazy(() => import('./SqlEditorPane')); @@ -71,9 +58,6 @@ const EditorFallback: React.FC = () => (
    ); -const UTIL_MENU_BTN = - 'w-full flex items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-semibold text-slate-100 hover:bg-slate-800 hover:text-slate-50 border border-transparent hover:border-amber-500/35'; -const UTIL_MENU_ICON = 'w-3.5 h-3.5 text-amber-400 shrink-0'; const EDITOR_PCT_MIN = 15; const EDITOR_PCT_MAX = 70; const EDITOR_PCT_DEFAULT = 26; @@ -111,8 +95,6 @@ export const SqlEditorView: React.FC = () => { const canEditorBookmarks = useAuthStore((s) => s.can('editor.sidebar.bookmarks')); const canEditorVariables = useAuthStore((s) => s.can('editor.sidebar.variables')); const canEditorSecrets = useAuthStore((s) => s.can('editor.sidebar.secrets')); - const canEditorUtilities = useAuthStore((s) => s.can('editor.sidebar.utilities')); - const canUtilityAccess = useAuthStore((s) => s.can('utility.access')); const canEditorSchema = useAuthStore((s) => s.can('editor.sidebar.schema')); const canSecretsView = useAuthStore((s) => s.can('secrets.view')); const canVariablesRead = useAuthStore((s) => s.can('editor.variables.read')); @@ -172,12 +154,6 @@ export const SqlEditorView: React.FC = () => { const secretsPanelRef = useRef(null); const schemaExplorerRef = useRef(null); const [secretsRefreshing, setSecretsRefreshing] = useState(false); - const [showIndexManagement, setShowIndexManagement] = useState(false); - const [showCloneTable, setShowCloneTable] = useState(false); - const [showFileQuery, setShowFileQuery] = useState(false); - const [showDatabaseAccess, setShowDatabaseAccess] = useState(false); - const [fileImportsKey, setFileImportsKey] = useState(0); - const [serverInsightsTab, setServerInsightsTab] = useState(null); const onSecretsRefresh = useCallback(async () => { setSecretsRefreshing(true); @@ -489,113 +465,8 @@ export const SqlEditorView: React.FC = () => {
    ); case 'utilities': - if (!canEditorUtilities || !canUtilityAccess) return null; - return ( - } - open={sidebarOpen.utilities} - onToggle={() => selectSidebar('utilities')} - {...drag} - > -
    - - - - - - - - -
    -
    - ); case 'files': - if (!canEditorUtilities || !canUtilityAccess) return null; - return ( - } - open={sidebarOpen.files} - onToggle={() => selectSidebar('files')} - height={sectionHeights.files} - onResizeHeight={(h) => setSectionHeight('files', h)} - {...drag} - > - setShowFileQuery(true)} - /> - - ); + return null; case 'schema': if (!canEditorSchema) return null; return ( @@ -637,8 +508,6 @@ export const SqlEditorView: React.FC = () => { canVariablesRead, canEditorSecrets, canSecretsView, - canEditorUtilities, - canUtilityAccess, canEditorSchema, sidebarOpen, selectSidebar, @@ -648,7 +517,6 @@ export const SqlEditorView: React.FC = () => { saveBookmark, secretsRefreshing, onSecretsRefresh, - fileImportsKey, sidebarDragProps, ] ); @@ -689,18 +557,6 @@ export const SqlEditorView: React.FC = () => { visible: canEditorSecrets && canSecretsView, icon: , }, - { - id: 'utilities', - title: 'Utilities', - visible: canEditorUtilities && canUtilityAccess, - icon: , - }, - { - id: 'files', - title: 'Files', - visible: canEditorUtilities && canUtilityAccess, - icon: , - }, ]; const orderedRail = sectionOrder .map((id) => railIcons.find((r) => r.id === id)) @@ -1051,25 +907,6 @@ export const SqlEditorView: React.FC = () => { )} {/* Always mounted so FK clicks from results work even when Schema is collapsed. */} - setShowIndexManagement(false)} - /> - setShowDatabaseAccess(false)} - /> - setShowCloneTable(false)} /> - setShowFileQuery(false)} - onImported={() => setFileImportsKey((k) => k + 1)} - /> - setServerInsightsTab(null)} - />
    ); }; diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx index ba43e6ae..c03ad6fc 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSidebarSection.tsx @@ -273,7 +273,9 @@ export function useSidebarSectionHeights(): [ /** * Collapsible block for the SQL Editor left sidebar * (Schema first by default, then Destinations / Bookmarks / Variables / - * Secrets / Utilities / Files — all sections are reorderable). + * Secrets — all sections are reorderable). Utilities and Files moved to the + * Utilities workspace; their ids stay in persisted order so old localStorage + * does not break. * Open sections are height-resizable via the bottom grip. */ export const SqlSidebarSection: React.FC<{ diff --git a/apps/web/src/frontend/features/sql-editor/index.ts b/apps/web/src/frontend/features/sql-editor/index.ts index fbe8bf18..e2753c65 100644 --- a/apps/web/src/frontend/features/sql-editor/index.ts +++ b/apps/web/src/frontend/features/sql-editor/index.ts @@ -19,3 +19,4 @@ export { dialectFkConstraintSupport, dialectIndexSupport, executableSqlStatement export { SqlDiffEditor } from './components/SqlEditor'; export { SqlEditor } from './components/SqlEditor'; export { SqlEditorView } from './components/SqlEditorView'; +export { FileImportsPanel } from './components/FileImportsPanel'; diff --git a/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx b/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx index 5e9fc023..30fe34fe 100644 --- a/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx @@ -13,6 +13,7 @@ import { Copy, Loader2, Play, X } from 'lucide-react'; import { executeSql } from '@/shared/api/sqlApi'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { useUiStore } from '@/app/store/uiStore'; import type { TableSchema } from '@/shared/lib/types'; import { PROVIDER_SETTINGS, connectionNeedsSecret } from '@/shared/lib/provider-settings'; import { insertAtCursor } from '@/features/sql-editor'; @@ -32,6 +33,8 @@ interface Props { onClose: () => void; /** Pre-select this table when opened from Schema explorer. */ initialTableName?: string | null; + /** Dock in the Utilities workspace (no overlay). */ + embedded?: boolean; } const LS_CONN = 'foxschema-utilities-clone-table-connection'; @@ -42,6 +45,7 @@ export const CloneTableModal: React.FC = ({ open, onClose, initialTableName = null, + embedded = false, }) => { const connections = useSyncStore((s) => s.connections); const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); @@ -50,6 +54,7 @@ export const CloneTableModal: React.FC = ({ const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); const schemaCache = useSqlEditorStore((s) => s.schemaCache); const safeMode = useSqlEditorStore((s) => s.safeMode); + const setActiveView = useUiStore((s) => s.setActiveView); const [connectionId, setConnectionId] = useState(''); const [passwordDraft, setPasswordDraft] = useState(''); @@ -275,15 +280,13 @@ export const CloneTableModal: React.FC = ({ if (!open) return null; - return createPortal( - <> -
    + const sheet = (
    e.stopPropagation()} >
    @@ -296,6 +299,7 @@ export const CloneTableModal: React.FC = ({ Archive huge table as name_N · recreate empty live table · keep apps working

    + {!embedded && ( + )}
    @@ -542,6 +547,7 @@ export const CloneTableModal: React.FC = ({
    + {!embedded && ( + )}
    -
    - {confirmApply && ( + ); + + const confirm = confirmApply ? ( = ({ void runClone(); }} /> - )} + ) : null; + + if (embedded) { + return ( + <> +
    + {sheet} +
    + {confirm} + + ); + } + + return createPortal( + <> +
    + {sheet} +
    + {confirm} , document.body ); diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx index 02880c22..0602295f 100644 --- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.tsx @@ -99,10 +99,14 @@ export const DatabaseAccessModal: React.FC = ({ const support = dialect ? dialectSupportsDbAccess(dialect) : null; useEffect(() => { - if (!open || embedded) return; - const saved = localStorage.getItem(LS_CONN) ?? ''; - if (saved && connections.some((c) => c.id === saved)) setConnectionId(saved); - }, [open, embedded, connections]); + if (!open) return; + setConnectionId((cur) => { + if (cur && connections.some((c) => c.id === cur)) return cur; + const saved = localStorage.getItem(LS_CONN) ?? ''; + if (saved && connections.some((c) => c.id === saved)) return saved; + return connections[0]?.id || ''; + }); + }, [open, connections]); const load = useCallback(async () => { if (!connectionId || needsPassword) return; @@ -266,7 +270,7 @@ export const DatabaseAccessModal: React.FC = ({ if (id === connectionId) return; ++loadToken.current; setConnectionId(id); - if (!embedded) localStorage.setItem(LS_CONN, id); + localStorage.setItem(LS_CONN, id); setPrincipals([]); setPrivileges([]); setSelectedName(null); @@ -756,7 +760,7 @@ export const DatabaseAccessModal: React.FC = ({ if (embedded) { return ( -
    +
    {content} {confirmPortal}
    diff --git a/apps/web/src/frontend/features/utilities/components/FileQueryModal.tsx b/apps/web/src/frontend/features/utilities/components/FileQueryModal.tsx index fb00a8c1..aec4f4d2 100644 --- a/apps/web/src/frontend/features/utilities/components/FileQueryModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/FileQueryModal.tsx @@ -24,6 +24,8 @@ interface Props { onClose: () => void; /** Fired after a successful import so the Files sidebar can refresh. */ onImported?: () => void; + /** Dock in the Utilities workspace (no overlay). */ + embedded?: boolean; } const EMPTY_OFFSETS: TextOffsetColumn[] = [ @@ -37,7 +39,12 @@ function isFilesConnectionName(name: string | undefined): boolean { return !!name && /^Files:\s+/i.test(name.trim()); } -export const FileQueryModal: React.FC = ({ open, onClose, onImported }) => { +export const FileQueryModal: React.FC = ({ + open, + onClose, + onImported, + embedded = false, +}) => { const loadConnections = useSyncStore((s) => s.loadConnections); const connections = useSyncStore((s) => s.connections); const setSql = useSqlEditorStore((s) => s.setSql); @@ -191,7 +198,7 @@ export const FileQueryModal: React.FC = ({ open, onClose, onImported }) = body: `"${label}" is checked — run the sample SELECT.${modeNote}`, }); onImported?.(); - onClose(); + if (!embedded) onClose(); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Import failed'); } finally { @@ -226,15 +233,15 @@ export const FileQueryModal: React.FC = ({ open, onClose, onImported }) = if (!open) return null; - return createPortal( -
    { - if (e.target === e.currentTarget) onClose(); - }} - > -
    + const form = ( +
    + {!embedded && (
    @@ -249,8 +256,13 @@ export const FileQueryModal: React.FC = ({ open, onClose, onImported }) =
    + )} -
    +

    Import CSV, JSON, or fixed-width text. Default lands in a temporary SQLite workspace (multiple files can share one DB). Or bulk-load into a saved @@ -568,6 +580,7 @@ export const FileQueryModal: React.FC = ({ open, onClose, onImported }) = Clear file imports

    + {!embedded && ( + )}
    + ); + + if (embedded) { + return ( +
    + {form} +
    + ); + } + + return createPortal( +
    { + if (e.target === e.currentTarget) onClose(); + }} + > + {form}
    , document.body ); diff --git a/apps/web/src/frontend/features/utilities/components/IndexManagementModal.tsx b/apps/web/src/frontend/features/utilities/components/IndexManagementModal.tsx index b2ec7c54..688ad4d5 100644 --- a/apps/web/src/frontend/features/utilities/components/IndexManagementModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/IndexManagementModal.tsx @@ -769,7 +769,7 @@ export const IndexManagementModal: React.FC = ({
    - {!embedded && ( + {!lockedConnectionId && (

    -
    } title="Text Size"> -
    - {FONT_SIZES.map((f, i) => ( - - ))} -
    -
    +
    } title="Background"> +
    + {MODES.map((m) => ( + + ))} +
    +
    - {info && ( -
    } title="Database"> - -
    - )} +
    } title="UI Tone"> +
    + {TONES.map((t) => ( + + ))} +
    +
    -
    } title="Updates"> - -
    +
    } title="Accent"> +
    + {(Object.keys(ACCENTS) as AccentId[]).map((id) => ( +
    +
    - {info && ( -
    } title="Security"> -
    - -
    -
    - )} +
    } title="Text Size"> +
    + {FONT_SIZES.map((f, i) => ( + + ))}
    +
    +
    + ); -
    + const body = ( +
    +
    +
    + +
    +

    Preferences

    +

    + Personalize the whole interface · changes apply instantly +

    +
    +
    +
    - + {onClose && ( + + )}
    + + + +
    + {tab === 'appearance' && appearance} + {tab === 'database' && ( +
    } title="Database"> + {info ? ( + + ) : ( +

    Loading app database info…

    + )} +
    + )} + {tab === 'updates' && ( +
    } title="Updates"> + +
    + )} + {tab === 'security' && ( +
    } title="Security"> +
    + {info ? ( + + ) : ( +

    Loading encryption binding…

    + )} +
    +
    + )} +
    +
    + ); + + if (embedded) { + return ( +
    + {body} +
    + ); + } + + return createPortal( +
    +
    e.stopPropagation()} + > + {body} +
    , document.body ); diff --git a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx index da90f0ff..1230ea83 100644 --- a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx +++ b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx @@ -36,5 +36,7 @@ describe('ActivityRail', () => { expect(screen.getByTestId('sync-pane-history-btn')).toBeTruthy(); fireEvent.click(screen.getByTestId('home-open-btn')); expect(useUiStore.getState().activeView).toBe('home'); + fireEvent.click(screen.getByTestId('view-settings-btn')); + expect(useUiStore.getState().activeView).toBe('settings'); }); }); diff --git a/apps/web/src/frontend/app/shell/ActivityRail.tsx b/apps/web/src/frontend/app/shell/ActivityRail.tsx index 1ce828d3..26849cf2 100644 --- a/apps/web/src/frontend/app/shell/ActivityRail.tsx +++ b/apps/web/src/frontend/app/shell/ActivityRail.tsx @@ -7,7 +7,7 @@ * Workspace switcher that lived in the top toolbar. */ import React from 'react'; -import { Camera, GitCompareArrows, ShieldCheck, Terminal, Wrench } from 'lucide-react'; +import { Camera, GitCompareArrows, Settings, ShieldCheck, Terminal, Wrench } from 'lucide-react'; import { useAuthStore } from '@/app/store/authStore'; import { useUiStore, type ActiveView } from '@/app/store/uiStore'; import { FoxLogo } from './FoxLogo'; @@ -73,7 +73,6 @@ export function ActivityRail(): React.ReactElement | null { }; const visible = ITEMS.filter((item) => allowed(item.permission)); - if (visible.length === 0) return null; return ( ); } diff --git a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx index 371757ee..2ec0c440 100644 --- a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx +++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx @@ -87,4 +87,11 @@ describe('CommandPalette', () => { fireEvent.click(screen.getByTestId('command-palette-item-ws-utilities')); expect(setActiveView).toHaveBeenCalledWith('utilities'); }); + + it('jumps to Preferences', () => { + render(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + fireEvent.click(screen.getByTestId('command-palette-item-ws-settings')); + expect(setActiveView).toHaveBeenCalledWith('settings'); + }); }); diff --git a/apps/web/src/frontend/app/shell/CommandPalette.tsx b/apps/web/src/frontend/app/shell/CommandPalette.tsx index d927fe4e..ca9ac4b2 100644 --- a/apps/web/src/frontend/app/shell/CommandPalette.tsx +++ b/apps/web/src/frontend/app/shell/CommandPalette.tsx @@ -73,6 +73,12 @@ export const CommandPalette: React.FC = () => { run: () => go('snapshots'), }); } + out.push({ + id: 'ws-settings', + group: 'Workspace', + label: 'Preferences', + run: () => go('settings'), + }); for (const c of connections) { out.push({ id: `conn-${c.id}`, diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 09f7efca..2e48535d 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -1,22 +1,17 @@ -import React, { Suspense, lazy, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { LogOut, Palette, ChevronDown, ArrowUpCircle, Globe, Shield } from 'lucide-react'; import { useAuthStore } from '@/app/store/authStore'; +import { useUiStore } from '@/app/store/uiStore'; import { checkForUpdates, type UpdateInfo } from '@/shared/api/updatesApi'; import { maybeToastUpdateAvailable } from '@/app/shell/updateToast'; import { AdminAccessPanel } from '@/features/admin'; -// Lazy so a SettingsPanel/HMR failure cannot empty this module's exports -// (which surfaces as: ProfileMenu.tsx does not provide export named 'ProfileMenu'). -const SettingsPanel = lazy(() => - import('@/app/settings/SettingsPanel').then((m) => ({ default: m.SettingsPanel })) -); - export function ProfileMenu(): React.ReactElement | null { const { user, logout, localSingleUser } = useAuthStore(); + const setActiveView = useUiStore((s) => s.setActiveView); const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles')); const [open, setOpen] = useState(false); - const [showSettings, setShowSettings] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [update, setUpdate] = useState(null); const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); @@ -104,13 +99,14 @@ export function ProfileMenu(): React.ReactElement | null { {canAdminAccess && ( @@ -172,11 +168,6 @@ export function ProfileMenu(): React.ReactElement | null { {menu} - {showSettings && ( - - setShowSettings(false)} /> - - )} setShowAdmin(false)} />
    ); diff --git a/apps/web/src/frontend/app/store/uiStore.test.ts b/apps/web/src/frontend/app/store/uiStore.test.ts index 8a34e26c..2bd90d2e 100644 --- a/apps/web/src/frontend/app/store/uiStore.test.ts +++ b/apps/web/src/frontend/app/store/uiStore.test.ts @@ -59,4 +59,11 @@ describe('migrateUiPersist', () => { }; expect(next.activeView).toBe('utilities'); }); + + it('keeps Preferences as a persisted workspace', () => { + const next = migrateUiPersist({ activeView: 'settings', syncPane: 'compare' }, 3) as { + activeView: string; + }; + expect(next.activeView).toBe('settings'); + }); }); diff --git a/apps/web/src/frontend/app/store/uiStore.ts b/apps/web/src/frontend/app/store/uiStore.ts index 0d62acfe..0d982f86 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -176,7 +176,14 @@ function applyToDocument(themeMode: ThemeMode, tone: ToneId, fontSize: FontSize, } /** Top-level workspace views. Snapshots (Lokee) is its own view, not a Sync pane. */ -export type ActiveView = 'sync' | 'sqlEditor' | 'access' | 'snapshots' | 'home' | 'utilities'; +export type ActiveView = + | 'sync' + | 'sqlEditor' + | 'access' + | 'snapshots' + | 'home' + | 'utilities' + | 'settings'; /** * Browse is its own pane, not a mode hiding inside Compare. It answers a * different question — "what is in this one database?" rather than "how do @@ -242,7 +249,7 @@ export function migrateUiPersist(persisted: unknown, _version: number): unknown state.syncPane = 'compare'; } if ( - !['sync', 'sqlEditor', 'access', 'snapshots', 'home', 'utilities'].includes( + !['sync', 'sqlEditor', 'access', 'snapshots', 'home', 'utilities', 'settings'].includes( state.activeView as string ) ) { From ff7e4aa3abe795aa99c4d1f1039387705ca87b95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:22:39 +0000 Subject: [PATCH 10/17] feat(ui): lock Access panels to one workspace credential Pick a database once on the Access chrome. User Management, Permission, and Diff inherit that chip instead of asking again in each pane. Co-authored-by: huy.phan9 --- apps/e2e/src/pages/AccessPage.ts | 9 +- .../tests/access-assistant-dialects.test.ts | 29 +++--- .../tests/database-access-dialects.test.ts | 6 +- .../components/AccessPermissionPanel.tsx | 21 +++-- .../access/components/AccessView.test.tsx | 18 +++- .../features/access/components/AccessView.tsx | 88 +++++++++++++++++-- .../access/components/PermissionDiff.tsx | 20 +++-- .../access/components/UserManagement.tsx | 32 ++++++- 8 files changed, 183 insertions(+), 40 deletions(-) diff --git a/apps/e2e/src/pages/AccessPage.ts b/apps/e2e/src/pages/AccessPage.ts index f66071dc..a7dd4c48 100644 --- a/apps/e2e/src/pages/AccessPage.ts +++ b/apps/e2e/src/pages/AccessPage.ts @@ -17,9 +17,14 @@ export class AccessPage { await clickWhen(this.page, `[data-testid="access-tab-${tab}"]`); } - /** Pick a saved credential in any Access / Database Access by visible name. + * The Access workspace uses one chip (`access-connection`); panel-local + * dropdowns are used when a panel still has its own picker. */ async selectConnection(selectTestId: string, nameSubstring: string): Promise { - const select = this.page.locator(`[data-testid="${selectTestId}"]`); + const workspace = this.page.locator('[data-testid="access-connection"]'); + const select = (await workspace.isVisible().catch(() => false)) + ? workspace + : this.page.locator(`[data-testid="${selectTestId}"]`); await select.waitFor({ state: 'visible', timeout: 10_000 }); const value = await select.evaluate((el, want) => { const sel = el as HTMLSelectElement; diff --git a/apps/e2e/src/tests/access-assistant-dialects.test.ts b/apps/e2e/src/tests/access-assistant-dialects.test.ts index b18e7bf1..fe3b5af4 100644 --- a/apps/e2e/src/tests/access-assistant-dialects.test.ts +++ b/apps/e2e/src/tests/access-assistant-dialects.test.ts @@ -132,23 +132,17 @@ describe.skipIf(configured.length === 0)('Access Assistant (all configured diale }); /** - * The exact option label for a dialect's saved connection. - * - * The two panels spell it differently: User Management renders - * `name · dialect`, while Permission Builder and Permission Diff render - * `[DIALECT] name`. Playwright matches `selectOption({ label })` as a literal - * string, so one format cannot serve both — and this used to pass a RegExp, - * which the types reject and which matches nothing at runtime, so the - * selection silently never happened at all. + * Access uses one workspace chip (`name · dialect`) for Users, Permission, and Diff. */ const usersLabel = (dialect: string) => `${credNameByDialect.get(dialect)!} · ${dialect}`; - const builderLabel = (dialect: string) => - `[${dialect.toUpperCase()}] ${credNameByDialect.get(dialect)!}`; async function selectConnection(dialect: string) { - await driver - .locator('[data-testid="user-connection"]') - .selectOption({ label: usersLabel(dialect) }); + const label = usersLabel(dialect); + const chip = driver.locator('[data-testid="access-connection"]'); + const select = (await chip.isVisible().catch(() => false)) + ? chip + : driver.locator('[data-testid="user-connection"]'); + await select.selectOption({ label }); } /** @@ -231,7 +225,7 @@ describe.skipIf(configured.length === 0)('Access Assistant (all configured diale await driver.waitForSelector('[data-testid="permission-builder"]', { timeout: 15_000 }); await driver .locator('[data-testid="access-connection"]') - .selectOption({ label: builderLabel(dialect) }); + .selectOption({ label: usersLabel(dialect) }); await driver.locator('[data-testid="access-principal-name"]').fill('report_user'); await fillScope(dialect); @@ -256,10 +250,9 @@ describe.skipIf(configured.length === 0)('Access Assistant (all configured diale await driver.locator('[data-testid="access-tab-diff"]').click(); await driver.waitForSelector('[data-testid="permission-diff"]', { timeout: 15_000 }); - const name = credNameByDialect.get(dialect)!; await driver - .locator('[data-testid="diff-connection"]') - .selectOption({ label: builderLabel(dialect) }); + .locator('[data-testid="access-connection"]') + .selectOption({ label: usersLabel(dialect) }); await driver.locator('[data-testid="diff-principal-name"]').fill('report_user'); // The desired-state row offers only the scopes the engine can grant on, @@ -346,7 +339,7 @@ describe.skipIf(configured.length === 0)('Access Assistant (all configured diale await driver.waitForSelector('[data-testid="permission-builder"]', { timeout: 15_000 }); await driver .locator('[data-testid="access-connection"]') - .selectOption({ label: builderLabel(dialect) }); + .selectOption({ label: usersLabel(dialect) }); await driver.locator('[data-testid="access-action"]').getByText('Deny').click(); await driver.locator('[data-testid="access-principal-name"]').fill('report_user'); await driver.locator('[data-testid="access-schema"]').fill('dbo'); diff --git a/apps/e2e/src/tests/database-access-dialects.test.ts b/apps/e2e/src/tests/database-access-dialects.test.ts index afc76ed4..0938e4b0 100644 --- a/apps/e2e/src/tests/database-access-dialects.test.ts +++ b/apps/e2e/src/tests/database-access-dialects.test.ts @@ -188,7 +188,11 @@ describe.skipIf(configured.length === 0)('Database Access · User Management', ( */ async function selectConnection(dialect: string) { const label = `${credNameByDialect.get(dialect)!} · ${dialect}`; - await driver.locator('[data-testid="user-connection"]').selectOption({ label }); + const chip = driver.locator('[data-testid="access-connection"]'); + const select = (await chip.isVisible().catch(() => false)) + ? chip + : driver.locator('[data-testid="user-connection"]'); + await select.selectOption({ label }); } /** diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx index 9b865bdf..d5aedd2a 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -48,7 +48,9 @@ const KIND_GROUPS: { kind: DbPrincipal['kind']; label: string }[] = [ export const AccessPermissionPanel: React.FC<{ initialDraft?: AccessPrincipalDraft | null; -}> = ({ initialDraft = null }) => { + lockedConnectionId?: string; + onConnectionChange?: (id: string) => void; +}> = ({ initialDraft = null, lockedConnectionId, onConnectionChange }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); const setSql = useSqlEditorStore((s) => s.setSql); @@ -56,7 +58,12 @@ export const AccessPermissionPanel: React.FC<{ const setActiveView = useUiStore((s) => s.setActiveView); const canGrant = useAuthStore((s) => s.can('editor.grant')); - const [connectionId, setConnectionId] = useState(initialDraft?.connectionId ?? ''); + const [localConnectionId, setLocalConnectionId] = useState(initialDraft?.connectionId ?? ''); + const connectionId = lockedConnectionId ?? localConnectionId; + const pickConnection = (id: string) => { + onConnectionChange?.(id); + if (lockedConnectionId === undefined) setLocalConnectionId(id); + }; const [principalName, setPrincipalName] = useState(initialDraft?.principalName ?? ''); const [principals, setPrincipals] = useState([]); const [privileges, setPrivileges] = useState([]); @@ -94,7 +101,7 @@ export const AccessPermissionPanel: React.FC<{ if (!initialDraft) return; if (initialDraft.connectionId !== connectionId) { ++loadToken.current; - setConnectionId(initialDraft.connectionId); + pickConnection(initialDraft.connectionId); setPrincipals([]); setPrivileges([]); setHint(undefined); @@ -199,7 +206,11 @@ export const AccessPermissionPanel: React.FC<{
    -
    ); diff --git a/apps/web/src/frontend/features/access/components/PermissionDiff.tsx b/apps/web/src/frontend/features/access/components/PermissionDiff.tsx index 48d0adf1..5fb2785f 100644 --- a/apps/web/src/frontend/features/access/components/PermissionDiff.tsx +++ b/apps/web/src/frontend/features/access/components/PermissionDiff.tsx @@ -61,11 +61,19 @@ function emptyRequest(scopeType: AccessScope['type'] = 'schema'): PermissionRequ * Phase D — compare desired grants against the live catalog and generate * reconciliation SQL. Generate-only; Fox Schema never applies changes. */ -export const PermissionDiff: React.FC = () => { +export const PermissionDiff: React.FC<{ + lockedConnectionId?: string; + onConnectionChange?: (id: string) => void; +}> = ({ lockedConnectionId, onConnectionChange }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); - const [connectionId, setConnectionId] = useState(''); + const [localConnectionId, setLocalConnectionId] = useState(''); + const connectionId = lockedConnectionId ?? localConnectionId; + const pickConnection = (id: string) => { + onConnectionChange?.(id); + if (lockedConnectionId === undefined) setLocalConnectionId(id); + }; const conn = connections.find((c) => c.id === connectionId) || null; const dialect = conn?.dialect ?? ''; @@ -229,11 +237,12 @@ export const PermissionDiff: React.FC = () => {

    +
    - + + +
    {accessBlockedBy ? (
    void; -}> = ({ onGrantAccess }) => { + lockedConnectionId?: string; + onConnectionChange?: (id: string) => void; +}> = ({ onGrantAccess, lockedConnectionId, onConnectionChange }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); - const [connectionId, setConnectionId] = useState(''); + const [localConnectionId, setLocalConnectionId] = useState(''); + const connectionId = lockedConnectionId ?? localConnectionId; + const pickConnection = (id: string) => { + onConnectionChange?.(id); + if (lockedConnectionId === undefined) setLocalConnectionId(id); + }; const conn = connections.find((c) => c.id === connectionId) || null; const accessCatalog = useAccessCatalog(connectionId, conn); const dialect = conn?.dialect ?? ''; @@ -785,12 +792,13 @@ export const UserManagement: React.FC<{ {/* ── Left: connection + list + action form ─────────────────── */}
    +
    + {lockedConnectionId === undefined && ( + )}
    +
    + {lockedConnectionId !== undefined && ( +
    + +
    + )} {!connectionId && ( Date: Tue, 8 Sep 2026 01:22:48 +0000 Subject: [PATCH 11/17] feat(sql): add a Runs drawer for recent queries Recent runs sit on the SQL toolbar as a drawer instead of only inside the Bookmarks sidebar tab. Co-authored-by: huy.phan9 --- .../sql-editor/components/SqlEditorView.tsx | 18 ++++ .../components/SqlRunsDrawer.test.tsx | 36 +++++++ .../sql-editor/components/SqlRunsDrawer.tsx | 100 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 apps/web/src/frontend/features/sql-editor/components/SqlRunsDrawer.test.tsx create mode 100644 apps/web/src/frontend/features/sql-editor/components/SqlRunsDrawer.tsx diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index 35edb710..68cb3f9c 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -17,6 +17,7 @@ import { PanelLeftClose, PanelLeftOpen, Plus, + History, } from 'lucide-react'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; @@ -48,6 +49,7 @@ import { type SidebarSectionId, } from './SqlSidebarSection'; import { WriteConfirmDialog } from './WriteConfirmDialog'; +import { SqlRunsDrawer } from './SqlRunsDrawer'; import type { RevealRequest } from './SqlEditorPane'; const SqlEditorPane = lazy(() => import('./SqlEditorPane')); @@ -154,6 +156,7 @@ export const SqlEditorView: React.FC = () => { const secretsPanelRef = useRef(null); const schemaExplorerRef = useRef(null); const [secretsRefreshing, setSecretsRefreshing] = useState(false); + const [runsOpen, setRunsOpen] = useState(false); const onSecretsRefresh = useCallback(async () => { setSecretsRefreshing(true); @@ -733,6 +736,20 @@ export const SqlEditorView: React.FC = () => { > Bookmark +
    + setRunsOpen(false)} /> {pendingWriteConfirm && pendingWriteConfirm.tabId === tab.id && ( + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SqlRunsDrawer } from './SqlRunsDrawer'; + +const openRecentQuery = vi.fn(); +const clearRecentQueries = vi.fn(); + +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: Record) => unknown) => + sel({ + recentQueries: [{ id: 'r1', sql: 'SELECT 1', title: 'Ping', ranAt: Date.now() }], + openRecentQuery, + clearRecentQueries, + }), +})); + +describe('SqlRunsDrawer', () => { + beforeEach(() => { + openRecentQuery.mockReset(); + clearRecentQueries.mockReset(); + }); + + it('reopens a recent run and closes', () => { + const onClose = vi.fn(); + render(); + expect(screen.getByTestId('sql-runs-drawer')).toBeTruthy(); + fireEvent.click(screen.getByTestId('sql-runs-open-r1')); + expect(openRecentQuery).toHaveBeenCalledWith('r1'); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlRunsDrawer.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlRunsDrawer.tsx new file mode 100644 index 00000000..b639a6bf --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/SqlRunsDrawer.tsx @@ -0,0 +1,100 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * SQL Editor Runs drawer — recent queries without burying them in Bookmarks. + */ +import React from 'react'; +import { History, X } from 'lucide-react'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { formatRelativeDay } from '@/features/sql-editor/lib/relativeTime'; +import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; + +function previewSql(sql: string): string { + const line = sql.trim().split('\n')[0] ?? ''; + return line.length > 72 ? `${line.slice(0, 71)}…` : line; +} + +export const SqlRunsDrawer: React.FC<{ + open: boolean; + onClose: () => void; +}> = ({ open, onClose }) => { + const recentQueries = useSqlEditorStore((s) => s.recentQueries); + const openRecentQuery = useSqlEditorStore((s) => s.openRecentQuery); + const clearRecentQueries = useSqlEditorStore((s) => s.clearRecentQueries); + + if (!open) return null; + + return ( + + ); +}; From e9dc0c520b9e102468b0a9038b6265ec24f27a5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 02:27:11 +0000 Subject: [PATCH 12/17] feat(ui): land Access on Principals matching the proposal mockups Access opens on the principals sidebar with Account | Grants | Effective. Users/Roles filter, Add user, and counts match the Access chrome designs; User Management and Diff stay as secondary tabs for e2e. Co-authored-by: huy.phan9 --- .../components/AccessPermissionPanel.tsx | 299 ++++++++++++------ .../access/components/AccessView.test.tsx | 62 ++-- .../features/access/components/AccessView.tsx | 31 +- 3 files changed, 262 insertions(+), 130 deletions(-) diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx index d5aedd2a..aab5e81d 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -17,6 +17,7 @@ import { Copy, FileCode2, Loader2, + Plus, RefreshCw, } from 'lucide-react'; import { @@ -39,6 +40,7 @@ import { PermissionInspector } from './PermissionInspector'; import type { AccessPrincipalDraft } from '../lib/access-draft'; type PermissionStage = 'account' | 'grants' | 'effective'; +type KindFilter = 'all' | 'user' | 'role'; const KIND_GROUPS: { kind: DbPrincipal['kind']; label: string }[] = [ { kind: 'user', label: 'Users' }, @@ -50,7 +52,8 @@ export const AccessPermissionPanel: React.FC<{ initialDraft?: AccessPrincipalDraft | null; lockedConnectionId?: string; onConnectionChange?: (id: string) => void; -}> = ({ initialDraft = null, lockedConnectionId, onConnectionChange }) => { + onAddUser?: () => void; +}> = ({ initialDraft = null, lockedConnectionId, onConnectionChange, onAddUser }) => { const connections = useSyncStore((s) => s.connections); const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); const setSql = useSqlEditorStore((s) => s.setSql); @@ -75,6 +78,7 @@ export const AccessPermissionPanel: React.FC<{ const [copied, setCopied] = useState(false); const [stage, setStage] = useState('grants'); const [filter, setFilter] = useState(''); + const [kindFilter, setKindFilter] = useState('all'); const [expandedKinds, setExpandedKinds] = useState>( () => new Set(['user', 'role', 'group']) ); @@ -177,12 +181,23 @@ export const AccessPermissionPanel: React.FC<{ setConfirm(null); }; + const userCount = useMemo( + () => principals.filter((p) => p.kind === 'user').length, + [principals] + ); + const roleCount = useMemo( + () => principals.filter((p) => p.kind === 'role' || p.kind === 'group').length, + [principals] + ); + const grouped = useMemo(() => { const needle = filter.trim().toLowerCase(); return KIND_GROUPS.map((group) => ({ ...group, principals: principals.filter((p) => { if (p.kind !== group.kind) return false; + if (kindFilter === 'user' && p.kind !== 'user') return false; + if (kindFilter === 'role' && p.kind === 'user') return false; if (!needle) return true; return ( p.name.toLowerCase().includes(needle) || @@ -190,63 +205,63 @@ export const AccessPermissionPanel: React.FC<{ p.members.some((m) => m.toLowerCase().includes(needle)) ); }), - allOfKind: principals.filter((p) => p.kind === group.kind), + allOfKind: principals.filter((p) => { + if (p.kind !== group.kind) return false; + if (kindFilter === 'user' && p.kind !== 'user') return false; + if (kindFilter === 'role' && p.kind === 'user') return false; + return true; + }), })).filter((g) => g.allOfKind.length > 0); - }, [principals, filter]); + }, [principals, filter, kindFilter]); return (
    -
    -
    -

    Permission

    -

    - One catalog, three stages. Access generates GRANT/REVOKE SQL — copy it or open the - SQL Editor. Execution stays in Database Access. -

    -
    - -
    -
    + + {connections.map((c) => ( + + ))} + + + + {hint && ( +

    + {hint} +

    + )}
    {!connectionId && ( @@ -272,15 +287,45 @@ export const AccessPermissionPanel: React.FC<{ {connectionId && !error && (
    -