diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index bebeaebd..2f8a6bf3 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -253,6 +253,27 @@ export class SqlEditorPage { await clickWhen(this.page, '[data-testid="sql-layout-by-credential"]'); } + /** Side-by-side Compare toolbar for statement index (default 0). */ + compareToggle(statementIndex = 0) { + return this.page.locator(`[data-testid="sql-result-compare-toggle-${statementIndex}"]`); + } + + compareLegend(statementIndex = 0) { + return this.page.locator(`[data-testid="sql-result-compare-legend-${statementIndex}"]`); + } + + compareBaselineSelect(statementIndex = 0) { + return this.page.locator(`[data-testid="sql-result-compare-baseline-${statementIndex}"]`); + } + + /** Count cells marked with a data-diff attribute in the results panel. */ + async diffCellCount(kind?: 'modified' | 'missing' | 'extra'): Promise { + const sel = kind + ? `[data-testid="sql-results-side-by-side"] td[data-diff="${kind}"]` + : '[data-testid="sql-results-side-by-side"] td[data-diff]'; + return this.page.locator(sel).count(); + } + async tabCount(): Promise { return this.page.locator('[data-testid="sql-editor-tabs"] [role="tab"]').count(); } diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts index a09801bb..5c829a0f 100644 --- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -9,6 +9,7 @@ import { mkdirSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Page } from 'playwright'; import { buildDriver, quitDriver } from '../helpers/driver.js'; +import { saveScreenshot, saveSeoScreenshot } from '../helpers/screenshot.js'; import { AppPage } from '../pages/AppPage.js'; import { SqlEditorPage } from '../pages/SqlEditorPage.js'; @@ -102,6 +103,55 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { await sql.setLayoutByCredential(); }); + it('compares seeded rows across credentials with colored cell diffs', async () => { + // Seeds: id=1 is Alice vs Bob (modified); id=2 Shared/Austin matches. + await sql.openView(); + await sql.checkConnection(NAME_A); + await sql.checkConnection(NAME_B); + await sql.setSql('SELECT id, name, city FROM customers ORDER BY id;'); + await sql.run(); + await sql.waitForResults(); + await sql.setLayoutSideBySide(); + + await driver.waitForSelector('[data-testid="sql-result-compare-toggle-0"]', { + timeout: 10_000, + }); + expect(await sql.compareToggle(0).isChecked()).toBe(true); + expect(await sql.compareLegend(0).isVisible()).toBe(true); + expect(await sql.compareBaselineSelect(0).isVisible()).toBe(true); + + // Wait for highlight pass after Compare defaults on. + await driver.waitForFunction( + () => + document.querySelectorAll('[data-testid="sql-results-side-by-side"] td[data-diff]').length > + 0, + { timeout: 10_000 } + ); + + const modified = await sql.diffCellCount('modified'); + const anyDiff = await sql.diffCellCount(); + expect(modified).toBeGreaterThanOrEqual(2); // name cell tinted on both grids + expect(anyDiff).toBeGreaterThanOrEqual(modified); + + const results = await sql.resultsText(); + expect(results).toMatch(/baseline/i); + expect(results).toMatch(/differ|match/i); + + // Capture the colored compare view for the PR / walkthrough. + await saveScreenshot(driver, 'sql-editor-data-compare'); + await saveSeoScreenshot(driver, 'sql-editor-data-compare'); + + // Toggle Compare off → highlights clear. + await sql.compareToggle(0).click(); + await driver.waitForFunction( + () => + document.querySelectorAll('[data-testid="sql-results-side-by-side"] td[data-diff]') + .length === 0, + { timeout: 10_000 } + ); + expect(await sql.diffCellCount()).toBe(0); + }); + it('shows the statement strip for multi-statement SQL', async () => { await sql.setSql('SELECT 1 AS n;\nSELECT name FROM customers WHERE id = 1;'); expect(await sql.statementStripVisible()).toBe(true); diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index a95562b0..52a69f82 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, Download, GripVertical, RefreshCw } from 'lucide-react'; import type { SqlStatementResult } from '../../api/sqlApi'; +import { CELL_DIFF_CLASS, type CellDiffKind } from '../../lib/resultDataDiff'; import { columnToListValues, rowsForTableVariable } from '../../lib/sql-variables'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { downloadCsv } from '../../utils/exportCsv'; @@ -268,6 +269,11 @@ export const DataGrid: React.FC<{ * compact grid so side-by-side compares stay dense. */ emphasis?: boolean; + /** + * Cross-connection data compare: return a highlight kind for a cell + * (`modified` / `missing` / `extra`), or null when unchanged. + */ + cellHighlight?: (rowIdx: number, colIdx: number) => CellDiffKind | null; }> = React.memo( ({ result, @@ -290,6 +296,7 @@ export const DataGrid: React.FC<{ onSelectRow, toolbarExtra, emphasis = false, + cellHighlight, }) => { const upsertVariable = useSqlEditorStore((s) => s.upsertVariable); const rowH = emphasis ? ROW_H_EMPHASIS_PX : ROW_H_PX; @@ -710,14 +717,18 @@ export const DataGrid: React.FC<{ const w = colWidths[colIdx] ?? COL_DEFAULT_PX; const { text, title, isNull } = cellDisplay(cell); const kind = isNull ? 'null' : (colKinds[colIdx] ?? 'string'); + const hl = cellHighlight?.(i, colIdx) ?? null; + const hlClass = hl ? CELL_DIFF_CLASS[hl] : ''; + const cellBg = hlClass || `${rowBg} group-hover:bg-[var(--fox-grid-bg-hover)]`; return ( { e.preventDefault(); setMenu({ diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 8b3504e8..70e5e9f8 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 * * Results for one SQL Editor tab (By cred / Side-by-side layouts). + * Side-by-side can Compare cell values across credentials (colored diffs). * Foreign-key cells can open Data Peek when schema FKs match the statement. * Single-table SELECT grids support add / edit / clone / delete (same DML path * as Data Peek) when the primary key is present in the result columns. */ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, Database, AlertCircle, GripVertical, RefreshCw } from 'lucide-react'; +import { Loader2, Database, AlertCircle, GripVertical, RefreshCw, GitCompare } from 'lucide-react'; import { useSqlEditorStore, type CredentialRun } from '../../store/useSqlEditorStore'; import { useSyncStore } from '../../store/useSyncStore'; import type { ResultsLayout } from '../../store/sqlEditorTabLogic'; @@ -18,6 +19,12 @@ import type { SqlStatementResult } from '../../api/sqlApi'; import { detectCodeCell } from '../../lib/codeCellRunner'; import { CODE_CELL_KIND_LABEL } from '../../lib/sql-splitter'; import { foreignKeyLinksFor, foreignKeyLinksForSql, singleTableForResultEdit } from '../../lib/tablePreview'; +import { + cellDiffKey, + compareResultGrids, + type CellDiffKind, + type GridDiffSummary, +} from '../../lib/resultDataDiff'; import { usePeekGridCrud } from './usePeekGridCrud'; import { SQL_ICON_STROKE } from './sqlIconStyle'; @@ -105,6 +112,10 @@ const ResultGridPane: React.FC<{ pageState?: Props['pageState']; syncScrollRow?: number | null; onSyncScrollRow?: (row: number | null) => void; + /** Cross-connection compare highlights for this grid. */ + diffSummary?: GridDiffSummary | null; + /** Suffix shown after the grid label (e.g. baseline / N differ). */ + compareBadge?: string | null; }> = ({ item, refreshing, @@ -113,6 +124,8 @@ const ResultGridPane: React.FC<{ pageState, syncScrollRow = null, onSyncScrollRow, + diffSummary = null, + compareBadge = null, }) => { const schemaCache = useSqlEditorStore((s) => s.schemaCache); const openDataPeekFromFk = useSqlEditorStore((s) => s.openDataPeekFromFk); @@ -222,12 +235,21 @@ const ResultGridPane: React.FC<{ ); + const cellHighlight = useMemo(() => { + if (!diffSummary || diffSummary.cells.size === 0) return undefined; + const cells = diffSummary.cells; + return (rowIdx: number, colIdx: number): CellDiffKind | null => + cells.get(cellDiffKey(rowIdx, colIdx)) ?? null; + }, [diffSummary]); + + const gridLabel = compareBadge ? `${item.label} · ${compareBadge}` : item.label; + return (
{crud.writeErrorBanner} onRefresh(item.connectionId) : undefined} @@ -263,6 +285,7 @@ const ResultGridPane: React.FC<{ selectedRowIndex={crud.selectedRowIndex} onSelectRow={crud.onSelectRow} toolbarExtra={toolbarExtra} + cellHighlight={cellHighlight} /> {linkColumns && linkColumns.size > 0 && (

void; + diffSummary?: GridDiffSummary | null; + compareBadge?: string | null; }> = ({ item, refreshing, @@ -293,6 +318,8 @@ const PaneBody: React.FC<{ pageState, syncScrollRow = null, onSyncScrollRow, + diffSummary = null, + compareBadge = null, }) => { if (item.kind === 'grid') { return ( @@ -304,6 +331,8 @@ const PaneBody: React.FC<{ pageState={pageState} syncScrollRow={syncScrollRow} onSyncScrollRow={onSyncScrollRow} + diffSummary={diffSummary} + compareBadge={compareBadge} /> ); } @@ -360,7 +389,20 @@ const ResizablePaneRow: React.FC<{ onRefresh?: (connectionId: string) => void; onPage?: Props['onPage']; pageState?: Props['pageState']; -}> = ({ items, rowKey, refreshing, onRefresh, onPage, pageState }) => { + /** Per connectionId: cell diff summary when Compare is on. */ + diffByConnection?: Record; + /** Per connectionId: label badge (baseline / N differ). */ + badgeByConnection?: Record; +}> = ({ + items, + rowKey, + refreshing, + onRefresh, + onPage, + pageState, + diffByConnection, + badgeByConnection, +}) => { const rowRef = useRef(null); const [widths, setWidths] = useState(() => items.map(() => PANE_DEFAULT_PX)); const [rowHeight, setRowHeight] = useState(PANE_DEFAULT_H_PX); @@ -454,6 +496,8 @@ const ResizablePaneRow: React.FC<{ pageState={pageState} syncScrollRow={syncScroll ? syncRow : null} onSyncScrollRow={syncScroll ? setSyncRow : undefined} + diffSummary={diffByConnection?.[item.connectionId] ?? null} + compareBadge={badgeByConnection?.[item.connectionId] ?? null} />

void; + onPage?: Props['onPage']; + pageState?: Props['pageState']; +}> = ({ + statementIndex, + outTestId, + headerLabel, + items, + refreshing, + onRefresh, + onPage, + pageState, +}) => { + const okGrids = useMemo( + () => + items.filter( + (x): x is Extract => x.kind === 'grid' && Boolean(x.result.ok) + ), + [items] + ); + const canCompare = okGrids.length >= 2; + const [compareOn, setCompareOn] = useState(true); + const [baselineId, setBaselineId] = useState(''); + + useEffect(() => { + if (!canCompare) return; + if (!baselineId || !okGrids.some((g) => g.connectionId === baselineId)) { + setBaselineId(okGrids[0]!.connectionId); + } + }, [canCompare, okGrids, baselineId]); + + const compareActive = canCompare && compareOn && Boolean(baselineId); + + const { diffByConnection, badgeByConnection, legendBits } = useMemo(() => { + const diffByConnection: Record = {}; + const badgeByConnection: Record = {}; + const legendBits: string[] = []; + if (!compareActive) { + return { diffByConnection, badgeByConnection, legendBits }; + } + const baselineItem = okGrids.find((g) => g.connectionId === baselineId); + if (!baselineItem || !baselineItem.result.ok) { + return { diffByConnection, badgeByConnection, legendBits }; + } + const baselineGrid = { + columns: baselineItem.result.columns, + rows: baselineItem.result.rows, + }; + badgeByConnection[baselineId] = 'baseline'; + + let totalModified = 0; + let totalMissing = 0; + let totalExtra = 0; + const missingCols = new Set(); + const extraCols = new Set(); + + for (const g of okGrids) { + if (g.connectionId === baselineId || !g.result.ok) continue; + const pair = compareResultGrids(baselineGrid, { + columns: g.result.columns, + rows: g.result.rows, + }); + // Merge baseline highlights across all others (union of diffs). + const prev = diffByConnection[baselineId]; + if (!prev) { + diffByConnection[baselineId] = pair.baseline; + } else { + for (const [k, kind] of pair.baseline.cells) { + if (!prev.cells.has(k)) { + prev.cells.set(k, kind); + if (kind === 'modified') prev.modified += 1; + else if (kind === 'missing') prev.missing += 1; + else prev.extra += 1; + } + } + for (const c of pair.baseline.missingColumns) { + if (!prev.missingColumns.includes(c)) prev.missingColumns.push(c); + } + for (const c of pair.baseline.extraColumns) { + if (!prev.extraColumns.includes(c)) prev.extraColumns.push(c); + } + } + diffByConnection[g.connectionId] = pair.other; + const n = pair.other.cells.size; + badgeByConnection[g.connectionId] = n === 0 ? 'match' : `${n} differ`; + totalModified += pair.other.modified; + totalMissing += pair.other.missing + pair.baseline.missing; + totalExtra += pair.other.extra; + for (const c of pair.other.missingColumns) missingCols.add(c); + for (const c of pair.other.extraColumns) extraCols.add(c); + } + + const baseCells = diffByConnection[baselineId]?.cells.size ?? 0; + badgeByConnection[baselineId] = + baseCells === 0 ? 'baseline' : `baseline · ${baseCells} differ`; + + if (totalModified > 0) legendBits.push(`${totalModified} modified`); + if (totalMissing > 0) legendBits.push(`${totalMissing} missing`); + if (totalExtra > 0) legendBits.push(`${totalExtra} extra`); + if (missingCols.size > 0) { + legendBits.push(`cols only in baseline: ${[...missingCols].join(', ')}`); + } + if (extraCols.size > 0) { + legendBits.push(`cols only in other: ${[...extraCols].join(', ')}`); + } + if (legendBits.length === 0) legendBits.push('grids match on this page'); + + return { diffByConnection, badgeByConnection, legendBits }; + }, [compareActive, okGrids, baselineId]); + + return ( +
+
+
+ {headerLabel} +
+ {canCompare && ( +
+ + {compareOn && ( + + )} + {compareActive && ( + + + modified + + + missing + + + extra + + + {legendBits.join(' · ')} + + + )} +
+ )} +
+ x.key).join('|')}`} + refreshing={refreshing} + onRefresh={onRefresh} + onPage={onPage} + pageState={pageState} + diffByConnection={compareActive ? diffByConnection : undefined} + badgeByConnection={compareActive ? badgeByConnection : undefined} + /> + {compareActive && ( +

+ Rows align by index on this page — use the same ORDER BY on each server for a meaningful + compare. Column names match case-insensitively. +

+ )} +
+ ); +}; + /** * Results for one tab. `byCredential` stacks credentials, and statement grids * under each credential are also stacked vertically (not side by side). @@ -654,23 +902,17 @@ export const ResultsPanel: React.FC = ({ }); } return ( -
-
- {statementLabel(statements[i] ?? '', outNumber(i))} -
- x.key).join('|')}`} - refreshing={refreshing} - onRefresh={onRefresh} - onPage={onPage} - pageState={pageState} - /> -
+ statementIndex={i} + outTestId={outTestId(i)} + headerLabel={statementLabel(statements[i] ?? '', outNumber(i))} + items={items} + refreshing={refreshing} + onRefresh={onRefresh} + onPage={onPage} + pageState={pageState} + /> ); })}
diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index 912c6cc8..771a8de3 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -756,7 +756,7 @@ export const SqlEditorView: React.FC = () => {