Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3449d59
fix(ui): keep selected connections across page refresh
cursoragent Jul 27, 2026
50a2bb4
fix(db2): restore Windows login path so tables/browse work
cursoragent Jul 27, 2026
d09f9c5
fix(sql-editor): make table blueprint entry points always visible
cursoragent Jul 27, 2026
a3eed08
fix(sql-editor): surface New table and Edit table where they cannot hide
cursoragent Jul 27, 2026
a47c774
test(e2e): open Edit table without hover group class
cursoragent Jul 27, 2026
68c8ad8
feat(blueprint): expose Indexes section testids and e2e Add-index cov…
cursoragent Jul 27, 2026
1642ad1
Fix DataGrid row contrast in light mode
cursoragent Jul 28, 2026
2c1ebd4
Strengthen light-mode DataGrid zebra contrast
cursoragent Jul 28, 2026
4f088e4
feat(sql-editor): sync Schema menu with Destination servers
cursoragent Jul 28, 2026
b5a7e67
chore(sql-editor): document Schema–Destination sync in explorer UI
cursoragent Jul 28, 2026
ffa9987
feat(sql-editor): allow Run with an empty editor
cursoragent Jul 28, 2026
f9fb7cc
feat(sql-editor): run JS/TS/Node cells without a destination
cursoragent Jul 28, 2026
c15340f
docs: persist SQL Editor session memory for agents
cursoragent Jul 28, 2026
08b9043
refactor(sql-editor): simplify Schema sync and Run enablement
cursoragent Jul 28, 2026
a3bacf8
docs: note simplify fixes in SQL Editor agent memory
cursoragent Jul 28, 2026
5885ba3
feat(sql-editor): open VIEW/PROCEDURE/FUNCTION source in the editor
cursoragent Jul 28, 2026
23d4bf0
docs: note View source for scriptable schema objects
cursoragent Jul 28, 2026
9b4b2c7
fix(web): bind Vite to IPv4 so 127.0.0.1:5173 loads
cursoragent Jul 28, 2026
c89f26e
fix(web): allow Vite port-forward hosts (blank page 403)
cursoragent Jul 28, 2026
ef91661
docs: note Vite host/allowedHosts blank-page gotcha
cursoragent Jul 28, 2026
ec4a8ec
fix(web): stop silent blank boot and harden SQL Editor load
cursoragent Jul 28, 2026
41bf8e3
fix(web): restore ProfileMenu export for Vite HMR
cursoragent Jul 28, 2026
c92d991
fix(web): accept ProfileMenu default or named export
cursoragent Jul 28, 2026
381ca25
chore(web): tidy ProfileMenu import order in TopToolbar
cursoragent Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .cursor/rules/sql-editor-agent-memory.mdc
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 9 additions & 2 deletions apps/cli/src/commands/drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise<void> {
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<void>((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) => {
Expand Down
13 changes: 9 additions & 4 deletions apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}

Expand Down
50 changes: 50 additions & 0 deletions apps/e2e/src/tests/sql-editor-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;');
Expand Down
2 changes: 1 addition & 1 deletion apps/e2e/src/tests/sql-editor-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
11 changes: 10 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
<title>Fox Schema</title>
</head>
<body>
<div id="app"></div>
<!-- Visible until React mounts. If this stays forever, JS failed to load. -->
<div id="app">
<div
id="boot-fallback"
style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#020617;color:#94a3b8;font-family:system-ui,sans-serif;font-size:14px;margin:0"
>
<div style="font-weight:700;color:#e2e8f0;font-size:18px">Fox Schema</div>
<div>Loading…</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
8 changes: 8 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DriverDetector,
buildConnectionString,
normalizeTableSchemas,
getProviderSettings,
type MigrationStep,
type ConnectionOptions,
type DbObjectType,
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 36 additions & 7 deletions apps/web/src/frontend/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -48,7 +52,9 @@ const Workspace: React.FC = () => {
<main className="flex-1 flex min-h-0 overflow-hidden">
{activeView === 'sqlEditor' ? (
<ErrorBoundary>
<SqlEditorView />
<Suspense fallback={<LoadingScreen />}>
<SqlEditorView />
</Suspense>
</ErrorBoundary>
) : (
<>
Expand All @@ -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') {
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/frontend/components/ProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -138,7 +143,13 @@ export const ProfileMenu: React.FC = () => {

{menu}

<SettingsPanel open={showSettings} onClose={() => setShowSettings(false)} />
{showSettings && (
<Suspense fallback={null}>
<SettingsPanel open={showSettings} onClose={() => setShowSettings(false)} />
</Suspense>
)}
</div>
);
};
}

export default ProfileMenu;
5 changes: 4 additions & 1 deletion apps/web/src/frontend/components/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ 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';
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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -59,7 +60,9 @@ export const ConnectionChecklist: React.FC = () => {
</span>
</label>

{connections.length === 0 ? (
{!connectionsLoaded ? (
<p className="text-[13px] font-medium text-slate-500">Loading connections…</p>
) : connections.length === 0 ? (
<p className="text-[13px] font-medium text-slate-500">
No saved connections yet — add one via the Credentials button in the toolbar.
</p>
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/frontend/components/sql-editor/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<tr
key={i}
className="hover:bg-[var(--fox-grid-bg-hover)] group border-b border-[var(--fox-grid-border-soft)]"
className={`${rowBg} hover:bg-[var(--fox-grid-bg-hover)] group border-b border-[var(--fox-grid-border-soft)]`}
style={{ height: ROW_H_PX }}
>
<td
className="sticky left-0 z-[5] px-1.5 text-center text-[10px] tabular-nums text-[var(--fox-grid-muted)] bg-[var(--fox-grid-bg)] group-hover:bg-[var(--fox-grid-bg-hover)] border-r border-[var(--fox-grid-border-soft)] select-none"
className={`sticky left-0 z-[5] px-1.5 text-center text-[10px] tabular-nums text-[var(--fox-grid-muted)] ${rowBg} group-hover:bg-[var(--fox-grid-bg-hover)] border-r border-[var(--fox-grid-border-soft)] select-none`}
style={{ width: ROW_NUM_PX, minWidth: ROW_NUM_PX }}
data-testid="sql-row-num"
>
Expand All @@ -647,7 +651,7 @@ export const DataGrid: React.FC<{
return (
<td
key={colIdx}
className={`px-3 overflow-hidden text-ellipsis ${KIND_CELL_CLASS[kind]}`}
className={`px-3 overflow-hidden text-ellipsis ${rowBg} group-hover:bg-[var(--fox-grid-bg-hover)] ${KIND_CELL_CLASS[kind]}`}
style={{ width: w, minWidth: COL_MIN_PX, maxWidth: w }}
title={title}
onContextMenu={(e) => {
Expand Down
Loading
Loading