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