diff --git a/.env.example b/.env.example index 51fd5eef..5b07ef79 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ # Host port to publish the app on (pick anything free — this is what you open in # the browser). Container-internal port is API_PORT below; they can differ. -PORT=3001 -API_PORT=3001 +# Default 3210 matches the CLI and avoids the busy 3000/3001 band. +PORT=3210 +API_PORT=3210 # Optional. 32-byte key that encrypts saved database passwords at rest. # Leave empty for Docker pull-and-run: the entrypoint auto-generates one into diff --git a/AGENTS.md b/AGENTS.md index 9dc31a4e..848b0f3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,8 @@ Standard commands live in `CONTRIBUTING.md` and `package.json` scripts (`npm run Node 22/24 without reinstalling — a Node switch alone does not require `npm install`. ### Running the app -- `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` +- `npm run dev` runs the Express API (`:3210`) and Vite UI (`:5173`) together; open the + UI at http://localhost:5173. API liveness: `GET http://localhost:3210/api/health` → `{"ok":true}`. Default mode is single-user (no login). - Vite is configured with `server.host: true`, `server.strictPort: true`, and `server.allowedHosts: true` so `http://127.0.0.1:5173` works (not only IPv6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5887b2cc..00bc6040 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ bash scripts/seed/seed-all.sh all # seed demo_a/demo_b schemas into each npm run dev # Express API + Vite UI (single-user mode) ``` -`npm run dev` serves the UI on **http://localhost:5173** and the API on **:3001**. +`npm run dev` serves the UI on **http://localhost:5173** and the API on **:3210**. Connection details for the seeded databases are printed by `seed-all.sh` (all use `foxuser` / `foxpass` except SQL Server/Oracle — see the script output). diff --git a/Dockerfile b/Dockerfile index 73d26ed5..8c20d9fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,7 @@ RUN mkdir -p /data \ USER fox ENV NODE_ENV=production \ - API_PORT=3001 \ + API_PORT=3210 \ STATIC_DIR=/app/apps/web/dist \ APP_DB_ENGINE=sqlite \ APP_DB_PATH=/data/foxschema.db \ @@ -73,11 +73,11 @@ ENV NODE_ENV=production \ # APP_ENCRYPTION_KEY is optional for pull-and-run: entrypoint generates one into # /data/.app_encryption_key on first boot. Set -e APP_ENCRYPTION_KEY=… to override. -EXPOSE 3001 +EXPOSE 3210 VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:'+(process.env.API_PORT||3001)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD node -e "fetch('http://127.0.0.1:'+(process.env.API_PORT||3210)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" # tsx is present in node_modules (a devDependency, kept because we run TS at # runtime). node:sqlite is flag-free on Node 24. diff --git a/README.md b/README.md index 8dc130db..3e1f18f7 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,12 @@ Cross-dialect demo: ```bash docker run -d --name foxschema \ - -p 3001:3001 \ + -p 3210:3210 \ -v foxschema_data:/data \ 5nickels/foxschema:latest ``` -Open **http://localhost:3001**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). +Open **http://localhost:3210**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). ## Supported dialects diff --git a/apps/cli/README.md b/apps/cli/README.md index 16db461d..fb57e269 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -48,7 +48,7 @@ brew install foxschema # Docker (linux/amd64, includes Db2) docker pull 5nickels/foxschema:latest -docker run -d -p 3001:3001 -v foxschema_data:/data 5nickels/foxschema:latest +docker run -d -p 3210:3210 -v foxschema_data:/data 5nickels/foxschema:latest ``` ## CLI diff --git a/apps/cli/package.json b/apps/cli/package.json index 723b1cd5..2fbc1bf5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@foxschema/cli", - "version": "0.2.48", + "version": "0.2.49", "private": true, "type": "module", "description": "Fox Schema CLI — schema diff, migrations, and a rich SQL Editor (TypeScript / Node ESM)", diff --git a/apps/cli/src/commands/__tests__/open-stale.test.ts b/apps/cli/src/commands/__tests__/open-stale.test.ts index 1a755c46..2d8e9e0e 100644 --- a/apps/cli/src/commands/__tests__/open-stale.test.ts +++ b/apps/cli/src/commands/__tests__/open-stale.test.ts @@ -120,3 +120,51 @@ describe('probeRunningVersion', () => { await expect(probeRunningVersion(3210)).resolves.toBe('0.2.10'); }); }); + +describe('resolveListenPort', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps preferred when free', async () => { + const { resolveListenPort } = await import('../open.js'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed'); + }) + ); + await expect(resolveListenPort(3210, true)).resolves.toEqual({ + port: 3210, + skippedConflict: false, + }); + }); + + it('skips to next free port when preferred is occupied by another app', async () => { + const { resolveListenPort } = await import('../open.js'); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(':3210/')) { + return new Response('other app', { status: 200 }); + } + throw new TypeError('fetch failed'); + }) + ); + await expect(resolveListenPort(3210, true)).resolves.toEqual({ + port: 3211, + skippedConflict: true, + }); + }); + + it('throws when explicit port is occupied by another app', async () => { + const { resolveListenPort } = await import('../open.js'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('other app', { status: 200 })) + ); + await expect(resolveListenPort(3210, false)).rejects.toThrow(/in use but does not look like Fox Schema/); + }); +}); diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index fe84a3c0..7ac475e0 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -69,6 +69,13 @@ export async function runDoctor(): Promise { } console.log(` ui lock pid ${managedPid || chalk.dim('(none)')}`); console.log(` ui server ${await uiServerStatus(DEFAULT_UI_PORT)}`); + if (managedPid) { + console.log( + chalk.dim( + ` tip look for process “foxschema” (or node · ui-server) · PID ${managedPid}` + ) + ); + } const coreModulesOk = typeof CompareModule === 'function' && typeof SqlGeneratorModule === 'function'; let core: string; diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index d0984197..c0a88614 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -149,6 +149,46 @@ async function waitUntilHealthy(port: number, timeoutMs = 30_000): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) }); + return true; + } catch { + return false; + } +} + +/** + * Choose a listen port. Prefer `preferred` when free or already Fox-healthy. + * If another app owns it and `allowFallback` is true, try preferred+1 … +19. + */ +export async function resolveListenPort( + preferred: number, + allowFallback: boolean +): Promise<{ port: number; skippedConflict: boolean }> { + if (await isHealthy(preferred)) { + return { port: preferred, skippedConflict: false }; + } + if (!(await isPortOccupied(preferred))) { + return { port: preferred, skippedConflict: false }; + } + if (!allowFallback) { + throw new Error( + `Port ${preferred} is in use but does not look like Fox Schema. ` + + `Stop that process, or run \`foxschema open --port \`.` + ); + } + const last = preferred + 19; + for (let p = preferred + 1; p <= last; p++) { + if (await isHealthy(p)) return { port: p, skippedConflict: true }; + if (!(await isPortOccupied(p))) return { port: p, skippedConflict: true }; + } + throw new Error( + `Ports ${preferred}–${last} are all in use. Free one, or pass \`--port \`.` + ); +} + async function waitUntilDead(pid: number, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline && isProcessAlive(pid)) { @@ -204,7 +244,17 @@ async function stopForRelaunch(port: number): Promise { * **and** matches the installed package version / has Query-files routes. */ export async function runOpen(opts: OpenOptions = {}): Promise { - const port = opts.port ?? (Number(process.env.FOXSCHEMA_PORT) || DEFAULT_UI_PORT); + const envPortRaw = process.env.FOXSCHEMA_PORT; + const envPort = envPortRaw != null && envPortRaw !== '' ? Number(envPortRaw) : NaN; + const portExplicit = opts.port != null || (Number.isFinite(envPort) && envPort > 0); + const preferred = + opts.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_UI_PORT); + const { port, skippedConflict } = await resolveListenPort(preferred, !portExplicit); + if (skippedConflict) { + console.log( + chalk.yellow(`Port ${preferred} is in use by another app — starting on ${port} instead.`) + ); + } const url = `http://localhost:${port}`; const installedVersion = readCliPackageVersion(); @@ -235,15 +285,11 @@ export async function runOpen(opts: OpenOptions = {}): Promise { `Run \`foxschema stop\` then \`foxschema open\`, or \`foxschema open --port \`.` ); } - try { - await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(800) }); + if (await isPortOccupied(port)) { throw new Error( `Port ${port} is in use but does not look like Fox Schema. ` + `Stop that process, or run \`foxschema open --port \`.` ); - } catch (e) { - if (e instanceof Error && e.message.startsWith('Port ')) throw e; - /* connection refused / timeout — free to bind */ } const keySource = ensureUiEnv(); diff --git a/apps/cli/src/runtime/paths.ts b/apps/cli/src/runtime/paths.ts index cae11753..14b48f63 100644 --- a/apps/cli/src/runtime/paths.ts +++ b/apps/cli/src/runtime/paths.ts @@ -11,5 +11,5 @@ export const PID_FILE = join(RUNTIME_DIR, 'ui-server.pid'); export const PORT_FILE = join(RUNTIME_DIR, 'ui-server.port'); export const LOCAL_KEY_FILE = join(DATA_DIR, '.app_encryption_key'); -/** Default port for the browser UI launcher (Docker stays on 3001). */ +/** Default port for the browser UI launcher (Docker / API use the same 3210). */ export const DEFAULT_UI_PORT = 3210; diff --git a/apps/cli/src/server/ui-server.ts b/apps/cli/src/server/ui-server.ts index e18d4cae..58aa4021 100644 --- a/apps/cli/src/server/ui-server.ts +++ b/apps/cli/src/server/ui-server.ts @@ -5,6 +5,14 @@ */ import { startUiServer } from '@foxschema/web/serve'; +// So Activity Monitor / `ps` / Task Manager “window title” can identify us +// (Image Name on Windows is still node.exe — see docs). +try { + process.title = 'foxschema'; +} catch { + /* ignore */ +} + const port = Number(process.env.API_PORT || process.env.PORT) || 3210; const host = process.env.LISTEN_HOST || '127.0.0.1'; const staticDir = process.env.STATIC_DIR; diff --git a/apps/web/package.json b/apps/web/package.json index b0355f1a..c1aa3c7d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@foxschema/web", "private": true, - "version": "0.2.48", + "version": "0.2.49", "type": "module", "exports": { "./package.json": "./package.json", diff --git a/apps/web/src/backend/api/server.ts b/apps/web/src/backend/api/server.ts index 2f5e173a..4044a744 100644 --- a/apps/web/src/backend/api/server.ts +++ b/apps/web/src/backend/api/server.ts @@ -18,6 +18,7 @@ import { createUserRoutes } from './user.routes'; import { createAdminRoutes } from './admin.routes'; import { createSignupRoutes } from './signup.routes'; import { createFileQueryRoutes } from './file-query.routes'; +import { DEFAULT_API_PORT } from '../defaultApiPort'; import { AppSecretsStore } from '../modules/app-secrets.module'; import { resolveAppVersion } from '../modules/updates.module'; @@ -104,7 +105,7 @@ export function createApp() { return app; } -export function startServer(port = Number(process.env.API_PORT) || 3001) { +export function startServer(port = Number(process.env.API_PORT) || DEFAULT_API_PORT) { const app = createApp(); const server = app.listen(port, () => { diff --git a/apps/web/src/backend/defaultApiPort.ts b/apps/web/src/backend/defaultApiPort.ts new file mode 100644 index 00000000..17a4f0b8 --- /dev/null +++ b/apps/web/src/backend/defaultApiPort.ts @@ -0,0 +1,6 @@ +/** + * Default listen port for the Fox Schema API / single-origin UI server. + * Shared by Docker, `npm run dev` API, and CLI (`foxschema open` uses the same). + * 3210 avoids the crowded 3000/3001 band used by many Node apps. + */ +export const DEFAULT_API_PORT = 3210; diff --git a/apps/web/src/backend/modules/updates.module.test.ts b/apps/web/src/backend/modules/updates.module.test.ts index fed4c862..7a84c139 100644 --- a/apps/web/src/backend/modules/updates.module.test.ts +++ b/apps/web/src/backend/modules/updates.module.test.ts @@ -60,7 +60,10 @@ describe('updates.module (npm publish channel)', () => { ); expect(parsed.updateAvailable).toBe(true); expect(parsed.latest).toBe('0.3.0'); - expect(parsed.url).toMatch(/npmjs\.com\/package\/foxschema\/v\/0\.3\.0/); + // “What’s new” lands on the GitHub Release page (notes from docs/RELEASE_*.md). + expect(parsed.url).toBe( + 'https://github.com/tedious-code/foxschema/releases/tag/v0.3.0' + ); }); it('parseUpdateFeed understands GitHub releases JSON', () => { diff --git a/apps/web/src/backend/modules/updates.module.ts b/apps/web/src/backend/modules/updates.module.ts index ed5c2409..051c9627 100644 --- a/apps/web/src/backend/modules/updates.module.ts +++ b/apps/web/src/backend/modules/updates.module.ts @@ -43,6 +43,11 @@ function stripV(v: string): string { return v.replace(/^v/i, '').trim(); } +/** User-facing release notes (in-app “What’s new” opens this page). */ +export function githubReleaseUrl(version: string): string { + return `https://github.com/tedious-code/foxschema/releases/tag/v${stripV(version)}`; +} + /** * Resolve the running app version: APP_VERSION env, else nearest package.json * (web → repo root → cwd). Falls back to 0.0.0 only if nothing is readable. @@ -92,21 +97,16 @@ type FeedJson = { /** Map npm / GitHub / custom feed JSON into version + link + notes. */ export function parseUpdateFeed( data: FeedJson, - feedUrl: string, + _feedUrl: string, current: string ): Pick { const latest = stripV(data.version || data.tag_name || '') || current; - const fromNpm = - data.name === NPM_PACKAGE || /registry\.npmjs\.org/i.test(feedUrl); - const npmPage = fromNpm - ? `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${latest}` - : undefined; return { latest, updateAvailable: !!latest && isNewer(latest, current), - // Prefer explicit release links; for the npm channel use the package page - // (homepage alone is not version-specific). - url: data.url || data.html_url || npmPage || data.homepage, + // Prefer explicit release links; otherwise the GitHub Release page + // (populated from docs/RELEASE_*.md) so “What’s new” shows ship notes. + url: data.url || data.html_url || githubReleaseUrl(latest) || data.homepage, notes: data.notes || data.body || data.description || undefined, }; } diff --git a/apps/web/src/backend/startUiServer.ts b/apps/web/src/backend/startUiServer.ts index 2bc76d5a..8ce44676 100644 --- a/apps/web/src/backend/startUiServer.ts +++ b/apps/web/src/backend/startUiServer.ts @@ -4,9 +4,10 @@ import http from 'node:http'; import express from 'express'; import { ConnectionFactory, setupDb2ClientEnv } from '@foxschema/db'; import { createApp } from './api/server'; +import { DEFAULT_API_PORT } from './defaultApiPort'; export interface StartUiServerOptions { - /** Listen port. Defaults to API_PORT / PORT / 3001. */ + /** Listen port. Defaults to API_PORT / PORT / DEFAULT_API_PORT (3210). */ port?: number; /** Absolute path to the Vite `dist` directory. Defaults to STATIC_DIR or apps/web/dist. */ staticDir?: string; @@ -40,7 +41,7 @@ export function startUiServer(opts: StartUiServerOptions = {}): StartedUiServer res.sendFile(join(staticDir, 'index.html')); }); - const port = opts.port ?? (Number(process.env.API_PORT || process.env.PORT) || 3001); + const port = opts.port ?? (Number(process.env.API_PORT || process.env.PORT) || DEFAULT_API_PORT); const host = opts.host ?? process.env.LISTEN_HOST ?? '0.0.0.0'; const server = app.listen(port, host); diff --git a/apps/web/src/frontend/components/ProfileMenu.tsx b/apps/web/src/frontend/components/ProfileMenu.tsx index 21f57514..3beb37de 100644 --- a/apps/web/src/frontend/components/ProfileMenu.tsx +++ b/apps/web/src/frontend/components/ProfileMenu.tsx @@ -97,6 +97,7 @@ export function ProfileMenu(): React.ReactElement | null { rel="noreferrer" onClick={() => setOpen(false)} className="w-full flex items-center gap-2 px-4 py-3 text-sm font-semibold text-amber-300 hover:bg-amber-950/20 transition cursor-pointer border-b border-slate-800" + title="Open release notes (What's new)" > Update available · v{update?.latest} diff --git a/apps/web/src/frontend/components/UpdatesSettings.tsx b/apps/web/src/frontend/components/UpdatesSettings.tsx index 3977d445..760c05e4 100644 --- a/apps/web/src/frontend/components/UpdatesSettings.tsx +++ b/apps/web/src/frontend/components/UpdatesSettings.tsx @@ -52,12 +52,25 @@ export const UpdatesSettings: React.FC = () => {

