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 && (
-