diff --git a/.cursor/rules/sql-editor-agent-memory.mdc b/.cursor/rules/sql-editor-agent-memory.mdc new file mode 100644 index 0000000..ed142fe --- /dev/null +++ b/.cursor/rules/sql-editor-agent-memory.mdc @@ -0,0 +1,43 @@ +--- +description: Session memory for SQL Editor work on agent branch (PR #117) +alwaysApply: true +--- + +# SQL Editor agent memory (PR #117) + +Branch: `cursor/cloud-agent-1785109452769-k0unh` · Base: `main` · PR: https://github.com/tedious-code/foxschema/pull/117 + +## Product context +FoxSchema = schema diff/migration tool. SQL Editor has Destination servers checklist, Schema explorer, Monaco editor, code cells (`-- @js` / `-- @ts` / `-- @node` / `-- @nodets`), and table blueprint (Indexes between PK and FKs). + +## Shipped on this branch (recent) +- **Indexes** in table blueprint (add/edit/drop, ASC/DESC, unique, WHERE) + e2e +- **Persist selected connections** across refresh (`schema-sync-storage` v5 IDs only; wait for rehydrate) +- **DB2 Windows login**: `Authentication=SERVER`, ODBC brace-escape, GSKit PATH strip +- **New table / Edit table** always visible (not hover-only) +- **DataGrid light mode**: white paper, stronger borders, zebra (`--fox-grid-bg-stripe`) +- **Schema ↔ Destination sync**: Schema dropdown `ensureConnectionSelected`; Dest changes move explorer onto a checked credential +- **Run empty editor**: allowed when destinations checked (0 statement results) +- **Run code cells with 0 destinations**: `canExecuteWithoutDestination` + `LOCAL_CODE_RUN_TARGET` (`__local__` / Local [editor]); SQL still needs a destination +- **View / Procedure / Function source**: click name or **View source** opens definition into the editor via `setSql` (`objectSourceScript.ts`). No edit form this version. TABLE/MQT keep blueprint. + +## Review findings (2026-07-28) + +### Bugbot (fix) — addressed in /simplify +1. **Schema sync vs password prompt** — Skip destination auto-follow while `pendingPassword.id === explorerId`. +2. **Stale explorer id when connections empty** — Gate on `connectionsLoaded`; clear explorer id when loaded empty. + +### Security +No medium+ issues. Local code-cell path does not send `__local__` to `/sql/execute`; auth still via `userId` + `resolveRef`. Passwords stay session-only. + +## Simplify cleanup (same day) +- `destinationIdsPatch` shared by toggle / ensure / password submit +- Memoized `runStatements` / `canRunLocal`; `runCount` derived from resolved statements +- Slim `ExecutionTarget` / `LOCAL_CODE_RUN_TARGET` (no fake `createdAt`, no id special-case for password) +- Schema explorer deps use destination id list (not whole tab) to avoid SQL-edit localStorage churn + +## Gotchas +- Agent branch binding: keep work on `cursor/cloud-agent-1785109452769-k0unh` so the user’s live UI matches the PR tip; stale Vite tabs need hard refresh. +- Do not commit `foxflow.sqlite*` or `apps/e2e/.env`. +- Prefer Node 24 (see root `AGENTS.md`). +- Skipped larger refactors: batching N×M `/sql/execute`, cross-component `liveConnectionIds` helper. diff --git a/AGENTS.md b/AGENTS.md index e2f44c5..ed08baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ Standard commands live in `CONTRIBUTING.md` and `package.json` scripts (`npm run - `npm run dev` runs the Express API (`:3001`) and Vite UI (`:5173`) together; open the UI at http://localhost:5173. API liveness: `GET http://localhost:3001/api/health` → `{"ok":true}`. Default mode is single-user (no login). +- Vite is configured with `server.host: true` and `server.allowedHosts: true` so + `http://127.0.0.1:5173` works (not only IPv6 localhost) and Cursor/cloud + port-forward Host headers are not rejected with 403 (which looks like a blank page). - Vite prints a "Failed to run dependency scan … monaco-editor/esm/…" warning on startup. It is **non-fatal** — Monaco is installed and the SQL editor works; ignore it. diff --git a/apps/cli/src/commands/drivers.ts b/apps/cli/src/commands/drivers.ts index 379698f..f481e86 100644 --- a/apps/cli/src/commands/drivers.ts +++ b/apps/cli/src/commands/drivers.ts @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise { const cwd = webWorkspaceRoot(); // Prefer installing into the @foxschema/web package directory. + // ibm_db must run install scripts (--foreground-scripts) or clidriver never + // downloads and Windows connects fail with SQL1042C / missing native binding. + const npmArgs = + entry.pkg === 'ibm_db' + ? ['install', 'ibm_db@4.0.1', '--foreground-scripts', '--prefix', cwd] + : ['install', entry.pkg, '--foreground-scripts', '--prefix', cwd]; + await new Promise((resolve, reject) => { - const child = spawn('npm', ['install', entry.pkg, '--prefix', cwd], { + const child = spawn('npm', npmArgs, { stdio: 'inherit', shell: process.platform === 'win32', - env: process.env, + env: { ...process.env, npm_config_ignore_scripts: '' }, }); child.on('error', reject); child.on('exit', (code) => { diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 0d99292..ec45f27 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -192,18 +192,23 @@ export class SqlEditorPage { tableName, { timeout: 45_000 } ); - // Prefer the blueprint button nearest the table name label. + // Edit table sits under the name (always visible — no hover / group class). const nameLabel = explorer.getByText(tableName, { exact: true }).first(); await nameLabel.scrollIntoViewIfNeeded(); - const row = nameLabel.locator('xpath=ancestor::div[contains(@class,"group")][1]'); - await row.hover(); + const row = nameLabel.locator('xpath=ancestor::div[./button[@data-testid="sql-open-blueprint"] or .//button[@data-testid="sql-open-blueprint"]][1]'); await row.locator('[data-testid="sql-open-blueprint"]').click({ force: true }); await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } async openNewTableBlueprint(): Promise { await this.dismissOverlays(); - await clickWhen(this.page, '[data-testid="sql-new-table"]'); + // Prefer Schema header action; fall back to explorer New control. + const header = this.page.locator('[data-testid="sql-schema-new-table"]'); + if (await header.count()) { + await header.click(); + } else { + await clickWhen(this.page, '[data-testid="sql-new-table"]'); + } await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } diff --git a/apps/e2e/src/tests/sql-editor-blueprint.test.ts b/apps/e2e/src/tests/sql-editor-blueprint.test.ts index 119a4a9..6ddac0d 100644 --- a/apps/e2e/src/tests/sql-editor-blueprint.test.ts +++ b/apps/e2e/src/tests/sql-editor-blueprint.test.ts @@ -146,6 +146,56 @@ describe.skipIf(!ready)('SQL Editor · blueprint + paging (SQLite)', () => { expect(editorText.toLowerCase()).toMatch(/create\s*table/); }); + it('Indexes section can add a UNIQUE index on a column', async () => { + await sql.openNewTableBlueprint(); + const modal = driver.locator('[data-testid="table-blueprint-modal"]'); + expect(await modal.isVisible()).toBe(true); + await driver.locator('[data-testid="blueprint-table-name"]').fill(`t_idx_${RUN}`); + + await modal.getByRole('button', { name: /add column/i }).click(); + await waitForColumnForm(driver); + await modal.locator('[data-testid="blueprint-column-form"] input').first().fill('email'); + await modal.getByRole('button', { name: /^save$/i }).click(); + await driver.waitForFunction(() => { + const m = document.querySelector('[data-testid="table-blueprint-modal"]'); + return !!m && /email/i.test(m.textContent ?? '') && !m.querySelector('[data-testid="blueprint-column-form"]'); + }); + + const indexes = modal.locator('[data-testid="blueprint-indexes"]'); + expect(await indexes.isVisible()).toBe(true); + await indexes.locator('[data-testid="blueprint-add-index"]').click(); + await driver.waitForSelector('[data-testid="blueprint-index-form"]', { timeout: 5_000 }); + + // openAddIndex already selects the first column — only toggle email on if missing. + const form = modal.locator('[data-testid="blueprint-index-form"]'); + const formText = await form.innerText(); + if (!/\(email/i.test(formText)) { + await form.getByRole('button', { name: /^email$/i }).click(); + } + const unique = form.locator('input[type="checkbox"]').first(); + if (await unique.count()) { + if (!(await unique.isChecked())) await unique.check(); + } + await modal.locator('[data-testid="blueprint-index-save"]').click(); + + await driver.waitForFunction(() => { + const root = document.querySelector('[data-testid="blueprint-indexes"]'); + if (!root) return false; + if (root.querySelector('[data-testid="blueprint-index-form"]')) return false; + return /email/i.test(root.textContent ?? ''); + }, { timeout: 5_000 }); + + await sql.blueprintInsertSql(); + await sql.closeBlueprint().catch(() => undefined); + await driver.waitForTimeout(500); + const editorText = await driver.evaluate(() => { + const lines = document.querySelector('.monaco-editor .view-lines'); + const ta = document.querySelector('.monaco-editor textarea') as HTMLTextAreaElement | null; + return `${lines?.textContent ?? ''}\n${ta?.value ?? ''}`; + }); + expect(editorText.toLowerCase()).toMatch(/create\s+unique\s+index|create\s+index/); + }); + it('pages SELECT results with Next/Prev', async () => { await driver.locator('[data-testid="sql-max-rows"]').fill('2'); await sql.setSql('SELECT id1, id2, label FROM parent ORDER BY id1;'); diff --git a/apps/e2e/src/tests/sql-editor-smoke.test.ts b/apps/e2e/src/tests/sql-editor-smoke.test.ts index b36bdd6..af5924d 100644 --- a/apps/e2e/src/tests/sql-editor-smoke.test.ts +++ b/apps/e2e/src/tests/sql-editor-smoke.test.ts @@ -46,7 +46,7 @@ describe('SQL Editor smoke', () => { expect(await sql.tabCount()).toBe(before + 1); }); - it('Run stays disabled with no SQL / no checked connections', async () => { + it('Run stays disabled with no checked connections', async () => { const btn = driver.locator('[data-testid="sql-run-btn"]'); expect(await btn.isDisabled()).toBe(true); }); diff --git a/apps/web/index.html b/apps/web/index.html index 9b2f398..ac23680 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,7 +7,16 @@ Fox Schema -
+ +
+
+
Fox Schema
+
Loading…
+
+
diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 4da1dd5..dd56ae3 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -10,6 +10,7 @@ import { DriverDetector, buildConnectionString, normalizeTableSchemas, + getProviderSettings, type MigrationStep, type ConnectionOptions, type DbObjectType, @@ -358,6 +359,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] }; try { const { dialect, option, schema } = await resolveRef((req as AuthedRequest).userId, ref); + const settings = getProviderSettings(dialect); + if (settings.schemaRequired && !schema?.trim()) { + res.status(400).json({ + error: `${settings.label} requires a schema. Load schemas for the connection, then pick one before browsing or editing tables.`, + }); + return; + } const { tables, warnings } = await loadScopedTables(dialect, option, schema, scope); res.json(warnings.length ? { tables, warnings } : { tables }); } catch (error: unknown) { diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 5ba4859..eb83c49 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -1,9 +1,9 @@ -import React, { useEffect } from 'react'; +import React, { Suspense, lazy, useEffect } from 'react'; import { TopToolbar } from './components/TopToolbar'; import { SchemaTreePanel } from './components/SchemaTreePanel'; import { ObjectDetailPanel } from './components/ObjectDetailPanel'; -import { SqlEditorView } from './components/sql-editor/SqlEditorView'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { LoadingScreen } from './components/LoadingScreen'; import { AuthPage } from './components/AuthPage'; import { OnboardingWizard } from './components/OnboardingWizard'; import { useSyncStore } from './store/useSyncStore'; @@ -12,6 +12,10 @@ import { useUiStore } from './store/uiStore'; import { apiGetPreferences } from './api/authApi'; import { AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react'; +const SqlEditorView = lazy(() => + import('./components/sql-editor/SqlEditorView').then((m) => ({ default: m.SqlEditorView })) +); + const Workspace: React.FC = () => { const { errorMsg, warnings, dismissWarnings } = useSyncStore(); const activeView = useUiStore((s) => s.activeView); @@ -48,7 +52,9 @@ const Workspace: React.FC = () => {
{activeView === 'sqlEditor' ? ( - + }> + + ) : ( <> @@ -74,14 +80,37 @@ const App: React.FC = () => { init(); }, [init, apply]); - // Once signed in, load the user's saved connections and appearance + // Once signed in, load the user's saved connections and appearance. + // Wait for sync-store persist rehydrate so selected connection IDs are + // restored before loadConnections reapplies source/target configs. useEffect(() => { - if (status === 'ready') { - useSyncStore.getState().loadConnections(); + if (status !== 'ready') return; + + let cancelled = false; + const load = () => { + if (cancelled) return; + void useSyncStore.getState().loadConnections(); apiGetPreferences() - .then((p) => hydrateFromServer(p.theme)) + .then((p) => { + if (!cancelled) hydrateFromServer(p.theme); + }) .catch(() => undefined); + }; + + if (useSyncStore.persist.hasHydrated()) { + load(); + return () => { + cancelled = true; + }; } + + const unsub = useSyncStore.persist.onFinishHydration(() => { + load(); + }); + return () => { + cancelled = true; + unsub(); + }; }, [status, hydrateFromServer]); if (status === 'loading') { diff --git a/apps/web/src/frontend/components/ProfileMenu.tsx b/apps/web/src/frontend/components/ProfileMenu.tsx index a50b675..5ee72ec 100644 --- a/apps/web/src/frontend/components/ProfileMenu.tsx +++ b/apps/web/src/frontend/components/ProfileMenu.tsx @@ -1,11 +1,16 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { Suspense, lazy, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { LogOut, Palette, ChevronDown, ArrowUpCircle, Globe } from 'lucide-react'; import { useAuthStore } from '../store/authStore'; -import { SettingsPanel } from './SettingsPanel'; import { checkForUpdates, type UpdateInfo } from '../api/updatesApi'; -export const ProfileMenu: React.FC = () => { +// 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('./SettingsPanel').then((m) => ({ default: m.SettingsPanel })) +); + +export function ProfileMenu(): React.ReactElement | null { const { user, logout } = useAuthStore(); const [open, setOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -138,7 +143,13 @@ export const ProfileMenu: React.FC = () => { {menu} - setShowSettings(false)} /> + {showSettings && ( + + setShowSettings(false)} /> + + )} ); -}; +} + +export default ProfileMenu; diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index 9b5eba9..69e035a 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -4,7 +4,8 @@ import { useSyncStore } from '../store/useSyncStore'; import { useUiStore } from '../store/uiStore'; import { ArrowRight, ArrowLeftRight, RefreshCw, AlertCircle, CheckCircle2, Zap, Settings, KeyRound, History, Search, X, Layers, GitCompareArrows, Terminal } from 'lucide-react'; import { Brand } from './Brand'; -import { ProfileMenu } from './ProfileMenu'; +// Support both default and named exports (avoids blank-page Vite/HMR mismatches). +import ProfileMenuDefault, { ProfileMenu as ProfileMenuNamed } from './ProfileMenu'; import { CredentialManager } from './CredentialManager'; import { MigrationHistory } from './MigrationHistory'; import { TYPE_META, TYPE_ORDER } from './SchemaTreePanel'; @@ -12,6 +13,8 @@ import type { DbObjectType } from '../lib/types'; import { PROVIDER_SETTINGS } from '../lib/provider-settings'; import { ConnectionModal } from './ConnectionModal'; +const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; + const dialectOptions = Object.values(PROVIDER_SETTINGS); export const TopToolbar: React.FC = () => { diff --git a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx index 0f71fcb..c852cc8 100644 --- a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx +++ b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx @@ -12,6 +12,7 @@ import { SQL_ICON_STROKE } from './sqlIconStyle'; */ export const ConnectionChecklist: React.FC = () => { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); @@ -59,7 +60,9 @@ export const ConnectionChecklist: React.FC = () => { - {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

No saved connections yet — add one via the Credentials button in the toolbar.

diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 1207bf0..441ef73 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -626,14 +626,18 @@ export const DataGrid: React.FC<{ const i = start + offset; const size = pageSize && pageSize > 0 ? pageSize : sourceRows.length; const absRow = pageIndex * size + i + 1; + const stripe = i % 2 === 1; + const rowBg = stripe + ? 'bg-[var(--fox-grid-bg-stripe)]' + : 'bg-[var(--fox-grid-bg)]'; return ( @@ -647,7 +651,7 @@ export const DataGrid: React.FC<{ return ( { diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index e7b85f9..2579353 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -16,13 +16,14 @@ import { KeyRound, PanelLeftClose, PanelLeftOpen, + Plus, } from 'lucide-react'; import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { splitSqlStatements, type SplitStatement } from '../../lib/sql-splitter'; import { formatEditorSql } from '../../utils/formatSql'; -import { effectiveConnectionIds } from '../../store/sqlEditorTabLogic'; -import { setCompletionContextGetter } from './sqlEditorBridge'; +import { effectiveConnectionIds, canExecuteWithoutDestination, resolveRunStatements } from '../../store/sqlEditorTabLogic'; +import { getSelectedSql, setCompletionContextGetter } from './sqlEditorBridge'; import { ConnectionChecklist } from './ConnectionChecklist'; import { EditorTabBar } from './EditorTabBar'; import { ResultsPanel } from './ResultsPanel'; @@ -31,7 +32,7 @@ import { SqlBookmarksPanel } from './SqlBookmarksPanel'; import { SqlVariablesPanel } from './SqlVariablesPanel'; import { SqlSecretsPanel, type SqlSecretsPanelHandle } from './SqlSecretsPanel'; import { SQL_ICON_STROKE } from './sqlIconStyle'; -import { SqlSchemaExplorer } from './SqlSchemaExplorer'; +import { SqlSchemaExplorer, type SqlSchemaExplorerHandle } from './SqlSchemaExplorer'; import { SqlSidebarSection, useSidebarSectionHeights, @@ -129,6 +130,7 @@ export const SqlEditorView: React.FC = () => { const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); const [sectionHeights, setSectionHeight] = useSidebarSectionHeights(); const secretsPanelRef = useRef(null); + const schemaExplorerRef = useRef(null); const [secretsRefreshing, setSecretsRefreshing] = useState(false); const onSecretsRefresh = useCallback(async () => { @@ -231,22 +233,26 @@ export const SqlEditorView: React.FC = () => { const firstSelected = connections.find((c) => liveSelectedIds.includes(c.id)); const dialect = firstSelected?.dialect ?? 'sql'; - const runCount = - tab.checkedStatements.length === 0 - ? statements.length > 0 - ? 1 - : 0 - : tab.checkedStatements.filter((i) => i >= 0 && i < statements.length).length || - (statements.length > 0 ? 1 : 0); - - const canRun = !runningTabId && liveSelectedIds.length > 0 && (hasSelection || runCount > 0); - const runTitle = !liveSelectedIds.length - ? 'Check at least one destination server to run against' - : hasSelection + const selectedSqlForRun = hasSelection ? getSelectedSql() : null; + const runStatements = useMemo( + () => resolveRunStatements(tab.sql, tab.checkedStatements, selectedSqlForRun), + [tab.sql, tab.checkedStatements, selectedSqlForRun] + ); + const canRunLocal = useMemo( + () => canExecuteWithoutDestination(runStatements), + [runStatements] + ); + const runCount = runStatements.length; + const canRun = !runningTabId && (liveSelectedIds.length > 0 || canRunLocal); + const runTitle = liveSelectedIds.length + ? hasSelection ? 'Run the selected SQL (⌘/Ctrl+Enter)' : !runCount - ? 'Write a SQL statement first' - : `Run ${runCount} statement(s) against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)`; + ? `Run with empty editor against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)` + : `Run ${runCount} statement(s) against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)` + : canRunLocal + ? `Run ${runCount} code cell(s) locally — no destination needed (⌘/Ctrl+Enter)` + : 'Check at least one destination server to run SQL, or use a JS/TS/Node code cell'; const onReveal = (stmt: SplitStatement) => { setReveal({ startLine: stmt.startLine, endLine: stmt.endLine, nonce: Date.now() }); @@ -377,8 +383,20 @@ export const SqlEditorView: React.FC = () => { grow height={sectionHeights.schema} onResizeHeight={(h) => setSectionHeight('schema', h)} + actions={ + + } > - +
void; +} /** Categories shown in the SQL Editor schema browser (order = display order). */ const EXPLORER_GROUPS: { type: DbObjectType; title: string }[] = [ @@ -39,20 +45,39 @@ function writeStoredExplorerId(id: string): void { /** * Slim schema tree for the SQL Editor. Categorized TABLE / VIEW / MQT / - * PROCEDURE / FUNCTION — click a name to insert at the Monaco cursor. + * PROCEDURE / FUNCTION. + * + * TABLE / MQT: click inserts the name; Edit table opens the blueprint. + * VIEW / PROCEDURE / FUNCTION: click opens the source script in the editor + * (view-only — no edit form in this version). + * + * The connection dropdown stays in sync with Destination servers: picking a + * schema credential checks it as a destination, and changing destinations + * moves the explorer onto a checked credential when needed. */ -export const SqlSchemaExplorer: React.FC = () => { +export const SqlSchemaExplorer = forwardRef(function SqlSchemaExplorer( + _props, + ref +) { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const schemaCache = useSqlEditorStore((s) => s.schemaCache); const ensureSchema = useSqlEditorStore((s) => s.ensureSchema); const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const pendingPasswordId = useSqlEditorStore((s) => s.pendingPassword?.id); + const setSql = useSqlEditorStore((s) => s.setSql); const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; - const preferredIds = effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter( - (id) => connections.some((c) => c.id === id) + const selectedDestinationIds = shareDestinations + ? sharedConnectionIds + : tab.selectedConnectionIds; + const preferredIds = useMemo( + () => selectedDestinationIds.filter((id) => connections.some((c) => c.id === id)), + [selectedDestinationIds, connections] ); const [explorerId, setExplorerId] = useState(() => readStoredExplorerId()); @@ -70,23 +95,58 @@ export const SqlSchemaExplorer: React.FC = () => { const selectExplorerId = (id: string) => { setExplorerId(id); writeStoredExplorerId(id); + if (id) ensureConnectionSelected(id); + }; + + const openCreateTable = () => { + setBlueprintMode('create'); + setBlueprintTable(null); }; + useImperativeHandle(ref, () => ({ openCreateTable }), []); + useEffect(() => { + if (!connectionsLoaded) return; + + if (connections.length === 0) { + if (explorerId) { + setExplorerId(''); + writeStoredExplorerId(''); + } + return; + } + + // Keep explorer on a password-pending credential until the prompt resolves. + if (pendingPasswordId && pendingPasswordId === explorerId) return; + + if (explorerId && preferredIds.includes(explorerId)) { + writeStoredExplorerId(explorerId); + return; + } + + if (preferredIds.length > 0) { + const next = preferredIds[0]!; + if (next !== explorerId) { + setExplorerId(next); + writeStoredExplorerId(next); + } + return; + } + if (explorerId && connections.some((c) => c.id === explorerId)) { - // Keep a valid selection in storage (e.g. after first hydrate). writeStoredExplorerId(explorerId); return; } + const stored = readStoredExplorerId(); const next = (stored && connections.some((c) => c.id === stored) ? stored : '') || - preferredIds[0] || connections[0]?.id || ''; if (next !== explorerId) setExplorerId(next); if (next) writeStoredExplorerId(next); - }, [connections, preferredIds, explorerId]); + else writeStoredExplorerId(''); + }, [connectionsLoaded, connections, preferredIds, explorerId, pendingPasswordId]); useEffect(() => { if (!explorerId) return; @@ -114,10 +174,23 @@ export const SqlSchemaExplorer: React.FC = () => { ); const conn = connections.find((c) => c.id === explorerId); + let schemaMissingHint: string | null = null; + if (conn) { + try { + const settings = getProviderSettings(conn.dialect); + if (settings.schemaRequired && !conn.schema?.trim()) { + schemaMissingHint = `${settings.label} needs a schema on the connection. Edit it under Credentials, pick a schema, then reload.`; + } + } catch { + /* unknown dialect */ + } + } return (
- {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

Save a connection to browse its tables.

) : ( <> @@ -127,6 +200,8 @@ export const SqlSchemaExplorer: React.FC = () => { onChange={(e) => selectExplorerId(e.target.value)} className="flex-1 min-w-0 bg-slate-950/80 border border-slate-700 rounded-md px-2 py-1 text-[12px] font-semibold text-slate-200 outline-none focus:border-cyan-600" aria-label="Schema connection" + data-testid="sql-schema-connection" + title="Schema for this connection — stays in sync with Destination servers" > {connections.map((c) => (
+ {schemaMissingHint && ( +

+ {schemaMissingHint} +

+ )} {entry?.status === 'error' && (

{entry.error}

)} @@ -170,10 +248,22 @@ export const SqlSchemaExplorer: React.FC = () => { Loading…

)} - {entry?.status === 'ready' && totalCount === 0 && ( -

- No tables, views, procedures, or functions in this schema. -

+ {entry?.status === 'ready' && totalCount === 0 && !schemaMissingHint && ( +
+

+ No tables in this schema yet. Use New to open the table blueprint and add columns. +

+ +
)}
@@ -225,6 +315,11 @@ export const SqlSchemaExplorer: React.FC = () => { } : undefined } + onOpenSource={ + isScriptableObject(t.objectType) + ? () => setSql(objectSourceScript(t, conn?.dialect ?? 'sql')) + : undefined + } /> ))}
@@ -250,7 +345,7 @@ export const SqlSchemaExplorer: React.FC = () => { )}
); -}; +}); const ObjectNode: React.FC<{ table: TableSchema; @@ -258,7 +353,8 @@ const ObjectNode: React.FC<{ onToggle: () => void; dialect: string; onOpenBlueprint?: () => void; -}> = ({ table, open, onToggle, dialect, onOpenBlueprint }) => { + onOpenSource?: () => void; +}> = ({ table, open, onToggle, dialect, onOpenBlueprint, onOpenSource }) => { const meta = TYPE_META[table.objectType] ?? TYPE_META.TABLE; const insertName = quoteIfNeeded(table.name, dialect); const isRoutine = table.objectType === 'PROCEDURE' || table.objectType === 'FUNCTION'; @@ -266,6 +362,7 @@ const ObjectNode: React.FC<{ const columns = !isRoutine ? (table.columns ?? []).map((c) => ({ name: c.name, detail: c.type })) : []; + const opensSource = Boolean(onOpenSource); const insertObject = () => { if (isRoutine) { @@ -275,17 +372,25 @@ const ObjectNode: React.FC<{ insertAtCursor(`${insertName} `); }; + const onNameClick = () => { + if (onOpenSource) { + onOpenSource(); + return; + } + insertObject(); + }; + const insertIdent = (name: string) => { insertAtCursor(`${quoteIfNeeded(name, dialect)} `); }; return ( -
-
+
+
- {onOpenBlueprint && ( - - )}
+ {onOpenBlueprint && ( + + )} + {opensSource && ( + + )} {open && isRoutine && params.length === 0 && (

No parameters

)} diff --git a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx index d049d3e..56bae68 100644 --- a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx +++ b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx @@ -1133,7 +1133,7 @@ export const TableBlueprintModal: React.FC = ({ {/* Indexes */} -
+

@@ -1145,6 +1145,7 @@ export const TableBlueprintModal: React.FC = ({ {!indexFormOpen && indexSupport.create && (