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/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..8f32af6a 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -46,6 +46,13 @@ export class SqlEditorPage { await this.dismissOverlays(); } + /** Open the Database utilities workspace (not the SQL Editor sidebar). */ + async openUtilitiesView(): Promise { + await this.dismissOverlays(); + await clickWhen(this.page, '[data-testid="view-utilities-btn"]'); + await waitFor(this.page, '[data-testid="utilities-view"]', 10_000); + } + async isEditorVisible(): Promise { return this.page.locator('[data-testid="sql-editor-view"]').isVisible(); } @@ -430,6 +437,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 @@ -439,30 +447,17 @@ export class SqlEditorPage { }); } - /** Ensure a sidebar section is expanded (Destinations / Utilities / Schema / …). */ + /** Ensure a sidebar section is expanded (Destinations / Schema / …). + * `utilities` / `files` open the Utilities workspace. */ async ensureSidebarSectionOpen(id: string): Promise { await this.dismissOverlays(); + if (id === 'utilities' || id === 'files') { + await this.openUtilitiesView(); + return; + } const section = this.page.locator(`[data-testid="sql-sidebar-${id}"]`); await section.waitFor({ state: 'visible', timeout: 10_000 }); const toggle = this.page.locator(`[data-testid="sql-sidebar-toggle-${id}"]`); - // Content for utilities is the Index Management button. - const openProbe = - id === 'utilities' - ? section.locator('[data-testid="utilities-index-management"]') - : section.locator(`[data-testid="sql-sidebar-${id}"] >> visible=true`); - if (id === 'utilities') { - if (await section.locator('[data-testid="utilities-index-management"]').isVisible().catch(() => false)) { - return; - } - await toggle.click(); - await section.locator('[data-testid="utilities-index-management"]').waitFor({ - state: 'visible', - timeout: 5_000, - }); - return; - } - void openProbe; - // Generic: if toggle says collapsed, click once. const aria = await toggle.getAttribute('aria-expanded').catch(() => null); if (aria === 'false') await toggle.click(); } @@ -496,7 +491,7 @@ export class SqlEditorPage { } async openIndexManagement(): Promise { - await this.ensureSidebarSectionOpen('utilities'); + await this.openUtilitiesView(); await clickWhen(this.page, '[data-testid="utilities-index-management"]'); await waitFor(this.page, '[data-testid="index-management-modal"]', 15_000); } @@ -504,7 +499,7 @@ export class SqlEditorPage { async openServerInsights( tab: 'pool' | 'sessions' | 'system' | 'sizes' = 'system' ): Promise { - await this.ensureSidebarSectionOpen('utilities'); + await this.openUtilitiesView(); const testId = tab === 'pool' ? 'utilities-connection-pool' @@ -521,7 +516,9 @@ export class SqlEditorPage { async closeServerInsights(): Promise { const modal = this.page.locator('[data-testid="server-insights-modal"]'); if (!(await modal.isVisible().catch(() => false))) return; - await modal.locator('button[aria-label="Close"]').click().catch(async () => { + const closeBtn = modal.locator('button[aria-label="Close"]'); + if (!(await closeBtn.isVisible().catch(() => false))) return; + await closeBtn.click().catch(async () => { await this.page.keyboard.press('Escape'); }); await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); @@ -529,7 +526,7 @@ export class SqlEditorPage { async openDatabaseAccess(): Promise { await this.dismissOverlays(); - await this.ensureSidebarSectionOpen('utilities'); + await this.openUtilitiesView(); await clickWhen(this.page, '[data-testid="utilities-database-access"]'); await waitFor(this.page, '[data-testid="db-access-modal"]', 15_000); } @@ -537,22 +534,26 @@ export class SqlEditorPage { async closeDatabaseAccess(): Promise { const modal = this.page.locator('[data-testid="db-access-modal"]'); if (!(await modal.isVisible().catch(() => false))) return; - // The testid is on the backdrop, and the backdrop's own click closes it. - // Going for the header's Close button matches more than one element in this - // subtree, and a strict-mode error there leaves the modal open — which then - // covers the sidebar for every dialect queued behind this one. - await modal.click({ position: { x: 4, y: 4 } }).catch(() => undefined); - await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); - if (await modal.isVisible().catch(() => false)) { - await this.page.reload(); - await this.page.waitForSelector('[data-testid="toolbar"]', { timeout: 30_000 }); + const closeBtn = modal.locator('button[aria-label="Close"]'); + if (await closeBtn.isVisible().catch(() => false)) { + await closeBtn.click().catch(() => undefined); + await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); + return; + } + // Overlay (legacy): backdrop click. Workspace pane: leave it docked. + const box = await modal.boundingBox().catch(() => null); + if (box && box.x <= 8 && box.y <= 8) { + await modal.click({ position: { x: 4, y: 4 } }).catch(() => undefined); + await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); } } async closeIndexManagement(): Promise { const modal = this.page.locator('[data-testid="index-management-modal"]'); if (!(await modal.isVisible().catch(() => false))) return; - await modal.locator('button[aria-label="Close"]').click().catch(async () => { + const closeBtn = modal.locator('button[aria-label="Close"]'); + if (!(await closeBtn.isVisible().catch(() => false))) return; + await closeBtn.click().catch(async () => { await modal.click({ position: { x: 4, y: 4 } }); }); await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); @@ -560,7 +561,7 @@ export class SqlEditorPage { async openCloneTable(): Promise { await this.dismissOverlays(); - await this.ensureSidebarSectionOpen('utilities'); + await this.openUtilitiesView(); await clickWhen(this.page, '[data-testid="utilities-clone-table"]'); await waitFor(this.page, '[data-testid="clone-table-modal"]', 15_000); } @@ -568,15 +569,22 @@ export class SqlEditorPage { async closeCloneTable(): Promise { const modal = this.page.locator('[data-testid="clone-table-modal"]'); if (!(await modal.isVisible().catch(() => false))) return; - await modal.locator('button[aria-label="Close"]').click().catch(async () => { + const closeBtn = modal.locator('button[aria-label="Close"]'); + if (!(await closeBtn.isVisible().catch(() => false))) return; + await closeBtn.click().catch(async () => { await modal.getByRole('button', { name: /^close$/i }).click(); }); await modal.waitFor({ state: 'detached', timeout: 8_000 }).catch(() => undefined); } - /** Pick credential in Clone Table / Index Management by visible name substring. */ + /** Pick credential in Clone Table / Index Management by visible name substring. + * The Utilities workspace uses one chip (`utilities-connection`); tool-local + * dropdowns are used when a modal still has its own picker. */ async selectUtilityConnection(nameSubstring: string, selectTestId: string): Promise { - const select = this.page.locator(`[data-testid="${selectTestId}"]`); + const workspace = this.page.locator('[data-testid="utilities-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/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/e2e/src/tests/sql-editor-utilities-dialects.test.ts b/apps/e2e/src/tests/sql-editor-utilities-dialects.test.ts index 64b35e27..fc2fa0d0 100644 --- a/apps/e2e/src/tests/sql-editor-utilities-dialects.test.ts +++ b/apps/e2e/src/tests/sql-editor-utilities-dialects.test.ts @@ -121,13 +121,6 @@ describe.skipIf(configured.length === 0)('SQL Editor · Utilities (all configure await sql.closeIndexManagement().catch(() => undefined); await sql.closeDatabaseAccess().catch(() => undefined); await sql.closeServerInsights().catch(() => undefined); - const fq = driver.locator('[data-testid="file-query-modal"]'); - if (await fq.isVisible().catch(() => false)) { - await fq.locator('button[aria-label="Close"]').click().catch(async () => { - await driver.keyboard.press('Escape'); - }); - await fq.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined); - } await sql.dismissOverlays().catch(() => undefined); }); @@ -135,7 +128,7 @@ describe.skipIf(configured.length === 0)('SQL Editor · Utilities (all configure if (driver) await quitDriver(driver); }); - it('Utilities sidebar lists every utility entry', async () => { + it('Utilities workspace lists every utility entry', async () => { await sql.ensureSidebarSectionOpen('utilities'); const section = driver.locator('[data-testid="sql-sidebar-utilities"]'); for (const id of UTILITY_BUTTONS) { @@ -317,15 +310,11 @@ describe.skipIf(configured.length === 0)('SQL Editor · Utilities (all configure }); } - it('Query files utility opens the import modal', async () => { + it('Query files utility opens the import pane', async () => { await sql.ensureSidebarSectionOpen('utilities'); await driver.locator('[data-testid="utilities-query-files"]').click(); await driver.waitForSelector('[data-testid="file-query-modal"]', { timeout: 10_000 }); expect(await driver.locator('[data-testid="file-query-format"]').isVisible()).toBe(true); await saveScreenshot(driver, 'utilities-query-files'); - await driver.locator('[data-testid="file-query-modal"] button[aria-label="Close"]').click(); - await driver - .locator('[data-testid="file-query-modal"]') - .waitFor({ state: 'detached', timeout: 8_000 }); }); }); diff --git a/apps/e2e/src/tests/sql-editor-utilities.test.ts b/apps/e2e/src/tests/sql-editor-utilities.test.ts index b5d9628e..8339c35b 100644 --- a/apps/e2e/src/tests/sql-editor-utilities.test.ts +++ b/apps/e2e/src/tests/sql-editor-utilities.test.ts @@ -62,7 +62,7 @@ INSERT INTO orders (id, customer_id, note) VALUES const ready = hasSqlite3(); -describe.skipIf(!ready)('SQL Editor · Utilities + Clone Table (SQLite)', () => { +describe.skipIf(!ready)('Utilities workspace + Clone Table (SQLite)', () => { let driver: Page; let app: AppPage; let sql: SqlEditorPage; @@ -107,7 +107,7 @@ describe.skipIf(!ready)('SQL Editor · Utilities + Clone Table (SQLite)', () => rmSync(DIR, { recursive: true, force: true }); }); - it('sidebar Utilities lists Index Management and Clone Table', async () => { + it('Utilities workspace lists Index Management and Clone Table', async () => { await sql.ensureSidebarSectionOpen('utilities'); const utilities = driver.locator('[data-testid="sql-sidebar-utilities"]'); expect(await utilities.locator('[data-testid="utilities-index-management"]').isVisible()).toBe( diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index d8e362b0..94e565b2 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'; @@ -13,6 +14,8 @@ import { apiGetPreferences } from '@/shared/api/authApi'; import { ToastHost } from '@/app/shell/ToastHost'; import { AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react'; import { BackendOfflineBanner } from '@/app/shell/BackendOfflineBanner'; +import { HomeView } from '@/app/shell/HomeView'; +import { CommandPalette } from '@/app/shell/CommandPalette'; const AccessView = lazy(() => import('@/features/access').then((m) => ({ default: m.AccessView })) @@ -20,22 +23,34 @@ const AccessView = lazy(() => const SqlEditorView = lazy(() => import('@/features/sql-editor').then((m) => ({ default: m.SqlEditorView })) ); +const UtilitiesView = lazy(() => + import('@/features/utilities').then((m) => ({ default: m.UtilitiesView })) +); const LokeeWeaveView = lazy(() => import('@/features/lokee-weave').then((m) => ({ default: m.LokeeWeaveView })) ); +const SettingsPanel = lazy(() => + import('@/app/settings/SettingsPanel').then((m) => ({ default: m.SettingsPanel })) +); 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')); const canSchemaCompare = useAuthStore((s) => s.can('schema.compare')); + const canUtilityAccess = useAuthStore((s) => s.can('utility.access')); useEffect(() => { if (activeView === 'sqlEditor' && !canEditorAccess) { - setActiveView('sync'); + setActiveView(canSchemaBrowse || canSchemaCompare ? 'sync' : 'home'); + } + if (activeView === 'utilities' && !canUtilityAccess) { + setActiveView('home'); + } + if (activeView === 'snapshots' && !canSchemaBrowse) { + setActiveView('home'); } if ( activeView === 'sync' && @@ -50,11 +65,14 @@ const Workspace: React.FC = () => { canEditorAccess, canSchemaBrowse, canSchemaCompare, + canUtilityAccess, setActiveView, ]); return ( -
+
+ +
{/* Above every other banner: when the backend is gone, nothing else on @@ -88,7 +106,15 @@ const Workspace: React.FC = () => { )}
- {activeView === 'access' ? ( + {activeView === 'home' ? ( + + ) : activeView === 'settings' ? ( + + }> + + + + ) : activeView === 'access' ? ( }> @@ -100,7 +126,13 @@ const Workspace: React.FC = () => { - ) : syncPane === 'history' && canSchemaBrowse ? ( + ) : activeView === 'utilities' && canUtilityAccess ? ( + + }> + + + + ) : activeView === 'snapshots' && canSchemaBrowse ? ( }>
@@ -120,6 +152,8 @@ const Workspace: React.FC = () => { )}
+ +
); }; diff --git a/apps/web/src/frontend/app/settings/SettingsPanel.test.tsx b/apps/web/src/frontend/app/settings/SettingsPanel.test.tsx new file mode 100644 index 00000000..74e81ded --- /dev/null +++ b/apps/web/src/frontend/app/settings/SettingsPanel.test.tsx @@ -0,0 +1,49 @@ +/** + * 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 { SettingsPanel } from './SettingsPanel'; + +vi.mock('@/features/auth', () => ({ + fetchAppInfo: vi.fn(async () => ({ + db: { engine: 'sqlite', location: '/tmp/foxschema.db' }, + security: { keyScheme: 'os', emailBound: false, boundEmail: '' }, + })), +})); + +vi.mock('@/app/shell/updateToast', () => ({ + runSelfUpdate: vi.fn(), + toastUpdateCheckResult: vi.fn(), +})); + +vi.mock('@/shared/api/updatesApi', () => ({ + checkForUpdates: vi.fn(async () => ({ + current: '0.2.0', + latest: '0.2.0', + updateAvailable: false, + })), +})); + +describe('SettingsPanel', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('opens as a Preferences workspace with tabs', () => { + render(); + expect(screen.getByTestId('settings-workspace')).toBeTruthy(); + expect(screen.getByTestId('settings-view').textContent).toMatch(/Preferences/); + expect(screen.getByTestId('settings-tab-appearance').getAttribute('aria-current')).toBe( + 'page' + ); + fireEvent.click(screen.getByTestId('settings-tab-database')); + expect(screen.getByTestId('settings-tab-database').getAttribute('aria-current')).toBe('page'); + fireEvent.click(screen.getByTestId('settings-tab-updates')); + expect(screen.getByTestId('settings-tab-updates').getAttribute('aria-current')).toBe('page'); + fireEvent.click(screen.getByTestId('settings-tab-security')); + expect(screen.getByTestId('settings-tab-security').getAttribute('aria-current')).toBe('page'); + }); +}); diff --git a/apps/web/src/frontend/app/settings/SettingsPanel.tsx b/apps/web/src/frontend/app/settings/SettingsPanel.tsx index 5c7427cd..c803bfca 100644 --- a/apps/web/src/frontend/app/settings/SettingsPanel.tsx +++ b/apps/web/src/frontend/app/settings/SettingsPanel.tsx @@ -1,29 +1,76 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Preferences: appearance, app database, updates, and security. + * Rail workspace by default; Profile can still open the same panel as a modal. + */ import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { X, Sun, Moon, Monitor, Palette, Type, RotateCcw, ShieldCheck, Database, ArrowUpCircle, Sparkles } from 'lucide-react'; -import { useUiStore, ACCENTS, TONES, FONT_SIZES, THEME_PRESETS, type ThemeMode, type AccentId, type ThemePreset } from '@/app/store/uiStore'; +import { + X, + Sun, + Moon, + Monitor, + Palette, + Type, + RotateCcw, + ShieldCheck, + Database, + ArrowUpCircle, + Sparkles, + Settings, +} from 'lucide-react'; +import { + useUiStore, + ACCENTS, + TONES, + FONT_SIZES, + THEME_PRESETS, + type ThemeMode, + type AccentId, + type ThemePreset, +} from '@/app/store/uiStore'; import { fetchAppInfo, type AppInfo } from '@/features/auth'; import { DatabaseSettings } from '@/features/connections'; import { EmailSettings } from './EmailSettings'; import { UpdatesSettings } from './UpdatesSettings'; interface Props { - open: boolean; - onClose: () => void; + open?: boolean; + onClose?: () => void; + embedded?: boolean; } +type SettingsTab = 'appearance' | 'database' | 'updates' | 'security'; + const MODES: { id: ThemeMode; label: string; icon: React.ReactNode }[] = [ { id: 'light', label: 'Light', icon: }, { id: 'dark', label: 'Dark', icon: }, { id: 'system', label: 'System', icon: }, ]; -// Representative mid-shade per neutral family, for the tone swatches. +const TABS: { id: SettingsTab; label: string }[] = [ + { id: 'appearance', label: 'Appearance' }, + { id: 'database', label: 'Database' }, + { id: 'updates', label: 'Updates' }, + { id: 'security', label: 'Security' }, +]; + const TONE_SWATCH: Record = { - slate: '#64748b', gray: '#6b7280', zinc: '#71717a', stone: '#78716c', neutral: '#737373', + slate: '#64748b', + gray: '#6b7280', + zinc: '#71717a', + stone: '#78716c', + neutral: '#737373', }; -const Section: React.FC<{ icon: React.ReactNode; title: string; children: React.ReactNode }> = ({ icon, title, children }) => ( +const Section: React.FC<{ icon: React.ReactNode; title: string; children: React.ReactNode }> = ({ + icon, + title, + children, +}) => (

{icon} {title} @@ -32,14 +79,26 @@ const Section: React.FC<{ icon: React.ReactNode; title: string; children: React.

); -/** Appearance settings: theme mode, UI tone, accent, and text size. Each change applies live. */ -export const SettingsPanel: React.FC = ({ open, onClose }) => { - const { themeMode, tone, fontSize, accent, setThemeMode, setTone, setFontSize, setAccent, applyPreset, resetAppearance } = useUiStore(); +/** Preferences: theme, app database, updates, and encryption binding. Changes apply live. */ +export const SettingsPanel: React.FC = ({ open = true, onClose, embedded = false }) => { + const { + themeMode, + tone, + fontSize, + accent, + setThemeMode, + setTone, + setFontSize, + setAccent, + applyPreset, + resetAppearance, + } = useUiStore(); const presetActive = (p: ThemePreset) => themeMode === p.mode && tone === p.tone && accent === p.accent; - // Hooks must stay above the early return (rules-of-hooks). const [info, setInfo] = useState(null); + const [tab, setTab] = useState('appearance'); + useEffect(() => { - if (!open) return; + if (!embedded && !open) return; let alive = true; fetchAppInfo() .then((i) => alive && setInfo(i)) @@ -47,174 +106,248 @@ export const SettingsPanel: React.FC = ({ open, onClose }) => { return () => { alive = false; }; - }, [open]); - if (!open) return null; + }, [open, embedded]); + + if (!embedded && !open) return null; const optionBtn = (active: boolean) => `transition cursor-pointer border ${ - active ? 'bg-slate-800 border-cyan-500/40 text-slate-100' : 'bg-slate-950/40 border-slate-800 text-slate-400 hover:border-slate-700' + active + ? 'bg-slate-800 border-cyan-500/40 text-slate-100' + : 'bg-slate-950/40 border-slate-800 text-slate-400 hover:border-slate-700' }`; - return createPortal( -
-
e.stopPropagation()} - > -
-
- -
-

Appearance

-

Personalize the whole interface · changes apply instantly

-
-
- -
- -
-
} title="Theme Presets"> -
- {THEME_PRESETS.map((p) => { - const active = presetActive(p); - const dark = p.mode === 'dark'; - return ( - - ); - })} -
-
- -
} title="Background"> -
- {MODES.map((m) => ( - - ))} -
-
- -
} title="UI Tone"> -
- {TONES.map((t) => ( - - ))} -
-
- -
} title="Accent"> -
- {(Object.keys(ACCENTS) as AccentId[]).map((id) => ( -
-
+
+
+
+
+
+
+ {dark ? : } + {p.label} +
+
+ {p.tone} · {ACCENTS[p.accent].label} +
+
+ + ); + })} +
+ -
} 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 new file mode 100644 index 00000000..1230ea83 --- /dev/null +++ b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx @@ -0,0 +1,42 @@ +/** + * 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-utilities-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'); + 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 new file mode 100644 index 00000000..26849cf2 --- /dev/null +++ b/apps/web/src/frontend/app/shell/ActivityRail.tsx @@ -0,0 +1,137 @@ +/** + * 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, 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'; + +const ITEMS: { + view: ActiveView; + testId: string; + label: string; + icon: React.ElementType; + permission: 'schema' | 'editor' | 'utilities' | '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: 'utilities', + testId: 'view-utilities-btn', + label: 'Utils', + icon: Wrench, + permission: 'utilities', + }, + { + 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 canUtilityAccess = useAuthStore((s) => s.can('utility.access')); + + const allowed = (permission: (typeof ITEMS)[number]['permission']): boolean => { + if (permission === 'schema') return canSchemaBrowse || canSchemaCompare; + if (permission === 'editor') return canEditorAccess; + if (permission === 'utilities') return canUtilityAccess; + if (permission === 'snapshots') return canSchemaBrowse; + return true; + }; + + const visible = ITEMS.filter((item) => allowed(item.permission)); + + 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..2ec0c440 --- /dev/null +++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx @@ -0,0 +1,97 @@ +/** + * 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(); + }); + + it('opens from the custom event the TopBar button fires', () => { + render(); + fireEvent(window, new Event('foxschema-command-palette')); + expect(screen.getByTestId('command-palette')).toBeTruthy(); + }); + + it('jumps to the Utilities workspace', () => { + render(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + 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 new file mode 100644 index 00000000..9f97ea4c --- /dev/null +++ b/apps/web/src/frontend/app/shell/CommandPalette.tsx @@ -0,0 +1,234 @@ +/** + * 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'; +import { COMMAND_PALETTE_EVENT } from './commandPaletteEvent'; + +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 canUtilityAccess = useAuthStore((s) => s.can('utility.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'), + }); + } + if (canUtilityAccess) { + out.push({ + id: 'ws-utilities', + group: 'Workspace', + label: 'Utilities', + run: () => go('utilities'), + }); + } + 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'), + }); + } + out.push({ + id: 'ws-settings', + group: 'Workspace', + label: 'Preferences', + run: () => go('settings'), + }); + 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, + canUtilityAccess, + 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(''); + } + }; + const onOpen = () => { + setOpen(true); + setQuery(''); + }; + window.addEventListener('keydown', onKey, true); + window.addEventListener(COMMAND_PALETTE_EVENT, onOpen); + return () => { + window.removeEventListener('keydown', onKey, true); + window.removeEventListener(COMMAND_PALETTE_EVENT, onOpen); + }; + }, []); + + 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/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 new file mode 100644 index 00000000..8378a22d --- /dev/null +++ b/apps/web/src/frontend/app/shell/HomeView.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 { 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' }], + compareResult: null, + sourceConfig: { option: {} }, + targetConfig: { option: {} }, + }), +})); + +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(); + }); + + it('continues into Snapshots from the home cards', () => { + render(); + fireEvent.click(screen.getByTestId('home-continue-snapshots')); + expect(setActiveView).toHaveBeenCalledWith('snapshots'); + }); + + it('opens the Utilities workspace from Home', () => { + render(); + fireEvent.click(screen.getByTestId('home-continue-utilities')); + expect(setActiveView).toHaveBeenCalledWith('utilities'); + }); +}); 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..fd30315f --- /dev/null +++ b/apps/web/src/frontend/app/shell/HomeView.tsx @@ -0,0 +1,202 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Home: continue last work, recents, and saved connections from Zustand only — + * no schema introspect. + */ +import React from 'react'; +import { Camera, Database, GitCompareArrows, Search, Terminal, Wrench } 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 './commandPaletteEvent'; +import { diffBriefing } from '@/features/schema-diff'; + +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 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); + + const openRecent = (id: string) => { + openRecentQuery(id); + setActiveView('sqlEditor'); + }; + + const openConnection = (id: string) => { + ensureConnectionSelected(id); + setActiveView('sqlEditor'); + }; + + const briefing = diffBriefing(compareResult?.tables); + const lastQuery = recentQueries[0]; + + return ( +
+
+
+

Home

+

+ Continue where you left off. Opening a recent or connection 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/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..2e48535d 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -1,21 +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); @@ -103,15 +99,17 @@ export function ProfileMenu(): React.ReactElement | null { + {canAdminAccess && ( + )} - setShowSettings(false)} /> - - )} setShowAdmin(false)} />
); diff --git a/apps/web/src/frontend/app/shell/TopToolbar.tsx b/apps/web/src/frontend/app/shell/TopToolbar.tsx index c62710c3..89408469 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, GitCompareArrows, Terminal, Camera, ShieldCheck } 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'; @@ -21,15 +20,28 @@ import { getSessionPassword, setSessionPassword } from '@/shared/lib/sessionPass import { HistoryCompareBar } from '@/features/lokee-weave'; 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 './commandPaletteEvent'; 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, @@ -46,7 +58,6 @@ export const TopToolbar: React.FC = () => { toggleTypeFilter, clearTypeFilter, showConnectionModal, - setShowConnectionModal, addConnection, connections, selectedSourceConnectionId, @@ -59,7 +70,7 @@ export const TopToolbar: React.FC = () => { 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')); @@ -70,29 +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); - 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. 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) || @@ -184,6 +170,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,444 +185,240 @@ 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. */} -
- - -
- {/* Only renders while something is actually running. */} - - - - {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 && ( +
+ )} + + {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 && ( + )} + + {activeView === 'snapshots' && ( +
+ +
+ )} + +
+ -
- )} - {/* 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' && ( -
- {/* 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'} - - + {compareResult && activeView === 'sync' && syncPane === 'compare' && ( - - {isTestingSource ? ( - - Connecting... - - ) : sourceConnected ? ( - - ) : ( - - )} + )} +
+
+
- {/* Direction / Swap control — migration always flows Original Server → Target */} -
- -
- - {/* Target Configuration */} -
-
- Target + {objectScopeOptions.map((opt) => { + const active = selectedObjectTypes.includes(opt.type); + return ( + + ); + })}
- {/* 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'} - - - - - {isTestingTarget ? ( - - Connecting... + {compareResult && ( +
+ + Viewing - ) : 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: - -
- {objectScopeOptions.map((opt) => { - const active = selectedObjectTypes.includes(opt.type); + {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 - - )} - - -
-
- )} - )} { 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/commandPaletteEvent.ts b/apps/web/src/frontend/app/shell/commandPaletteEvent.ts new file mode 100644 index 00000000..04bab5b3 --- /dev/null +++ b/apps/web/src/frontend/app/shell/commandPaletteEvent.ts @@ -0,0 +1,21 @@ +/** + * 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. + * + * Named `commandPaletteEvent`, not `commandPalette`, because a sibling module + * is `CommandPalette.tsx`. On a case-insensitive filesystem — macOS, which is + * the usual dev machine here — `./commandPalette` and `./CommandPalette` + * resolve to the same path, and the resolver picked this file for both: the + * component came back `undefined` and every test rendering it failed with + * "Element type is invalid". Linux CI resolves exactly and stayed green, so + * the breakage only ever showed up locally. + */ +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 new file mode 100644 index 00000000..2bd90d2e --- /dev/null +++ b/apps/web/src/frontend/app/store/uiStore.test.ts @@ -0,0 +1,69 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * 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('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' }, 3) as { + activeView: string; + syncPane: string; + }; + expect(compare).toMatchObject({ activeView: 'sync', syncPane: 'compare' }); + 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'); + }); + + it('keeps the Utilities workspace when already persisted', () => { + const next = migrateUiPersist({ activeView: 'utilities', syncPane: 'compare' }, 3) as { + activeView: string; + }; + 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 6140dc93..0d982f86 100644 --- a/apps/web/src/frontend/app/store/uiStore.ts +++ b/apps/web/src/frontend/app/store/uiStore.ts @@ -175,17 +175,27 @@ 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' + | '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 * 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 +212,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 +241,24 @@ 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') { - state.activeView = 'sync'; - state.syncPane = 'history'; + if (state.activeView === 'lokeeWeave' || state.syncPane === 'history') { + state.activeView = 'snapshots'; + state.syncPane = 'compare'; + } + if ( + !['sync', 'sqlEditor', 'access', 'snapshots', 'home', 'utilities', 'settings'].includes( + state.activeView as string + ) + ) { + state.activeView = 'home'; + } + if (_version < 3 && state.activeView === 'sync') { + state.activeView = 'home'; } - 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; @@ -260,12 +281,18 @@ export const useUiStore = create()( return { ...DEFAULTS, resolvedMode: 'dark', - activeView: 'sync' as ActiveView, + activeView: 'home' as ActiveView, syncPane: 'compare' as SyncPane, 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 +324,7 @@ export const useUiStore = create()( }, }; }, - { name: 'schema-sync-ui', version: 1, migrate: migrateUiPersist } + { name: 'schema-sync-ui', version: 3, migrate: migrateUiPersist } ) ); 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/access/components/AccessGrantsStage.tsx b/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx new file mode 100644 index 00000000..d53fe116 --- /dev/null +++ b/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx @@ -0,0 +1,300 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Access → Grants stage: presets + object×privilege matrix + GRANT SQL. + * Generate-only — matches the Access mockup chrome. + */ +import React, { useMemo, useState } from 'react'; +import { Copy, FileCode2 } from 'lucide-react'; +import { + buildAccessReconciliationSql, + buildAccessSql, + diffAccessDesired, + permissionsForPreset, + type AccessPermission, + type AccessPreset, + type AccessDesiredState, + type DbPrivilege, + type PermissionRequest, +} from '../lib/access'; +import { useAllSchemaObjects } from '../lib/useAllSchemaObjects'; +import { PermissionMatrix } from './PermissionMatrix'; +import { Segmented } from './controls'; +import { + DbAccessPermissionSections, + type DbAccessConfirmRequest, +} from './DbAccessPermissionSections'; +import type { DbPrincipal } from '@foxschema/sql'; + +const PRESET_LABEL: Record, string> = { + 'read-only': 'Read only', + 'read-write': 'Read and write', + 'application-writer': 'Application writer', + 'procedure-executor': 'Execute procedures', + 'schema-developer': 'Manage schema', +}; + +const STATUS_STYLE: Record = { + match: 'text-emerald-300 bg-emerald-500/10 border-emerald-500/30', + missing: 'text-amber-200 bg-amber-500/10 border-amber-500/30', + extra: 'text-rose-200 bg-rose-500/10 border-rose-500/30', + denied: 'text-violet-200 bg-violet-500/10 border-violet-500/30', +}; + +type GrantsMode = 'matrix' | 'live'; + +export const AccessGrantsStage: React.FC<{ + dialect: string; + connectionId: string; + database?: string; + defaultSchema?: string; + principal: DbPrincipal; + privileges: DbPrivilege[]; + canGrant: boolean; + grantSupported: boolean; + onConfirm: (req: DbAccessConfirmRequest) => void; + onError: (msg: string) => void; +}> = ({ + dialect, + connectionId, + database, + defaultSchema, + principal, + privileges, + canGrant, + grantSupported, + onConfirm, + onError, +}) => { + const [mode, setMode] = useState('matrix'); + const [activePreset, setActivePreset] = useState('custom'); + const [gridPreset, setGridPreset] = useState<{ + permissions: AccessPermission[]; + nonce: number; + } | null>(null); + const [gridRequests, setGridRequests] = useState([]); + const catalog = useAllSchemaObjects(connectionId, mode === 'matrix'); + + const accessPrincipal = useMemo( + () => ({ + type: (principal.kind === 'user' ? 'user' : 'role') as 'user' | 'role', + name: principal.name, + }), + [principal] + ); + + const desired: AccessDesiredState = useMemo( + () => ({ + principal: accessPrincipal, + requests: gridRequests.map((r) => ({ + ...r, + principal: accessPrincipal, + })), + }), + [accessPrincipal, gridRequests] + ); + + const diff = useMemo(() => { + if (gridRequests.length === 0) return null; + return diffAccessDesired(desired, privileges); + }, [desired, privileges, gridRequests.length]); + + const sqlText = useMemo(() => { + if (gridRequests.length === 0) return ''; + if (diff && privileges.length > 0) { + const recon = buildAccessReconciliationSql(diff, dialect); + if (!('error' in recon) && recon.statements.length > 0) { + return recon.statements.map((s) => s.sql).join('\n\n'); + } + if ('error' in recon && /matches the catalog/i.test(recon.error)) { + return '-- Desired matrix matches the live catalog. Nothing to grant or revoke.'; + } + } + return desired.requests + .map((r) => { + const built = buildAccessSql(r, dialect); + if ('error' in built) return `-- ${built.error}`; + return built.statements.map((s) => s.sql).join('\n'); + }) + .filter(Boolean) + .join('\n\n'); + }, [dialect, desired.requests, diff, privileges.length, gridRequests.length]); + + const applyPreset = (preset: Exclude) => { + setActivePreset(preset); + setGridPreset((prev) => ({ + permissions: permissionsForPreset(preset), + nonce: (prev?.nonce ?? 0) + 1, + })); + }; + + const openSql = () => { + if (!sqlText.trim() || sqlText.startsWith('-- Desired matrix matches')) { + onError(sqlText || 'No GRANT SQL for this selection.'); + return; + } + if (sqlText.split('\n').every((line) => line.trim().startsWith('--') || !line.trim())) { + onError('No GRANT SQL for this selection.'); + return; + } + onConfirm({ + title: `GRANT SQL for ${principal.name}`, + sql: sqlText, + kind: 'grant', + }); + }; + + return ( +
+
+ setMode(v as GrantsMode)} + options={[ + { value: 'matrix', label: 'Desired matrix' }, + { value: 'live', label: 'Live catalog' }, + ]} + /> +

+ Fox Schema generates GRANT/REVOKE SQL — it does not apply it. +

+
+ + {mode === 'live' ? ( + + ) : ( + <> +
+ + Presets + + {(Object.keys(PRESET_LABEL) as Exclude[]).map((p) => ( + + ))} + +
+ + {catalog.loading && ( +

Reading schema objects…

+ )} + + { + setGridRequests(reqs); + setActivePreset('custom'); + }} + /> + + {diff && diff.entries.length > 0 && ( +
    + {diff.entries.slice(0, 12).map((e, i) => ( +
  • + {e.status} + {e.label} +
  • + ))} + {diff.entries.length > 12 && ( +
  • + +{diff.entries.length - 12} more +
  • + )} +
+ )} + +
+
+

+ Grant SQL +

+
+ + +
+
+
+              {sqlText.trim() || 'Tick objects and privileges to generate GRANT SQL.'}
+            
+

+ Fox Schema does not apply this SQL. The database stays the source of truth. +

+
+ + )} +
+ ); +}; 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..6b54589e --- /dev/null +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx @@ -0,0 +1,224 @@ +/** + * 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')); + // findBy, not getBy: the stage loads its own schema objects, so a + // synchronous read passed or failed on microtask timing — which is what + // made this file flaky on CI while passing in isolation. + expect(await screen.findByTestId('access-grants-stage')).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); + }); + + /** Select a principal and land on the Grants stage with its catalog loaded. */ + async function grantsStageFor(principal: string) { + render(); + fireEvent.change(screen.getByTestId('access-permission-connection'), { + target: { value: 'c1' }, + }); + fireEvent.click(await screen.findByTestId(`access-permission-row-${principal}`)); + fireEvent.click(screen.getByTestId('access-permission-stage-grants')); + await screen.findByTestId('access-grants-stage'); + } + + it('copies GRANT SQL instead of executing it', async () => { + await grantsStageFor('alice'); + + // Read-and-write, not read-only: alice already holds SELECT on + // public.orders, so the read-only preset matches the catalog and correctly + // generates nothing. The SQL only exists where the desired state differs. + fireEvent.click(await screen.findByTestId('access-grants-preset-read-write')); + const copy = (await screen.findByTestId('access-grants-copy')) as HTMLButtonElement; + // This project does not load jest-dom, so read the property directly. + await waitFor(() => expect(copy.disabled).toBe(false)); + fireEvent.click(copy); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringMatching(/GRANT/i)) + ); + // The guarantee the screen is built on: it writes SQL out, never runs it. + expect(runAccessSql).not.toHaveBeenCalled(); + }); + + it('opens generated SQL in the SQL Editor without executing', async () => { + await grantsStageFor('alice'); + + fireEvent.click(await screen.findByTestId('access-grants-preset-read-write')); + const open = (await screen.findByTestId('access-grants-open-sql')) as HTMLButtonElement; + await waitFor(() => expect(open.disabled).toBe(false)); + fireEvent.click(open); + + // Handing SQL to the editor goes through the panel's confirm step, so the + // reader sees what is about to land there. + fireEvent.click(await screen.findByTestId('access-permission-open-sql')); + + expect(setSql).toHaveBeenCalledWith(expect.stringMatching(/GRANT/i)); + expect(ensureConnectionSelected).toHaveBeenCalledWith('c1'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + expect(runAccessSql).not.toHaveBeenCalled(); + }); + + it('offers nothing to copy when the desired matrix already matches', async () => { + // alice holds exactly SELECT on public.orders, which is what read-only + // asks for. Offering a GRANT here would hand over SQL that changes + // nothing, and running it would still be a write against production. + await grantsStageFor('alice'); + + fireEvent.click(await screen.findByTestId('access-grants-preset-read-only')); + await waitFor(() => + expect(screen.getByTestId('access-grants-sql').textContent).toMatch( + /matches the live catalog/i + ) + ); + expect((screen.getByTestId('access-grants-copy') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTestId('access-grants-open-sql') as HTMLButtonElement).disabled).toBe(true); + 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..d8a1d92d 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -3,14 +3,23 @@ * 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, + Plus, + RefreshCw, +} from 'lucide-react'; import { dialectSupportsDbAccess, privilegesForPrincipal, @@ -18,30 +27,59 @@ 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 { AccessGrantsStage } from './AccessGrantsStage'; 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 { - DbAccessPermissionSections, - type DbAccessConfirmRequest, -} from './DbAccessPermissionSections'; +import { EmptyState, Segmented, inputCls, labelCls } from './controls'; +import { type DbAccessConfirmRequest } from './DbAccessPermissionSections'; +import { PermissionInspector } from './PermissionInspector'; +import type { AccessPrincipalDraft } from '../lib/access-draft'; + +type PermissionStage = 'account' | 'grants' | 'effective'; +type KindFilter = 'all' | 'user' | 'role'; -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; + lockedConnectionId?: string; + onConnectionChange?: (id: string) => void; + 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); + 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 [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([]); + 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 [kindFilter, setKindFilter] = useState('all'); + const [expandedKinds, setExpandedKinds] = useState>( + () => new Set(['user', 'role', 'group']) + ); const loadToken = useRef(0); const conn = connections.find((c) => c.id === connectionId) || null; @@ -61,7 +99,23 @@ export const AccessPermissionPanel: React.FC = () => { [privileges, selected] ); - const load = useCallback(async () => { + useEffect(() => { + if (!initialDraft) return; + if (initialDraft.connectionId !== connectionId) { + ++loadToken.current; + pickConnection(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 (opts?: { force?: boolean }) => { if (!connectionId) return; const mine = ++loadToken.current; setLoading(true); @@ -70,34 +124,33 @@ export const AccessPermissionPanel: React.FC = () => { try { const data = await fetchDbAccess( { connectionId, password: sessionPasswords[connectionId] || undefined }, - { schema: conn?.schema } + { schema: conn?.schema, force: opts?.force === true } ); 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,42 +159,67 @@ 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'); } }; - return ( -
-
-

Permission

-

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

-
+ const openInSqlEditor = () => { + if (!confirm || !connectionId) return; + setSql?.(confirm.sql); + ensureConnectionSelected?.(connectionId); + setActiveView('sqlEditor'); + setStatus('Opened in SQL Editor.'); + 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) || + p.memberOf.some((m) => m.toLowerCase().includes(needle)) || + p.members.some((m) => m.toLowerCase().includes(needle)) + ); + }), + 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, kindFilter]); -
-
diff --git a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx index 61212ef7..0e011013 100644 --- a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx +++ b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx @@ -80,9 +80,21 @@ beforeEach(() => { }); }); +/** + * Render Access and land on User Management. + * + * Access opens on Permission now, so these tests have to walk to the tab they + * are about. They used to rely on Users being the default — which is what the + * old `useState('users')` comment in AccessView was propping up. + */ +function renderOnUsersTab() { + render(); + fireEvent.click(screen.getByTestId('access-tab-users')); +} + /** Get as far as an Add-user form with SQL that needs a password. */ async function addUserForm(name = 'report_user', connectionId = 'c1') { - render(); + renderOnUsersTab(); fireEvent.change(screen.getByTestId('user-connection'), { target: { value: connectionId } }); await waitFor(() => expect(screen.getByTestId('user-add-user')).toBeTruthy()); fireEvent.click(screen.getByTestId('user-add-user')); @@ -267,7 +279,7 @@ describe('the hint matches what is actually on screen', () => { // A typed password with no account name (or an invalid one) produces no // commands. Telling the reader to copy them as they are would describe // SQL that is not on screen. - render(); + renderOnUsersTab(); fireEvent.change(screen.getByTestId('user-connection'), { target: { value: 'c3' } }); await waitFor(() => expect(screen.getByTestId('user-add-user')).toBeTruthy()); fireEvent.click(screen.getByTestId('user-add-user')); @@ -284,7 +296,7 @@ describe('the hint matches what is actually on screen', () => { }); it('says a valid Db2 OS password is already in the commands', async () => { - render(); + renderOnUsersTab(); fireEvent.change(screen.getByTestId('user-connection'), { target: { value: 'c3' } }); await waitFor(() => expect(screen.getByTestId('user-add-user')).toBeTruthy()); fireEvent.click(screen.getByTestId('user-add-user')); diff --git a/apps/web/src/frontend/features/access/components/PermissionDiff.tsx b/apps/web/src/frontend/features/access/components/PermissionDiff.tsx index 48d0adf1..b6063bd8 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 ?? ''; @@ -185,7 +193,7 @@ export const PermissionDiff: React.FC = () => { try { const res = await fetchDbAccess( { connectionId, password: sessionPasswords[connectionId] || undefined }, - { schema: conn?.schema || undefined } + { schema: conn?.schema || undefined, force: true } ); if (superseded()) return; setPrivileges(res.privileges ?? []); @@ -229,11 +237,12 @@ export const PermissionDiff: React.FC = () => {

+
- + + +
{accessBlockedBy ? (
+ * 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..b7c5839f 100644 --- a/apps/web/src/frontend/features/access/components/PermissionInspector.tsx +++ b/apps/web/src/frontend/features/access/components/PermissionInspector.tsx @@ -8,7 +8,9 @@ import { describePermission, resolveEffectiveAccess, type AccessPermission, + type AccessSource, type EffectiveAccess, + type EffectiveObject, } from '../lib/access'; import type { DbPrincipal, DbPrivilege } from '@foxschema/sql'; import { inputCls, labelCls } from './controls'; @@ -30,8 +32,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 +66,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 +134,10 @@ export const PermissionInspector: React.FC<{ }, [data, principalName, schema]); return ( -
+
{!embedded && (

Permission Inspector

@@ -117,51 +148,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 && (