{info?.updateAvailable ? (

- v{info.latest} available on npm + v{info.latest} available + {info.url ? ( + <> + {' · '} + + What's new + + + ) : null}

) : info ? (

- {info.configured ? 'Up to date (npm)' : 'Up to date (check disabled)'} + {info.configured ? 'Up to date' : 'Up to date (check disabled)'}

) : null} diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 52a69f82..923f26f6 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -22,6 +22,7 @@ const COL_LONG_TEXT_PX = 200; /** Upper bound when double-clicking a header to fit content. */ const COL_FIT_MAX_PX = 720; const ROW_NUM_PX = 48; +const SYNC_COL_PX = 44; /** Fixed row height for windowing (must match rendered row). Off-screen pages live in pageCache LRU, not the DOM. */ const ROW_H_PX = 28; /** Taller rows for Data Peek’s larger/bolder type. */ @@ -241,9 +242,17 @@ export const DataGrid: React.FC<{ exportName?: string; refreshing?: boolean; onRefresh?: () => void; - /** Sync vertical scroll by row index with sibling grids in the same row. */ - syncScrollRow?: number | null; - onSyncScrollRow?: (rowIndex: number) => void; + /** Sync vertical scroll by pixel with sibling grids (direct DOM — no React lag). */ + scrollSyncId?: string; + scrollSync?: { + register: (id: string, apply: (scrollTop: number) => void) => () => void; + broadcast: (sourceId: string, scrollTop: number) => void; + }; + /** Sync hovered row index with sibling grids (same id as scrollSyncId). */ + hoverSync?: { + register: (id: string, apply: (rowIdx: number | null) => void) => () => void; + broadcast: (sourceId: string, rowIdx: number | null) => void; + }; /** 0-based page index for server-side paging. */ pageIndex?: number; /** Rows requested per page (Max rows / Rows/page). */ @@ -274,6 +283,11 @@ export const DataGrid: React.FC<{ * (`modified` / `missing` / `extra`), or null when unchanged. */ cellHighlight?: (rowIdx: number, colIdx: number) => CellDiffKind | null; + /** Compare migrate: per-row Sync checkbox (sticky right); null = matching row. */ + rowSync?: { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = React.memo( ({ result, @@ -281,8 +295,9 @@ export const DataGrid: React.FC<{ exportName = 'query-result', refreshing, onRefresh, - syncScrollRow, - onSyncScrollRow, + scrollSyncId, + scrollSync, + hoverSync, pageIndex = 0, pageSize, hasPrevPage, @@ -297,6 +312,7 @@ export const DataGrid: React.FC<{ toolbarExtra, emphasis = false, cellHighlight, + rowSync, }) => { const upsertVariable = useSqlEditorStore((s) => s.upsertVariable); const rowH = emphasis ? ROW_H_EMPHASIS_PX : ROW_H_PX; @@ -328,7 +344,9 @@ export const DataGrid: React.FC<{ const scrollRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [viewportH, setViewportH] = useState(320); + const [hoverRow, setHoverRow] = useState(null); const rafRef = useRef(0); + /** True while this grid's scrollTop is being driven by a peer (skip re-broadcast). */ const syncLock = useRef(false); const colKey = sourceColumns.join('\0'); @@ -344,6 +362,7 @@ export const DataGrid: React.FC<{ setDragFrom(null); setDragOver(null); setScrollTop(0); + setHoverRow(null); if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [colKey]); @@ -357,29 +376,56 @@ export const DataGrid: React.FC<{ return () => ro.disconnect(); }, [result.ok, sourceColumns.length]); + // Register with the side-by-side scroll bus: peers set our DOM scrollTop + // directly (no React state round-trip) so fast scrolls stay locked. useEffect(() => { - if (syncScrollRow == null || !scrollRef.current) return; - const target = syncScrollRow * rowH; - if (Math.abs(scrollRef.current.scrollTop - target) < 2) return; - syncLock.current = true; - scrollRef.current.scrollTop = target; - setScrollTop(target); - requestAnimationFrame(() => { - syncLock.current = false; - }); - }, [syncScrollRow, rowH]); + if (!scrollSync || !scrollSyncId) return; + const apply = (top: number) => { + const el = scrollRef.current; + if (!el) return; + if (Math.abs(el.scrollTop - top) < 0.5) return; + syncLock.current = true; + el.scrollTop = top; + // Match leader: DOM is live; virtualization state updates once per frame. + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + setScrollTop(top); + // Unlock after the programmatic scroll event has had a chance to fire. + requestAnimationFrame(() => { + syncLock.current = false; + }); + }); + }; + return scrollSync.register(scrollSyncId, apply); + }, [scrollSync, scrollSyncId]); + + // Peer hover row — local state only (no parent re-render). + useEffect(() => { + if (!hoverSync || !scrollSyncId) return; + return hoverSync.register(scrollSyncId, setHoverRow); + }, [hoverSync, scrollSyncId]); + + const publishHoverRow = useCallback( + (rowIdx: number | null) => { + setHoverRow(rowIdx); + if (hoverSync && scrollSyncId) hoverSync.broadcast(scrollSyncId, rowIdx); + }, + [hoverSync, scrollSyncId] + ); const onScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; + // Broadcast immediately (pixel-accurate) so peers track during fast flings. + if (!syncLock.current && scrollSync && scrollSyncId) { + scrollSync.broadcast(scrollSyncId, el.scrollTop); + } + // Throttle only the local virtualization state update. cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { setScrollTop(el.scrollTop); - if (!syncLock.current && onSyncScrollRow) { - onSyncScrollRow(Math.floor(el.scrollTop / rowH)); - } }); - }, [onSyncScrollRow, rowH]); + }, [scrollSync, scrollSyncId]); useEffect(() => () => cancelAnimationFrame(rafRef.current), []); @@ -505,9 +551,12 @@ export const DataGrid: React.FC<{ const order = colOrder.length === sourceColumns.length ? colOrder : identityOrder(sourceColumns.length); const orderedColumns = order.map((i) => sourceColumns[i]!); + const syncColPx = rowSync ? SYNC_COL_PX : 0; const tableWidth = - ROW_NUM_PX + order.reduce((sum, i) => sum + (colWidths[i] ?? COL_DEFAULT_PX), 0); - const colCount = 1 + order.length; + ROW_NUM_PX + + order.reduce((sum, i) => sum + (colWidths[i] ?? COL_DEFAULT_PX), 0) + + syncColPx; + const colCount = 1 + order.length + (rowSync ? 1 : 0); const totalRows = sourceRows.length; const start = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN); @@ -537,6 +586,9 @@ export const DataGrid: React.FC<{ className="fox-sql-grid flex-1 min-h-0 border border-[var(--fox-grid-border)] rounded-lg shadow-sm bg-[var(--fox-grid-bg)] text-[var(--fox-grid-ink)]" style={{ overflowX: 'auto', overflowY: 'auto' }} onScroll={onScroll} + onMouseLeave={() => { + if (hoverRow !== null) publishHoverRow(null); + }} onContextMenu={(e) => { // Empty area / row-number context: save whole result as table. if ((e.target as HTMLElement).closest('td, th')) return; @@ -566,6 +618,9 @@ export const DataGrid: React.FC<{ }} /> ))} + {rowSync ? ( + + ) : null} @@ -674,6 +729,18 @@ export const DataGrid: React.FC<{ ); })} + {rowSync ? ( + + Sync + + ) : null} @@ -688,11 +755,14 @@ export const DataGrid: React.FC<{ const absRow = pageIndex * size + i + 1; const stripe = i % 2 === 1; const selected = selectedRowIndex === i; + const rowHovered = hoverRow === i; const rowBg = selected ? 'bg-amber-500/15' - : stripe - ? 'bg-[var(--fox-grid-bg-stripe)]' - : 'bg-[var(--fox-grid-bg)]'; + : rowHovered + ? 'bg-[var(--fox-grid-bg-hover)]' + : stripe + ? 'bg-[var(--fox-grid-bg-stripe)]' + : 'bg-[var(--fox-grid-bg)]'; return ( onSelectRow?.(i)} + onMouseEnter={() => { + if (hoverRow !== i) publishHoverRow(i); + }} > ); })} + {rowSync ? ( + + {(() => { + const syncVal = rowSync.isChecked(i); + if (syncVal === null) { + return ( + + — + + ); + } + return ( + { + e.stopPropagation(); + rowSync.onToggle(i, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + className="rounded border-sky-500/60 accent-sky-500" + title="Include in migrate" + /> + ); + })()} + + ) : null} ); })} diff --git a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx index c5730264..f413ad2f 100644 --- a/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataMigrateBar.tsx @@ -6,9 +6,9 @@ * Side-by-side data migrate: key-based insert/update/delete onto a destination * grid (≤500 ops). Larger sets toast with Server Beam instructions. */ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ArrowRightLeft, History, Loader2, X } from 'lucide-react'; +import { ArrowRightLeft, CheckCheck, History, Loader2, X } from 'lucide-react'; import { apiExecuteDataMigrate, apiFinishDataMigrate, @@ -21,8 +21,10 @@ import { } from '../../api/dataMigrateApi'; import { buildDataMigratePlans, buildDestSnapshotJson } from '../../lib/dataMigratePlans'; import { + allDiffKeyLabels, classifyRowsByKey, DATA_MIGRATE_ROW_CAP, + filterOpsByKeyLabels, migrateGridsAreComplete, selectMigrateOps, type ClassifiedRowDiff, @@ -65,6 +67,9 @@ interface Props { /** Controlled key columns (shared with Compare alignment). */ keyNames?: string[]; onKeyNamesChange?: (names: string[]) => void; + /** Row Sync checkboxes — which differing keys to include in migrate. */ + selectedSyncKeys?: ReadonlySet; + onSelectedSyncKeysChange?: (keys: Set) => void; onAfterMigrate?: () => void; onOpenServerBeamSample?: () => void; } @@ -74,8 +79,10 @@ export const DataMigrateBar: React.FC = ({ source, dest, ignoreColumns = [], - keyNames: keyNamesProp, + keyNames: keyNamesProp = [], onKeyNamesChange, + selectedSyncKeys = new Set(), + onSelectedSyncKeysChange, onAfterMigrate, onOpenServerBeamSample, }) => { @@ -96,9 +103,7 @@ export const DataMigrateBar: React.FC = ({ const table: TableSchema | undefined = editTarget.ok ? editTarget.table : undefined; const tableName = table?.name ?? ''; - // Only PK / non-partial unique keys that appear in the result — never fall back - // to "first column" (non-unique WHERE would UPDATE/DELETE multiple rows). - const keyNames = useMemo( + const preferredKeyNames = useMemo( () => resolvePeekKeyColumns(table, source.columns) .filter((k) => k.resultIndex >= 0) @@ -106,6 +111,26 @@ export const DataMigrateBar: React.FC = ({ [table, source.columns] ); + const sharedColumns = useMemo(() => { + const destLower = new Set(dest.columns.map((c) => c.toLowerCase())); + return source.columns.filter((c) => destLower.has(c.toLowerCase())); + }, [source.columns, dest.columns]); + + const keyNames = keyNamesProp; + + const toggleKeyColumn = (col: string) => { + if (!onKeyNamesChange) return; + const lower = col.toLowerCase(); + const has = keyNames.some((k) => k.toLowerCase() === lower); + if (has) { + const next = keyNames.filter((k) => k.toLowerCase() !== lower); + if (next.length === 0) return; + onKeyNamesChange(next); + } else { + onKeyNamesChange([...keyNames, col]); + } + }; + /** User opts into each op — nothing selected until they choose. */ const [doInsert, setDoInsert] = useState(false); const [doUpdate, setDoUpdate] = useState(false); @@ -138,6 +163,17 @@ export const DataMigrateBar: React.FC = ({ [source.columns, source.rows, dest.columns, dest.rows, keyNames, ignoreColumns] ); + const diffLabelsKey = useMemo( + () => allDiffKeyLabels(classification).join('\0'), + [classification] + ); + + useEffect(() => { + if (!onSelectedSyncKeysChange || !diffLabelsKey) return; + const labels = diffLabelsKey.split('\0').filter(Boolean); + onSelectedSyncKeysChange(new Set(labels)); + }, [diffLabelsKey, onSelectedSyncKeysChange]); + const selected = useMemo( () => selectMigrateOps(classification, { @@ -148,6 +184,16 @@ export const DataMigrateBar: React.FC = ({ [classification, doInsert, doUpdate, doDelete] ); + const filtered = useMemo( + () => filterOpsByKeyLabels(selected.ops, selectedSyncKeys, DATA_MIGRATE_ROW_CAP), + [selected.ops, selectedSyncKeys] + ); + + const syncAll = () => { + if (!onSelectedSyncKeysChange) return; + onSelectedSyncKeysChange(new Set(allDiffKeyLabels(classification))); + }; + const openHistory = async () => { setHistoryOpen(true); try { @@ -210,6 +256,16 @@ export const DataMigrateBar: React.FC = ({ toast({ tone: 'info', title: 'Nothing to migrate', body: 'Grids match for the selected ops.' }); return; } + if (filtered.uncappedCount === 0) { + toast({ + tone: 'warning', + title: 'No rows selected for Sync', + body: + 'You chose Add / Edit / Delete but no differing rows are checked. ' + + 'Use the Sync column on the destination grid or click Sync all.', + }); + return; + } if (classification.duplicateKeys > 0) { toast({ tone: 'warning', @@ -220,12 +276,12 @@ export const DataMigrateBar: React.FC = ({ }); return; } - if (selected.uncappedCount > DATA_MIGRATE_ROW_CAP) { + if (filtered.uncappedCount > DATA_MIGRATE_ROW_CAP) { toast({ tone: 'warning', title: `Over ${DATA_MIGRATE_ROW_CAP} row ops — use Server Beam`, body: - `This compare has ${selected.uncappedCount} insert/update/delete ops. ` + + `This compare has ${filtered.uncappedCount} insert/update/delete ops. ` + `Side-by-side migrate is limited to ${DATA_MIGRATE_ROW_CAP} rows. ` + 'Check source then target Destinations, turn Safe mode off, and run the ' + 'Server Beam chunked sample (Bookmarks → Add samples).', @@ -242,7 +298,7 @@ export const DataMigrateBar: React.FC = ({ sourceColumns: source.columns, destColumns: dest.columns, keyNames, - ops: selected.ops, + ops: filtered.ops, includeIdentity, identityColumns: editability.identityColumns, ignoreColumns, @@ -258,7 +314,7 @@ export const DataMigrateBar: React.FC = ({ const snapshotJson = buildDestSnapshotJson({ destColumns: dest.columns, - ops: selected.ops, + ops: filtered.ops, }); const script = [ `-- useTransaction=${useTransaction} continueOnError=${continueOnError}`, @@ -304,7 +360,6 @@ export const DataMigrateBar: React.FC = ({ let rolledBack = false; try { - // Mark all running while the server applies (one connection / optional tx). setProgress((prev) => prev?.map((p) => ({ ...p, status: 'running' })) ?? prev); const out = await apiExecuteDataMigrate( { @@ -395,91 +450,133 @@ export const DataMigrateBar: React.FC = ({ if (!canCompareReady(source, dest)) return null; + const migrateCount = filtered.uncappedCount; + const overCap = migrateCount > DATA_MIGRATE_ROW_CAP; + return (
-
- - +
+ + Data migrate - + {source.label} → {dest.label} - + {classification.inserts.length} add · {classification.updates.length} edit ·{' '} {classification.deletes.length} delete available - {selected.uncappedCount > DATA_MIGRATE_ROW_CAP - ? ` · capped ${DATA_MIGRATE_ROW_CAP}` + {selected.uncappedCount > migrateCount + ? ` · ${migrateCount} synced` : ''} + {overCap ? ` · capped ${DATA_MIGRATE_ROW_CAP}` : ''}
-
- Keys - {keyNames.length > 0 ? ( - keyNames.map((c) => ( - - {c} - - )) +
+ Keys + {sharedColumns.length > 0 ? ( + sharedColumns.map((col) => { + const checked = keyNames.some((k) => k.toLowerCase() === col.toLowerCase()); + const preferred = preferredKeyNames.some((k) => k.toLowerCase() === col.toLowerCase()); + return ( + + ); + }) ) : ( - {editability.reason || 'No PK/unique key in this result — migrate disabled.'} + No shared columns between source and destination grids. )} + {keyNames.length === 0 && sharedColumns.length > 0 && ( + Pick at least one key column. + )} + {preferredKeyNames.length === 0 && editability.reason && ( + {editability.reason} + )}
-
- +
+ Ops -
-
+
Safety @@ -533,35 +630,40 @@ export const DataMigrateBar: React.FC = ({ applying || !canDml || selected.uncappedCount === 0 || + migrateCount === 0 || classification.duplicateKeys > 0 || - selected.uncappedCount > DATA_MIGRATE_ROW_CAP + overCap } onClick={() => void apply()} - className="ml-auto px-2 py-0.5 rounded bg-cyan-700/40 border border-cyan-500/40 text-cyan-200 hover:bg-cyan-600/50 disabled:opacity-40 disabled:cursor-not-allowed" + className="ml-auto px-3 py-1 rounded-md bg-cyan-600/50 border border-cyan-400/50 text-sm font-bold text-cyan-100 hover:bg-cyan-500/60 disabled:opacity-40 disabled:cursor-not-allowed shadow-sm shadow-cyan-500/20" title={ selected.uncappedCount === 0 ? 'Select Add, Edit, and/or Delete first' - : selected.uncappedCount > DATA_MIGRATE_ROW_CAP - ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam` - : undefined + : migrateCount === 0 + ? 'Check rows in the Sync column' + : overCap + ? `Over ${DATA_MIGRATE_ROW_CAP} ops — use Server Beam` + : undefined } > {applying ? ( - - Migrating… + + Migrating… - ) : selected.uncappedCount > DATA_MIGRATE_ROW_CAP ? ( + ) : overCap ? ( `Over ${DATA_MIGRATE_ROW_CAP} — Server Beam` ) : selected.uncappedCount === 0 ? ( 'Select ops to migrate' + ) : migrateCount === 0 ? ( + 'Select Sync rows' ) : ( - `Migrate ${selected.uncappedCount} ops` + `Migrate ${migrateCount} ops` )}
{!editTarget.ok && ( -

+

Migrate needs a single-table SELECT with schema loaded on the destination.

)} diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 4b26c5e0..54159727 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -118,25 +118,38 @@ const ResultGridPane: React.FC<{ onRefresh?: (connectionId: string) => void; onPage?: Props['onPage']; pageState?: Props['pageState']; - syncScrollRow?: number | null; - onSyncScrollRow?: (row: number | null) => void; + scrollSyncId?: string; + scrollSync?: { + register: (id: string, apply: (scrollTop: number) => void) => () => void; + broadcast: (sourceId: string, scrollTop: number) => void; + }; + hoverSync?: { + register: (id: string, apply: (rowIdx: number | null) => void) => () => void; + broadcast: (sourceId: string, rowIdx: number | null) => void; + }; /** Cross-connection compare highlights for this grid. */ diffSummary?: GridDiffSummary | null; /** Suffix shown after the grid label (e.g. original / N differ). */ compareBadge?: string | null; /** Key-aligned compare remaps rows — disable inline CRUD to avoid wrong targets. */ compareLocked?: boolean; + rowSync?: { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = ({ item, refreshing, onRefresh, onPage, pageState, - syncScrollRow = null, - onSyncScrollRow, + scrollSyncId, + scrollSync, + hoverSync, diffSummary = null, compareBadge = null, compareLocked = false, + rowSync, }) => { const schemaCache = useSqlEditorStore((s) => s.schemaCache); const openDataPeekFromFk = useSqlEditorStore((s) => s.openDataPeekFromFk); @@ -267,8 +280,9 @@ const ResultGridPane: React.FC<{ exportName={item.exportName} refreshing={refreshing} onRefresh={onRefresh ? () => onRefresh(item.connectionId) : undefined} - syncScrollRow={onSyncScrollRow ? syncScrollRow : null} - onSyncScrollRow={onSyncScrollRow} + scrollSyncId={scrollSyncId} + scrollSync={scrollSync} + hoverSync={hoverSync} pageIndex={pageIndex} pageSize={page?.pageSize} hasPrevPage={!refreshing && Boolean(page) && pageIndex > 0} @@ -300,6 +314,7 @@ const ResultGridPane: React.FC<{ onSelectRow={crud.onSelectRow} toolbarExtra={toolbarExtra} cellHighlight={cellHighlight} + rowSync={rowSync} /> {linkColumns && linkColumns.size > 0 && (

void; onPage?: Props['onPage']; pageState?: Props['pageState']; - syncScrollRow?: number | null; - onSyncScrollRow?: (row: number | null) => void; + scrollSyncId?: string; + scrollSync?: { + register: (id: string, apply: (scrollTop: number) => void) => () => void; + broadcast: (sourceId: string, scrollTop: number) => void; + }; + hoverSync?: { + register: (id: string, apply: (rowIdx: number | null) => void) => () => void; + broadcast: (sourceId: string, rowIdx: number | null) => void; + }; diffSummary?: GridDiffSummary | null; compareBadge?: string | null; compareLocked?: boolean; + rowSync?: { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + }; }> = ({ item, refreshing, onRefresh, onPage, pageState, - syncScrollRow = null, - onSyncScrollRow, + scrollSyncId, + scrollSync, + hoverSync, diffSummary = null, compareBadge = null, compareLocked = false, + rowSync, }) => { if (item.kind === 'grid') { return ( @@ -345,11 +373,13 @@ const PaneBody: React.FC<{ onRefresh={onRefresh} onPage={onPage} pageState={pageState} - syncScrollRow={syncScrollRow} - onSyncScrollRow={onSyncScrollRow} + scrollSyncId={scrollSyncId} + scrollSync={scrollSync} + hoverSync={hoverSync} diffSummary={diffSummary} compareBadge={compareBadge} compareLocked={compareLocked} + rowSync={rowSync} /> ); } @@ -412,6 +442,14 @@ const ResizablePaneRow: React.FC<{ badgeByConnection?: Record; /** Key-aligned compare remaps rows — lock inline CRUD. */ compareLocked?: boolean; + /** Per connectionId: row Sync column (destination grid). */ + rowSyncByConnection?: Record< + string, + { + isChecked: (rowIdx: number) => boolean | null; + onToggle: (rowIdx: number, checked: boolean) => void; + } + >; }> = ({ items, rowKey, @@ -422,12 +460,50 @@ const ResizablePaneRow: React.FC<{ diffByConnection, badgeByConnection, compareLocked = false, + rowSyncByConnection, }) => { const rowRef = useRef(null); const [widths, setWidths] = useState(() => items.map(() => PANE_DEFAULT_PX)); const [rowHeight, setRowHeight] = useState(PANE_DEFAULT_H_PX); - const [syncRow, setSyncRow] = useState(null); const sizedForKey = useRef(null); + /** Peer scrollTop bus — pixel sync without React re-renders (avoids lag on fast scroll). */ + const scrollPeersRef = useRef(new Map void>()); + const scrollSync = useMemo( + () => ({ + register: (id: string, apply: (scrollTop: number) => void) => { + scrollPeersRef.current.set(id, apply); + return () => { + scrollPeersRef.current.delete(id); + }; + }, + broadcast: (sourceId: string, scrollTop: number) => { + for (const [id, apply] of scrollPeersRef.current) { + if (id === sourceId) continue; + apply(scrollTop); + } + }, + }), + [] + ); + /** Peer hover-row bus — highlights the same row index across side-by-side grids. */ + const hoverPeersRef = useRef(new Map void>()); + const hoverSync = useMemo( + () => ({ + register: (id: string, apply: (rowIdx: number | null) => void) => { + hoverPeersRef.current.set(id, apply); + return () => { + hoverPeersRef.current.delete(id); + }; + }, + broadcast: (sourceId: string, rowIdx: number | null) => { + for (const [id, apply] of hoverPeersRef.current) { + if (id === sourceId) continue; + apply(rowIdx); + } + }, + }), + [] + ); useLayoutEffect(() => { const el = rowRef.current; @@ -440,7 +516,8 @@ const ResizablePaneRow: React.FC<{ if (sizedForKey.current === rowKey) return; sizedForKey.current = rowKey; setWidths(equalWidths(items.length, w)); - setSyncRow(null); + for (const apply of scrollPeersRef.current.values()) apply(0); + for (const apply of hoverPeersRef.current.values()) apply(null); }; applyEqual(); @@ -492,7 +569,7 @@ const ResizablePaneRow: React.FC<{ if (items.length === 0) return null; - const syncScroll = items.filter((x) => x.kind === 'grid').length > 1; + const enableScrollSync = items.filter((x) => x.kind === 'grid').length > 1; return (

@@ -514,11 +591,13 @@ const ResizablePaneRow: React.FC<{ onRefresh={onRefresh} onPage={onPage} pageState={pageState} - syncScrollRow={syncScroll ? syncRow : null} - onSyncScrollRow={syncScroll ? setSyncRow : undefined} + scrollSyncId={enableScrollSync ? item.connectionId : undefined} + scrollSync={enableScrollSync ? scrollSync : undefined} + hoverSync={enableScrollSync ? hoverSync : undefined} diffSummary={diffByConnection?.[item.connectionId] ?? null} compareBadge={badgeByConnection?.[item.connectionId] ?? null} compareLocked={compareLocked} + rowSync={rowSyncByConnection?.[item.connectionId]} />
(''); /** Shared with Data migrate — Compare aligns rows by these keys. */ const [keyNames, setKeyNames] = useState([]); + /** Row Sync checkboxes — which differing keys to include in migrate. */ + const [selectedSyncKeys, setSelectedSyncKeys] = useState>(() => new Set()); const schemaCache = useSqlEditorStore((s) => s.schemaCache); const connections = useSyncStore((s) => s.connections); @@ -801,6 +882,11 @@ const SideBySideStatementSection: React.FC<{ if (keyAligned.insertCount > 0) legendBits.push(`${keyAligned.insertCount} add`); if (keyAligned.deleteCount > 0) legendBits.push(`${keyAligned.deleteCount} delete`); if (keyAligned.matchCount > 0) legendBits.push(`${keyAligned.matchCount} match`); + if (keyAligned.duplicateKeys > 0) { + legendBits.push( + `⚠ ${keyAligned.duplicateKeys} duplicate key${keyAligned.duplicateKeys === 1 ? '' : 's'} skipped` + ); + } if (triggerIgnoreColumns.length > 0) { legendBits.push(`skipping ${triggerIgnoreColumns.join(', ')}`); } @@ -938,6 +1024,31 @@ const SideBySideStatementSection: React.FC<{ ignoreOpts, ]); + const rowSyncByConnection = useMemo(() => { + if (!compareActive || !keyAligned || !destId) return undefined; + return { + [destId]: { + isChecked: (rowIdx: number): boolean | null => { + const op = keyAligned.rowOps[rowIdx]; + if (op === 'match') return null; + const label = keyAligned.rowKeyLabels[rowIdx]; + if (!label) return false; + return selectedSyncKeys.has(label); + }, + onToggle: (rowIdx: number, checked: boolean) => { + const label = keyAligned.rowKeyLabels[rowIdx]; + if (!label) return; + setSelectedSyncKeys((prev) => { + const next = new Set(prev); + if (checked) next.add(label); + else next.delete(label); + return next; + }); + }, + }, + }; + }, [compareActive, keyAligned, destId, selectedSyncKeys]); + const insertServerBeamSample = () => { const sample = buildSampleBookmarks().find((b) => b.id === 'sample-server-beam-chunked'); if (!sample) return; @@ -955,33 +1066,33 @@ const SideBySideStatementSection: React.FC<{
{canCompare && (
-