From 3449d5901cbb82447c49a66de44ab77d382eee55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:46:16 +0000 Subject: [PATCH 01/24] fix(ui): keep selected connections across page refresh After refresh, connections briefly load as [] which cleared the SQL schema-explorer selection, and sync source/target IDs were never persisted. Wait for store rehydrate, persist selection IDs only, and skip wiping explorer state during the empty-list window. Co-authored-by: huy.phan9 --- apps/web/src/frontend/App.tsx | 31 +++++++++-- .../sql-editor/ConnectionChecklist.tsx | 5 +- .../sql-editor/SqlSchemaExplorer.tsx | 17 ++++-- apps/web/src/frontend/store/sync-types.ts | 2 + apps/web/src/frontend/store/useSyncStore.ts | 52 +++++++++++++++++-- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 5ba4859..cc935a9 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -74,14 +74,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/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/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index ad04550..3943486 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -43,6 +43,7 @@ function writeStoredExplorerId(id: string): void { */ export const SqlSchemaExplorer: 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 schemaCache = useSqlEditorStore((s) => s.schemaCache); @@ -51,8 +52,12 @@ export const SqlSchemaExplorer: React.FC = () => { const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); 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 preferredIds = useMemo( + () => + effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter((id) => + connections.some((c) => c.id === id) + ), + [tab, shareDestinations, sharedConnectionIds, connections] ); const [explorerId, setExplorerId] = useState(() => readStoredExplorerId()); @@ -73,6 +78,10 @@ export const SqlSchemaExplorer: React.FC = () => { }; useEffect(() => { + // After refresh, connections starts [] until loadConnections finishes. + // Do not clear a persisted explorerId during that empty-list window. + if (connections.length === 0) return; + if (explorerId && connections.some((c) => c.id === explorerId)) { // Keep a valid selection in storage (e.g. after first hydrate). writeStoredExplorerId(explorerId); @@ -117,7 +126,9 @@ export const SqlSchemaExplorer: React.FC = () => { return (
- {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

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

Save a connection to browse its tables.

) : ( <> diff --git a/apps/web/src/frontend/store/sync-types.ts b/apps/web/src/frontend/store/sync-types.ts index 2c4d6db..5a61011 100644 --- a/apps/web/src/frontend/store/sync-types.ts +++ b/apps/web/src/frontend/store/sync-types.ts @@ -32,6 +32,8 @@ export interface SyncState { targetConfig: ConnectionConfig; connections: SavedConnectionSummary[]; + /** False until the first loadConnections() attempt finishes (refresh race guard). */ + connectionsLoaded: boolean; selectedSourceConnectionId: string | null; selectedTargetConnectionId: string | null; diff --git a/apps/web/src/frontend/store/useSyncStore.ts b/apps/web/src/frontend/store/useSyncStore.ts index ca76d02..45d8a14 100644 --- a/apps/web/src/frontend/store/useSyncStore.ts +++ b/apps/web/src/frontend/store/useSyncStore.ts @@ -41,6 +41,7 @@ export const useSyncStore = create()( }, connections: [], + connectionsLoaded: false, selectedSourceConnectionId: null, selectedTargetConnectionId: null, showConnectionModal: false, @@ -83,9 +84,48 @@ export const useSyncStore = create()( // --- Actions --- loadConnections: async () => { try { - set({ connections: await apiListConnections() }); + const list = await apiListConnections(); + const { selectedSourceConnectionId, selectedTargetConnectionId } = get(); + const patch: Partial = { connections: list, connectionsLoaded: true }; + + // Restore persisted source/target picks (IDs only — never passwords). + // Skip auto-test: session passwords are gone after refresh. + const restore = (side: 'source' | 'target', id: string | null) => { + if (!id) return; + const conn = list.find((c) => c.id === id); + if (!conn) { + if (side === 'source') patch.selectedSourceConnectionId = null; + else patch.selectedTargetConnectionId = null; + return; + } + const config: ConnectionConfig = { + dialect: conn.dialect as ConnectionConfig['dialect'], + option: { + host: conn.host, + port: conn.port, + database: conn.database, + username: conn.username, + schema: conn.schema, + }, + schema: conn.schema ?? '', + connectionId: id, + }; + if (side === 'source') { + patch.selectedSourceConnectionId = id; + patch.sourceConfig = config; + patch.sourceConnected = false; + } else { + patch.selectedTargetConnectionId = id; + patch.targetConfig = config; + patch.targetConnected = false; + } + }; + restore('source', selectedSourceConnectionId); + restore('target', selectedTargetConnectionId); + set(patch); } catch (e) { console.error('Failed to load saved connections:', e); + set({ connectionsLoaded: true }); } }, @@ -504,13 +544,14 @@ export const useSyncStore = create()( }), { name: 'schema-sync-storage', - version: 4, - // Persist only the object-type scope. Connections live server-side now, - // and we deliberately do NOT persist configs — credentials must never - // touch localStorage (saved connections reload from the server on sign-in). + version: 5, + // Persist object-type scope + selected connection IDs only. Never persist + // configs/passwords — credentials reload from the server on sign-in. partialize: (state) => ({ selectedObjectTypes: state.selectedObjectTypes, nonDestructive: state.nonDestructive, + selectedSourceConnectionId: state.selectedSourceConnectionId, + selectedTargetConnectionId: state.selectedTargetConnectionId, }), migrate: (persisted: any, version) => { const ensure = (type: string) => { @@ -523,6 +564,7 @@ export const useSyncStore = create()( if (version < 2) ensure('TRIGGER'); if (version < 3) { ensure('SEQUENCE'); ensure('TYPE'); } if (version < 4) { ensure('MQT'); ensure('ROLE'); } + // v5: selected source/target connection ids (no credentials). return persisted; }, } From 50a2bb44ed62882248a0f947af737c648d86b847 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:07:39 +0000 Subject: [PATCH 02/24] fix(db2): restore Windows login path so tables/browse work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IBM DB2 on Windows often connected without Authentication=SERVER, lost passwords containing ';', or skipped clidriver install scripts — then schema load returned zero tables and table edit looked missing. - Always rebuild DB2 strings with Authentication=SERVER; ODBC-brace PWD - Strip IBM GSKit from PATH; clear error when clidriver is missing - Require schema on /schema/load for schemaRequired dialects - foxschema drivers install db2 uses --foreground-scripts like the UI Co-authored-by: huy.phan9 --- apps/cli/src/commands/drivers.ts | 11 ++- apps/web/src/backend/api/routes.ts | 8 ++ .../web/src/frontend/lib/provider-settings.ts | 11 ++- .../core/src/providers/db2/db2.adapter.ts | 14 +++- .../src/providers/db2/db2.connection.test.ts | 71 ++++++++++++++++ .../core/src/providers/db2/db2.connection.ts | 81 +++++++++++++++---- .../core/src/providers/db2/db2.env.test.ts | 19 +++++ packages/core/src/providers/db2/db2.env.ts | 27 ++++++- 8 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/providers/db2/db2.connection.test.ts create mode 100644 packages/core/src/providers/db2/db2.env.test.ts 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/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/lib/provider-settings.ts b/apps/web/src/frontend/lib/provider-settings.ts index e243a05..96d23be 100644 --- a/apps/web/src/frontend/lib/provider-settings.ts +++ b/apps/web/src/frontend/lib/provider-settings.ts @@ -1,3 +1,5 @@ +import { buildConnectionString as coreBuildConnectionString } from '@foxschema/core'; + export type Dialect = 'postgres' | 'mysql' | 'mariadb' | 'db2' | 'sqlserver' | 'oracle' | 'sqlite' | 'redshift' | 'clickhouse' | 'azuresql' | 'cockroachdb' | 'yugabytedb' | 'tidb' | 'duckdb'; export interface ConnectionOptions { @@ -87,13 +89,10 @@ const db2Settings: ProviderSettings = { defaultPort: 50000, defaultSchema: '', schemaRequired: true, + // Delegate to core so pasted strings always get Authentication=SERVER / + // CurrentSchema (verbatim early-return caused SQL1042C / empty browse on Windows). buildConnectionString(o) { - if (o.connectionString?.trim()) return o.connectionString.trim(); - const host = o.host || 'localhost'; - const port = o.port || this.defaultPort; - let cs = `DATABASE=${o.database || ''};HOSTNAME=${host};PORT=${port};PROTOCOL=TCPIP;UID=${o.username || ''};PWD=${o.password || ''};Authentication=SERVER;`; - if (o.schema) cs += `CurrentSchema=${o.schema.toUpperCase()};`; - return cs; + return coreBuildConnectionString('db2', o); }, }; diff --git a/packages/core/src/providers/db2/db2.adapter.ts b/packages/core/src/providers/db2/db2.adapter.ts index 9c85d16..bbd657b 100644 --- a/packages/core/src/providers/db2/db2.adapter.ts +++ b/packages/core/src/providers/db2/db2.adapter.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface'; import { assertSafeIdentifier } from '../../cores/sql-identifier'; import { BoundedPoolCache, disposePoolEndOrClose } from '../../cores/pool-cache'; -import { setupDb2ClientEnv } from './db2.env'; +import { setupDb2ClientEnv, hasDb2Clidriver } from './db2.env'; const nodeRequire = createRequire(import.meta.url); @@ -20,12 +20,22 @@ class Db2Adapter implements DriverAdapter { private load(): any { if (this.driver) return this.driver; setupDb2ClientEnv(); // point at the bundled clidriver before native load + if (!hasDb2Clidriver()) { + throw new Error( + 'ibm_db is installed but its CLI driver (clidriver) is missing. ' + + 'On Windows this usually means scripts were skipped — reinstall with: ' + + 'npm install ibm_db@4.0.1 --foreground-scripts (or: foxschema drivers install db2)' + ); + } try { const mod = nodeRequire(this.packageName); this.driver = mod.default ?? mod; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - throw new Error(`Database driver "${this.packageName}" is not installed for db2. Install it with: npm install ${this.packageName} — ${message}`); + throw new Error( + `Database driver "${this.packageName}" is not installed for db2. ` + + `Install it with: npm install ${this.packageName} --foreground-scripts — ${message}` + ); } return this.driver; } diff --git a/packages/core/src/providers/db2/db2.connection.test.ts b/packages/core/src/providers/db2/db2.connection.test.ts new file mode 100644 index 0000000..7b2c594 --- /dev/null +++ b/packages/core/src/providers/db2/db2.connection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDb2ConnectionString, + odbcEscape, + parseDb2SemicolonMap, +} from './db2.connection'; + +describe('buildDb2ConnectionString', () => { + it('always injects Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + host: 'db.example', + port: 50000, + database: 'SAMPLE', + username: 'db2inst1', + password: 'secret', + }); + expect(cs).toContain('Authentication=SERVER'); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('HOSTNAME=db.example'); + expect(cs).toContain('UID=db2inst1'); + expect(cs).toContain('PWD=secret'); + }); + + it('normalizes a pasted semicolon string and still forces Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + connectionString: + 'DATABASE=SAMPLE;HOSTNAME=h;PORT=50000;PROTOCOL=TCPIP;UID=u;PWD=p;', + }); + expect(cs).toMatch(/Authentication=SERVER/); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('UID=u'); + }); + + it('brace-escapes passwords that contain semicolons so they round-trip', () => { + const password = 'a;b}c'; + const cs = buildDb2ConnectionString({ + host: 'h', + database: 'D', + username: 'u', + password, + }); + expect(cs).toContain(`PWD=${odbcEscape(password)}`); + const map = parseDb2SemicolonMap(cs); + expect(map.get('PWD')).toBe(password); + }); + + it('parses braced PWD from a pasted string', () => { + const cs = buildDb2ConnectionString({ + connectionString: 'DATABASE=D;HOSTNAME=h;PORT=50000;UID=u;PWD={p;a;s;s};', + }); + expect(parseDb2SemicolonMap(cs).get('PWD')).toBe('p;a;s;s'); + }); + + it('adds CurrentSchema when schema is provided', () => { + const cs = buildDb2ConnectionString( + { host: 'h', database: 'D', username: 'u', password: 'p', schema: 'myschema' }, + 'myschema' + ); + expect(cs).toContain('CurrentSchema=MYSCHEMA'); + }); +}); + +describe('odbcEscape', () => { + it('leaves plain values alone', () => { + expect(odbcEscape('plain')).toBe('plain'); + }); + + it('escapes braces inside braced values', () => { + expect(odbcEscape('a}b')).toBe('{a}}b}'); + }); +}); diff --git a/packages/core/src/providers/db2/db2.connection.ts b/packages/core/src/providers/db2/db2.connection.ts index 6a09cb9..59a63c2 100644 --- a/packages/core/src/providers/db2/db2.connection.ts +++ b/packages/core/src/providers/db2/db2.connection.ts @@ -2,20 +2,21 @@ import type { ConnectionOptions } from '../../interfaces/schema-provider.interfa /** * Build a DB2 CLI connection string (semicolon-delimited key=value pairs). - * ibm_db2 does not accept db2:// URLs — those must be converted first. + * ibm_db does not accept db2:// URLs — those must be converted first. * - * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups. + * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups + * (especially Windows when GSKit / system CLI libraries pollute PATH). */ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: string): string { const parsed = parseDb2ConnectionInput(options.connectionString, options); const parts: string[] = [ - `DATABASE=${parsed.database}`, - `HOSTNAME=${parsed.host}`, + `DATABASE=${odbcEscape(parsed.database)}`, + `HOSTNAME=${odbcEscape(parsed.host)}`, `PORT=${parsed.port}`, 'PROTOCOL=TCPIP', - `UID=${parsed.username}`, - `PWD=${parsed.password}`, + `UID=${odbcEscape(parsed.username)}`, + `PWD=${odbcEscape(parsed.password)}`, 'Authentication=SERVER', ]; @@ -25,12 +26,18 @@ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: st const schemaName = schema?.trim() || options.schema?.trim(); if (schemaName) { - parts.push(`CurrentSchema=${schemaName.toUpperCase()}`); + parts.push(`CurrentSchema=${odbcEscape(schemaName.toUpperCase())}`); } return parts.join(';') + ';'; } +/** ODBC brace-escape so values containing `;` or `}` do not truncate the string. */ +export function odbcEscape(value: string): string { + if (!/[;{}]/.test(value) && value === value.trim()) return value; + return `{${value.replace(/}/g, '}}')}}`; +} + function parseDb2ConnectionInput( connectionString: string | undefined, options: ConnectionOptions @@ -81,6 +88,57 @@ function parseDb2Url(url: string): { }; } +/** + * Parse `KEY=value;KEY2={value;with;semicolons};` with ODBC brace rules. + * A plain `split(';')` would truncate passwords that contain `;`. + */ +export function parseDb2SemicolonMap(connStr: string): Map { + const map = new Map(); + let i = 0; + const s = connStr; + + while (i < s.length) { + while (i < s.length && (s[i] === ';' || /\s/.test(s[i]!))) i++; + if (i >= s.length) break; + + const eq = s.indexOf('=', i); + if (eq === -1) break; + const key = s.slice(i, eq).trim().toUpperCase(); + let j = eq + 1; + while (j < s.length && s[j] === ' ') j++; + + let value: string; + if (s[j] === '{') { + j++; + let out = ''; + while (j < s.length) { + if (s[j] === '}' && s[j + 1] === '}') { + out += '}'; + j += 2; + continue; + } + if (s[j] === '}') { + j++; + break; + } + out += s[j]!; + j++; + } + value = out; + while (j < s.length && s[j] !== ';') j++; + } else { + const semi = s.indexOf(';', j); + value = (semi === -1 ? s.slice(j) : s.slice(j, semi)).trim(); + j = semi === -1 ? s.length : semi; + } + + if (key) map.set(key, value); + i = j + 1; + } + + return map; +} + function parseDb2Semicolon( connStr: string, fallback: ConnectionOptions @@ -91,14 +149,7 @@ function parseDb2Semicolon( username: string; password: string; } { - const map = new Map(); - for (const segment of connStr.split(';')) { - const eq = segment.indexOf('='); - if (eq === -1) continue; - const key = segment.slice(0, eq).trim().toUpperCase(); - const value = segment.slice(eq + 1).trim(); - if (key) map.set(key, value); - } + const map = parseDb2SemicolonMap(connStr); return { database: map.get('DATABASE') ?? fallback.database ?? '', diff --git a/packages/core/src/providers/db2/db2.env.test.ts b/packages/core/src/providers/db2/db2.env.test.ts new file mode 100644 index 0000000..685507a --- /dev/null +++ b/packages/core/src/providers/db2/db2.env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { stripIbmGskPath } from './db2.env'; + +describe('stripIbmGskPath', () => { + it('removes IBM GSKit directories that conflict with bundled clidriver', () => { + const sep = process.platform === 'win32' ? ';' : ':'; + const input = ['C:\\Tools', 'C:\\Program Files\\IBM\\gsk8\\lib', 'C:\\clidriver\\bin'].join( + sep + ); + const out = stripIbmGskPath(input) ?? ''; + expect(out.toLowerCase()).not.toContain('gsk8'); + expect(out).toContain('Tools'); + expect(out).toContain('clidriver'); + }); + + it('returns undefined for undefined input', () => { + expect(stripIbmGskPath(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/providers/db2/db2.env.ts b/packages/core/src/providers/db2/db2.env.ts index ac35b97..acf6167 100644 --- a/packages/core/src/providers/db2/db2.env.ts +++ b/packages/core/src/providers/db2/db2.env.ts @@ -9,6 +9,9 @@ let configured = false; /** * Point the process at ibm_db's bundled clidriver before loading native bindings. * Helps avoid SQL1042C caused by missing/wrong CLI library paths. + * + * On Windows, IBM GSKit (`…\IBM\gsk8\…`) on PATH is a known conflict with + * ibm_db's bundled CLI — strip those entries when we install our clidriver PATH. */ export function setupDb2ClientEnv(): void { if (configured) return; @@ -20,6 +23,8 @@ export function setupDb2ClientEnv(): void { const binDir = path.join(clidriverDir, 'bin'); if (!fs.existsSync(clidriverDir)) { + // Leave configured=false so a later successful `npm install ibm_db + // --foreground-scripts` can retry setup on the next connect. return; } @@ -32,7 +37,7 @@ export function setupDb2ClientEnv(): void { process.env.LD_LIBRARY_PATH = prependPath(process.env.LD_LIBRARY_PATH, libDir); process.env.PATH = prependPath(process.env.PATH, binDir); } else if (process.platform === 'win32') { - process.env.PATH = prependPath(process.env.PATH, binDir, libDir); + process.env.PATH = prependPath(stripIbmGskPath(process.env.PATH), binDir, libDir); process.env.LIB = prependPath(process.env.LIB, libDir, binDir); } @@ -42,6 +47,26 @@ export function setupDb2ClientEnv(): void { } } +/** True when ibm_db's installer/clidriver directory is present on disk. */ +export function hasDb2Clidriver(): boolean { + try { + const pkgPath = nodeRequire.resolve('ibm_db/package.json'); + const clidriverDir = path.join(path.dirname(pkgPath), 'installer', 'clidriver'); + return fs.existsSync(clidriverDir); + } catch { + return false; + } +} + +/** Exported for tests — remove IBM GSKit dirs that conflict with bundled CLI. */ +export function stripIbmGskPath(current: string | undefined): string | undefined { + if (!current) return current; + const kept = current + .split(path.delimiter) + .filter((p) => p && !/IBM[/\\]gsk8/i.test(p)); + return kept.join(path.delimiter); +} + function prependPath(current: string | undefined, ...dirs: string[]): string { const existing = current?.split(path.delimiter).filter(Boolean) ?? []; const merged = [...dirs, ...existing.filter((d) => !dirs.includes(d))]; From d09f9c5404037e16255812493e10fd2f15adbb92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 04:52:50 +0000 Subject: [PATCH 03/24] fix(sql-editor): make table blueprint entry points always visible The Edit/open-blueprint control only appeared on loaded table rows, so an empty schema (or missing DB2 schema) looked like the feature was deleted. Label New/Edit, add empty-state Create table, surface schema-required hints, and keep the DB2 Windows login path so tables can load again. Co-authored-by: huy.phan9 --- .../sql-editor/SqlSchemaExplorer.tsx | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index 3943486..36f0c2a 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Columns3, Loader2, Plus, RefreshCw } from 'l import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { effectiveConnectionIds } from '../../store/sqlEditorTabLogic'; +import { getProviderSettings } from '../../lib/provider-settings'; import { TYPE_META } from '../SchemaTreePanel'; import { filterCallParameters, insertAtCursor } from './sqlEditorBridge'; import type { DbObjectType, TableSchema } from '../../lib/types'; @@ -77,6 +78,11 @@ export const SqlSchemaExplorer: React.FC = () => { writeStoredExplorerId(id); }; + const openCreateTable = () => { + setBlueprintMode('create'); + setBlueprintTable(null); + }; + useEffect(() => { // After refresh, connections starts [] until loadConnections finishes. // Do not clear a persisted explorerId during that empty-list window. @@ -123,6 +129,17 @@ 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 (
@@ -147,16 +164,14 @@ export const SqlSchemaExplorer: React.FC = () => {
+ {schemaMissingHint && ( +

+ {schemaMissingHint} +

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

{entry.error}

)} @@ -181,10 +201,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. +

+ +
)}
@@ -310,9 +342,17 @@ const ObjectNode: React.FC<{ title={ isRoutine ? `Insert ${table.name}(${params.map((p) => `${p.mode} ${p.name}`).join(', ')})` - : `Insert ${table.name}` + : onOpenBlueprint + ? `Insert ${table.name} (double-click to edit table)` + : `Insert ${table.name}` } onClick={insertObject} + onDoubleClick={(e) => { + if (!onOpenBlueprint) return; + e.preventDefault(); + e.stopPropagation(); + onOpenBlueprint(); + }} className="flex-1 flex items-center gap-1.5 min-w-0 text-left text-[13px] font-semibold text-slate-200 hover:text-cyan-300 py-1 truncate" > {meta.icon} @@ -326,15 +366,16 @@ const ObjectNode: React.FC<{ {onOpenBlueprint && ( )}
From a3eed08fb151b993b8cc146f99ffb69cd70c2be6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:03:58 +0000 Subject: [PATCH 04/24] fix(sql-editor): surface New table and Edit table where they cannot hide Your Schema screenshot matched a build where the only controls were the connection dropdown + refresh. Put New table in the Schema section header and render Edit table under each table name so add-column stays reachable in a narrow sidebar. Secrets + blueprint code remain in the tree. Co-authored-by: huy.phan9 --- .../components/sql-editor/SqlEditorView.tsx | 18 +++++- .../sql-editor/SqlSchemaExplorer.tsx | 62 ++++++++++--------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index e7b85f9..465db50 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -16,6 +16,7 @@ import { KeyRound, PanelLeftClose, PanelLeftOpen, + Plus, } from 'lucide-react'; import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; @@ -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 () => { @@ -377,8 +379,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 }[] = [ { type: 'TABLE', title: 'Tables' }, @@ -42,7 +47,10 @@ 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. */ -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); @@ -83,6 +91,8 @@ export const SqlSchemaExplorer: React.FC = () => { setBlueprintTable(null); }; + useImperativeHandle(ref, () => ({ openCreateTable }), []); + useEffect(() => { // After refresh, connections starts [] until loadConnections finishes. // Do not clear a persisted explorerId during that empty-list window. @@ -293,7 +303,7 @@ export const SqlSchemaExplorer: React.FC = () => { )}
); -}; +}); const ObjectNode: React.FC<{ table: TableSchema; @@ -323,12 +333,12 @@ const ObjectNode: React.FC<{ }; return ( -
-
+
+
- {onOpenBlueprint && ( - - )}
+ {onOpenBlueprint && ( + + )} {open && isRoutine && params.length === 0 && (

No parameters

)} From a47c77494246d690b0302179adbad42e1de1bf84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:12:14 +0000 Subject: [PATCH 05/24] test(e2e): open Edit table without hover group class Edit table is always visible under each table name now; the old page object looked for a removed Tailwind group ancestor and timed out. Co-authored-by: huy.phan9 --- apps/e2e/src/pages/SqlEditorPage.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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); } From 68c8ad83af998877a37d6fdfae5dba15469a29e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:34:41 +0000 Subject: [PATCH 06/24] feat(blueprint): expose Indexes section testids and e2e Add-index coverage Indexes (add/edit/drop, unique, ASC/DESC, optional WHERE) already live in the table blueprint modal; add stable data-testids and a Playwright flow that adds a UNIQUE index and inserts CREATE INDEX SQL. Co-authored-by: huy.phan9 --- .../src/tests/sql-editor-blueprint.test.ts | 50 +++++++++++++++++++ .../sql-editor/TableBlueprintModal.tsx | 10 +++- 2 files changed, 58 insertions(+), 2 deletions(-) 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/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 && (

@@ -342,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'; @@ -350,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) { @@ -359,6 +372,14 @@ const ObjectNode: React.FC<{ insertAtCursor(`${insertName} `); }; + const onNameClick = () => { + if (onOpenSource) { + onOpenSource(); + return; + } + insertObject(); + }; + const insertIdent = (name: string) => { insertAtCursor(`${quoteIfNeeded(name, dialect)} `); }; @@ -380,12 +401,15 @@ const ObjectNode: React.FC<{ )} + {opensSource && ( + + )} {open && isRoutine && params.length === 0 && (

No parameters

)} diff --git a/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts b/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts new file mode 100644 index 0000000..3865ce0 --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isScriptableObject, objectSourceScript } from './objectSourceScript'; +import type { TableSchema } from '../../lib/types'; + +function view(partial: Partial & Pick): TableSchema { + return { + name: partial.name, + objectType: 'VIEW', + columns: [], + indexes: [], + foreignKeys: [], + definition: partial.definition, + ...partial, + } as TableSchema; +} + +describe('objectSourceScript', () => { + it('marks VIEW / PROCEDURE / FUNCTION as scriptable', () => { + expect(isScriptableObject('VIEW')).toBe(true); + expect(isScriptableObject('PROCEDURE')).toBe(true); + expect(isScriptableObject('FUNCTION')).toBe(true); + expect(isScriptableObject('TABLE')).toBe(false); + expect(isScriptableObject('MQT')).toBe(false); + }); + + it('returns definition for a view and ensures a trailing semicolon', () => { + const sql = objectSourceScript( + view({ name: 'v_orders', definition: 'CREATE VIEW v_orders AS SELECT 1 AS id' }), + 'postgres' + ); + expect(sql).toContain('v_orders'); + expect(sql.trimEnd().endsWith(';')).toBe(true); + }); + + it('falls back when definition is missing', () => { + const sql = objectSourceScript(view({ name: 'v_empty' }), 'postgres'); + expect(sql).toMatch(/No definition available/i); + expect(sql).toContain('v_empty'); + }); +}); diff --git a/apps/web/src/frontend/components/sql-editor/objectSourceScript.ts b/apps/web/src/frontend/components/sql-editor/objectSourceScript.ts new file mode 100644 index 0000000..40f24ee --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/objectSourceScript.ts @@ -0,0 +1,29 @@ +import { SqlGeneratorModule } from '../../lib/sql-generator'; +import { formatSql } from '../../utils/formatSql'; +import type { DbObjectType, TableSchema } from '../../lib/types'; + +/** Objects that open their source script in the editor (no edit form in this version). */ +export const SCRIPTABLE_OBJECT_TYPES = new Set(['VIEW', 'PROCEDURE', 'FUNCTION']); + +const ddlGenerator = new SqlGeneratorModule(); +const FORMAT_SQL_MAX = 50_000; + +export function isScriptableObject(type: DbObjectType): boolean { + return SCRIPTABLE_OBJECT_TYPES.has(type); +} + +/** Source script for VIEW / PROCEDURE / FUNCTION — view-only (no edit form). */ +export function objectSourceScript(table: TableSchema, dialect: string): string { + let ddl = ddlGenerator.generateObjectDdl(table, dialect).trim(); + if (!ddl) { + return `-- No definition available for ${table.objectType} ${table.name}\n`; + } + if (ddl.length <= FORMAT_SQL_MAX) { + try { + ddl = formatSql(ddl, dialect).trim() || ddl; + } catch { + /* keep raw definition */ + } + } + return ddl.endsWith(';') ? `${ddl}\n` : `${ddl};\n`; +} From 23d4bf0cb9f1ed067f526c48906abbf4a572be7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 04:26:40 +0000 Subject: [PATCH 17/24] docs: note View source for scriptable schema objects Co-authored-by: huy.phan9 --- .cursor/rules/sql-editor-agent-memory.mdc | 1 + 1 file changed, 1 insertion(+) diff --git a/.cursor/rules/sql-editor-agent-memory.mdc b/.cursor/rules/sql-editor-agent-memory.mdc index 9658c5b..ed142fe 100644 --- a/.cursor/rules/sql-editor-agent-memory.mdc +++ b/.cursor/rules/sql-editor-agent-memory.mdc @@ -19,6 +19,7 @@ FoxSchema = schema diff/migration tool. SQL Editor has Destination servers check - **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) From 9b4b2c76e0617b8dc2f49f9516d896da72900395 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 04:43:42 +0000 Subject: [PATCH 18/24] fix(web): bind Vite to IPv4 so 127.0.0.1:5173 loads Default listen could bind [::1] only, causing ERR_CONNECTION_REFUSED on http://127.0.0.1:5173. Also fix a duplicate-key TypeScript warning in the objectSourceScript test fixture. Co-authored-by: huy.phan9 --- .../components/sql-editor/objectSourceScript.test.ts | 6 +++--- apps/web/vite.config.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts b/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts index 3865ce0..f063e3e 100644 --- a/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts +++ b/apps/web/src/frontend/components/sql-editor/objectSourceScript.test.ts @@ -3,14 +3,14 @@ import { isScriptableObject, objectSourceScript } from './objectSourceScript'; import type { TableSchema } from '../../lib/types'; function view(partial: Partial & Pick): TableSchema { + const { name, ...rest } = partial; return { - name: partial.name, + name, objectType: 'VIEW', columns: [], indexes: [], foreignKeys: [], - definition: partial.definition, - ...partial, + ...rest, } as TableSchema; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d0e1674..a591818 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -24,6 +24,10 @@ export default defineConfig({ dedupe: ['react', 'react-dom'], }, server: { + // Bind IPv4 + IPv6. Default Node listen can end up on [::1] only, which + // makes http://127.0.0.1:5173 fail with ERR_CONNECTION_REFUSED. + host: true, + port: 5173, proxy: { '/api': { target: 'http://localhost:3001', From c89f26e01e6acc5fd121fc8698bb5f70ff325c69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 04:55:08 +0000 Subject: [PATCH 19/24] fix(web): allow Vite port-forward hosts (blank page 403) Vite 8 rejects non-localhost Host headers by default. Cursor/cloud port-forwards then get 403 Blocked request, which shows as a blank page. Set server.allowedHosts: true for local/cloud dev. Co-authored-by: huy.phan9 --- apps/web/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a591818..cb49aaa 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -28,6 +28,9 @@ export default defineConfig({ // makes http://127.0.0.1:5173 fail with ERR_CONNECTION_REFUSED. host: true, port: 5173, + // Cursor / cloud port-forwards send a non-localhost Host header; Vite 8 + // rejects those with 403 ("Blocked request…") → blank page in the browser. + allowedHosts: true, proxy: { '/api': { target: 'http://localhost:3001', From ef91661b06018d3d85f935cffc1f16b5828d9a4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 04:55:13 +0000 Subject: [PATCH 20/24] docs: note Vite host/allowedHosts blank-page gotcha Co-authored-by: huy.phan9 --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) 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. From ec4a8ec68ce793768118b3e58b10b19201a31151 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 05:04:13 +0000 Subject: [PATCH 21/24] fix(web): stop silent blank boot and harden SQL Editor load Show a Loading fallback in index.html, paint boot failures instead of an empty #app, time out hung signup checks, lazy-load SqlEditorView behind Suspense/ErrorBoundary, and fix Monaco @end fence attribute escaping. Co-authored-by: huy.phan9 --- apps/web/index.html | 11 ++- apps/web/src/frontend/App.tsx | 12 ++- .../src/frontend/lib/foxschemaSqlLanguage.ts | 7 +- apps/web/src/main.tsx | 87 +++++++++++++++++-- 4 files changed, 105 insertions(+), 12 deletions(-) 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/frontend/App.tsx b/apps/web/src/frontend/App.tsx index cc935a9..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' ? ( - + }> + + ) : ( <> diff --git a/apps/web/src/frontend/lib/foxschemaSqlLanguage.ts b/apps/web/src/frontend/lib/foxschemaSqlLanguage.ts index 213ebe5..0a5b667 100644 --- a/apps/web/src/frontend/lib/foxschemaSqlLanguage.ts +++ b/apps/web/src/frontend/lib/foxschemaSqlLanguage.ts @@ -8,7 +8,8 @@ const FLAG = '__foxschemaSqlLanguageRegistered'; type MonarchLang = Monaco.languages.IMonarchLanguage; const FENCE_END_RULE: Monaco.languages.IMonarchLanguageRule = [ - /^[ \t]*--[ \t]*@end[ \t]*$/, + // String form + `@@` so Monarch treats `@` as literal (not an attribute ref). + '^[ \\t]*--[ \\t]*@@end[ \\t]*$', { token: 'comment.fence', next: '@pop', nextEmbedded: '@pop' }, ]; @@ -55,7 +56,7 @@ export async function ensureFoxschemaSqlLanguage( ...tokenizer, root: [ [ - /^[ \t]*--[ \t]*@(?:node-typescript|node-ts|nodets|typescript|ts)[ \t]*$/, + '^[ \\t]*--[ \\t]*@@(?:node-typescript|node-ts|nodets|typescript|ts)[ \\t]*$', { token: 'comment.fence', next: '@tsEmbedded', @@ -63,7 +64,7 @@ export async function ensureFoxschemaSqlLanguage( }, ], [ - /^[ \t]*--[ \t]*@(?:javascript|js|node)[ \t]*$/, + '^[ \\t]*--[ \\t]*@@(?:javascript|js|node)[ \\t]*$', { token: 'comment.fence', next: '@jsEmbedded', diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ffaa5d4..f089d49 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,7 +13,56 @@ import './style.css' // Packaged desktop only: block the WebView inspector (no-op on web / in dev). hardenAgainstInspect() -const root = ReactDOM.createRoot(document.getElementById('app')!) +const rootEl = document.getElementById('app') +if (!rootEl) { + throw new Error('Fox Schema boot failed: #app root missing from index.html') +} + +const root = ReactDOM.createRoot(rootEl) + +function renderFatal(err: unknown) { + const message = err instanceof Error ? err.message : String(err) + const stack = err instanceof Error ? err.stack ?? '' : '' + root.render( +
+
Fox Schema failed to start
+
+        {message}
+        {stack ? `\n\n${stack}` : ''}
+      
+
+ Open DevTools → Console, or hard-refresh (Ctrl/⌘+Shift+R). +
+
, + ) +} function renderApp() { root.render( @@ -23,11 +72,27 @@ function renderApp() { ) } +/** Don't let an hung /signup/state keep the splash up forever. */ +function signupStateWithTimeout(ms = 4000): Promise<{ shown: boolean }> { + return new Promise((resolve) => { + const timer = window.setTimeout(() => resolve({ shown: true }), ms) + getSignupState() + .then((s) => { + window.clearTimeout(timer) + resolve(s) + }) + .catch(() => { + window.clearTimeout(timer) + resolve({ shown: true }) + }) + }) +} + // After the required setup gate (if any) resolves: offer the skippable // "stay in the loop" signup wizard once, then render the app. Fails open on // a network hiccup — never let this optional step block boot. async function afterSetup() { - const signup = await getSignupState().catch(() => ({ shown: true })) + const signup = await signupStateWithTimeout() if (!signup.shown) { root.render( @@ -59,7 +124,7 @@ async function boot() { initial={setup} onDone={(s: SetupState) => { setApiBase(s.api_base) - afterSetup() + void afterSetup().catch(renderFatal) }} /> , @@ -70,7 +135,19 @@ async function boot() { // Already set up (or web): resolve the API base, then render. if (setup?.api_base) setApiBase(setup.api_base) else await resolveApiBase() - afterSetup() + await afterSetup() } -boot() +window.addEventListener('error', (ev) => { + // Only replace the UI if React never mounted a real screen (boot fallback still present). + if (document.getElementById('boot-fallback')) { + renderFatal(ev.error ?? ev.message) + } +}) +window.addEventListener('unhandledrejection', (ev) => { + if (document.getElementById('boot-fallback')) { + renderFatal(ev.reason) + } +}) + +boot().catch(renderFatal) From 41bf8e3df1f3f3ea60448c1792ef4c5e0a381349 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 05:12:37 +0000 Subject: [PATCH 22/24] fix(web): restore ProfileMenu export for Vite HMR Lazy-load SettingsPanel and ship a default export so a settings-module failure can no longer empty ProfileMenu's named exports (blank page). Co-authored-by: huy.phan9 --- .../src/frontend/components/ProfileMenu.tsx | 21 ++++++++++++++----- .../src/frontend/components/TopToolbar.tsx | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) 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..e570e06 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -4,7 +4,7 @@ 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'; +import ProfileMenu from './ProfileMenu'; import { CredentialManager } from './CredentialManager'; import { MigrationHistory } from './MigrationHistory'; import { TYPE_META, TYPE_ORDER } from './SchemaTreePanel'; From c92d9913695466d24683131035a4b374576e0881 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 05:19:02 +0000 Subject: [PATCH 23/24] fix(web): accept ProfileMenu default or named export TopToolbar resolves either export shape so a stale Vite cache or mixed checkout cannot blank the app on the ProfileMenu import. Co-authored-by: huy.phan9 --- apps/web/src/frontend/components/TopToolbar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index e570e06..5862081 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -4,8 +4,11 @@ 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'; + +const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; import { MigrationHistory } from './MigrationHistory'; import { TYPE_META, TYPE_ORDER } from './SchemaTreePanel'; import type { DbObjectType } from '../lib/types'; From 381ca255946398f7116d9006b5888ead60d48a04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 05:24:33 +0000 Subject: [PATCH 24/24] chore(web): tidy ProfileMenu import order in TopToolbar Co-authored-by: huy.phan9 --- apps/web/src/frontend/components/TopToolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index 5862081..69e035a 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -7,14 +7,14 @@ import { Brand } from './Brand'; // Support both default and named exports (avoids blank-page Vite/HMR mismatches). import ProfileMenuDefault, { ProfileMenu as ProfileMenuNamed } from './ProfileMenu'; import { CredentialManager } from './CredentialManager'; - -const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; import { MigrationHistory } from './MigrationHistory'; import { TYPE_META, TYPE_ORDER } from './SchemaTreePanel'; 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 = () => {