From bafa8b5e50b5f3f3006c7359f3789d70854ab74f Mon Sep 17 00:00:00 2001 From: huyplb Date: Wed, 21 Oct 2026 11:43:20 -0600 Subject: [PATCH 1/2] feat(sql-editor): install a missing driver from the editor Selecting Redis or another credential with no driver package only showed the error. The schema browser and the result error now offer the same install the credential form already runs. Co-authored-by: Cursor --- apps/e2e/src/generated/test-ids.ts | 6 ++ .../components/MissingDriverBanner.test.tsx | 66 +++++++++++++ .../components/MissingDriverBanner.tsx | 99 +++++++++++++++++++ .../sql-editor/components/ResultsPanel.tsx | 15 ++- .../components/SqlSchemaExplorer.tsx | 10 ++ docs/testing/TEST_IDS.md | 7 +- 6 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.test.tsx create mode 100644 apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.tsx diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index c44fb8c05..33aaa262a 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2181,6 +2181,10 @@ export const TestIds = { ResultsPanel: { /** button · Refresh this server */ sqlCredRefresh: 'sql-cred-refresh', + /** button · Install the driver package on the server */ + sqlInstallDriver: (connectionId: string | number) => `sql-install-driver-${connectionId}`, + /** div */ + sqlInstallDriverBanner: (connectionId: string | number) => `sql-install-driver-${connectionId}-banner`, /** div · Drag to resize this table */ sqlPaneResize: 'sql-pane-resize', /** button · Retry this server */ @@ -4735,6 +4739,8 @@ export type TestId = | 'sql-grid-save-result-as' | 'sql-grid-save-result-as-mode' | 'sql-grid-view-scope' + | `sql-install-driver-${string}` + | `sql-install-driver-${string}-banner` | 'sql-layout-by-credential' | 'sql-layout-side-by-side' | 'sql-max-rows' diff --git a/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.test.tsx b/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.test.tsx new file mode 100644 index 000000000..63df2a561 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.test.tsx @@ -0,0 +1,66 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const checkDriver = vi.fn(); +const installDriver = vi.fn(); + +vi.mock('@/shared/api/schemaApi', () => ({ + checkDriver: (...args: unknown[]) => checkDriver(...args), + installDriver: (...args: unknown[]) => installDriver(...args), +})); + +import { MissingDriverBanner } from './MissingDriverBanner'; + +beforeEach(() => { + checkDriver.mockReset(); + installDriver.mockReset(); +}); + +describe('MissingDriverBanner', () => { + it('offers Install when the selected dialect has no driver, then reloads after it loads', async () => { + checkDriver + .mockResolvedValueOnce({ + provider: 'redis', + packageName: 'redis', + installed: false, + installCommand: 'npm install redis', + }) + .mockResolvedValueOnce({ + provider: 'redis', + packageName: 'redis', + installed: true, + version: '6.3.0', + }); + installDriver.mockResolvedValue({ success: true }); + const onInstalled = vi.fn(); + + render(); + + const button = await screen.findByTestId('sql-install-driver'); + expect(button.textContent).toMatch(/Install driver/); + expect(screen.getByText(/redis/)).toBeTruthy(); + + fireEvent.click(button); + await waitFor(() => expect(installDriver).toHaveBeenCalledWith('redis')); + await waitFor(() => expect(onInstalled).toHaveBeenCalled()); + expect(screen.queryByTestId('sql-install-driver')).toBeNull(); + }); + + it('stays hidden when the driver is already installed', async () => { + checkDriver.mockResolvedValue({ + provider: 'postgres', + packageName: 'pg', + installed: true, + }); + render( + + ); + await waitFor(() => expect(checkDriver).toHaveBeenCalled()); + expect(screen.queryByTestId('sql-install-driver')).toBeNull(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.tsx b/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.tsx new file mode 100644 index 000000000..d5d40dbf3 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/MissingDriverBanner.tsx @@ -0,0 +1,99 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, Download, Loader2 } from 'lucide-react'; +import { checkDriver, installDriver } from '@/shared/api/schemaApi'; +import type { DriverInfo } from '@/shared/lib/types'; +import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; + +function isMissingDriverError(message: string | undefined): boolean { + return Boolean(message && message.toLowerCase().includes('is not installed')); +} + +export const MissingDriverBanner: React.FC<{ + dialect: string; + /** Lets the button show before the check returns. */ + error?: string; + onInstalled?: () => void; + testId?: string; +}> = ({ dialect, error, onInstalled, testId = 'sql-install-driver' }) => { + const [info, setInfo] = useState(null); + const [installing, setInstalling] = useState(false); + const [fail, setFail] = useState(''); + + useEffect(() => { + let cancelled = false; + setInfo(null); + setFail(''); + checkDriver(dialect) + .then((next) => { + if (!cancelled) setInfo(next); + }) + .catch(() => { + // Unknown dialect or a failed check. The error text is the fallback. + }); + return () => { + cancelled = true; + }; + }, [dialect]); + + const missing = info ? !info.installed : isMissingDriverError(error); + const packageName = info?.packageName ?? 'driver'; + if (!missing) return null; + + const install = async () => { + setInstalling(true); + setFail(''); + try { + await installDriver(dialect); + const next = await checkDriver(dialect); + setInfo(next); + if (next.installed) { + onInstalled?.(); + return; + } + setFail( + next.error + ? `Driver still not loading: ${next.error}` + : `Install finished but "${next.packageName}" still failed to load.` + ); + } catch (caught: unknown) { + setFail(caught instanceof Error ? caught.message : 'Driver install failed'); + } finally { + setInstalling(false); + } + }; + + return ( +
+
+ + + Driver “{packageName}” is not installed + + +
+ {fail ?

{fail}

: null} +
+ ); +}; diff --git a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx index 31bb5edfa..182fb44fd 100644 --- a/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/ResultsPanel.tsx @@ -27,6 +27,7 @@ import { useSqlEditorStore, type CredentialRun } from '@/features/sql-editor/sta import { useSyncStore } from '@/features/compare'; import type { ResultsLayout } from '@/features/sql-editor/state/sqlEditorTabLogic'; import { DataGrid, PANE_DEFAULT_H_PX, PANE_DEFAULT_PX, PANE_MIN_H_PX, PANE_MIN_PX } from './DataGrid'; +import { MissingDriverBanner } from './MissingDriverBanner'; import { ResultChart } from './ResultChart'; import { chartableSeries } from '../lib/resultChart'; import type { SqlStatementResult } from '@/shared/api/sqlApi'; @@ -1870,9 +1871,17 @@ const CredentialResultsSection: React.FC<{ {run.status === 'error' && ( -
- - {run.error} +
+
+ + {run.error} +
+ onRefresh?.(run.connectionId)} + />
)} diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx index 1a1acece7..36a36110b 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx @@ -28,6 +28,7 @@ import { import type { DbObjectType, TableSchema } from '@/shared/lib/types'; import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; import { TableBlueprintModal, type BlueprintMode } from './TableBlueprintModal'; +import { MissingDriverBanner } from './MissingDriverBanner'; import { isScriptableObject, objectSourceScript } from '../lib/objectSourceScript'; import { fetchDbaUtility } from '@/shared/api/schemaApi'; import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; @@ -325,6 +326,15 @@ export const SqlSchemaExplorer = forwardRef(function Sq {schemaMissingHint}

)} + {conn && ( + { + if (explorerId) void ensureSchema(explorerId, { force: true, scope: entry?.scope }); + }} + /> + )} {entry?.status === 'error' && (

{entry.error}

)} diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index cf1bf315a..646d0560a 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -8,7 +8,7 @@ or `TestIds` for the same tree with autocomplete. `{name}` marks a part filled i time; "in FilterPicker" marks an ID a shared component draws from the prop written here. How to write a test with them: [WRITING_E2E.md](WRITING_E2E.md). -Every control has a test ID: **996** buttons, text boxes, selects and textareas. +Every control has a test ID: **997** buttons, text boxes, selects and textareas. ## access @@ -1117,6 +1117,9 @@ Every control has a test ID: **996** buttons, text boxes, selects and textareas. - `file-imports-panel` · div - `file-imports-refresh` · button · Refresh - `file-imports-table-head` · div +- **MissingDriverBanner** · `features/sql-editor/components/MissingDriverBanner.tsx` + - `{testId}` · button · Install the driver package on the server · passed in by the parent + - `{testId}-banner` · div · passed in by the parent - **PeekDatePicker** · `features/sql-editor/components/PeekDatePicker.tsx` - `peek-date-day-{y}-{m}-{day}` · button - `peek-date-today` · button · › @@ -1171,6 +1174,8 @@ Every control has a test ID: **996** buttons, text boxes, selects and textareas. - `{testId}-bar-{i}` · rect · passed in by the parent - **ResultsPanel** · `features/sql-editor/components/ResultsPanel.tsx` - `sql-cred-refresh` · button · Refresh this server + - `sql-install-driver-{connectionId}` · button in MissingDriverBanner · Install the driver package on the server + - `sql-install-driver-{connectionId}-banner` · div in MissingDriverBanner - `sql-pane-resize` · div · Drag to resize this table - `sql-pane-retry-{connectionId}` · button · Retry this server - `sql-result-{statementIndex}-{action}` · data From 0f3d9295575e5960df68bbe042e66db3526ed8e9 Mon Sep 17 00:00:00 2001 From: huyplb Date: Thu, 22 Oct 2026 12:40:38 -0600 Subject: [PATCH 2/2] feat(sql-editor): put run checks on the left of each statement The statement list between the editor and the results used a band of vertical space. Each cell's play button and include checkbox now sit on that statement's first line. Co-authored-by: Cursor --- apps/e2e/src/generated/test-ids.ts | 9 ++ apps/e2e/src/pages/SqlEditorPage.ts | 4 +- apps/e2e/src/tests/sql-editor-sqlite.test.ts | 10 +- .../sql-editor/components/SqlEditorPane.tsx | 38 ++++- .../sql-editor/components/SqlEditorView.tsx | 25 +-- .../components/StatementRunGutter.test.tsx | 60 ++++++++ .../components/StatementRunGutter.tsx | 143 ++++++++++++++++++ docs/testing/TEST_IDS.md | 6 +- 8 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.test.tsx create mode 100644 apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.tsx diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 33aaa262a..de1712bc6 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2528,6 +2528,14 @@ export const TestIds = { /** div */ sqlVariables: 'sql-variables', }, + StatementRunGutter: { + /** div · Statement run checks */ + sqlStatementGutter: 'sql-statement-gutter', + /** input */ + sqlStatementInclude: (i: string | number) => `sql-statement-include-${i}`, + /** button */ + sqlStatementRun: (i: string | number) => `sql-statement-run-${i}`, + }, StatementStrip: { /** div */ sqlStatementCell: (i: string | number) => `sql-statement-cell-${i}`, @@ -4886,6 +4894,7 @@ export type TestId = | `sql-statement-cell-${string}` | 'sql-statement-copy' | `sql-statement-copy-row-${string}` + | 'sql-statement-gutter' | `sql-statement-include-${string}` | `sql-statement-reveal-${string}` | `sql-statement-run-${string}` diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index a9970d2ec..004bd2879 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -357,8 +357,8 @@ export class SqlEditorPage { return this.page.locator('[data-testid="sql-editor-tabs"] [role="tab"]').count(); } - async statementStripVisible(): Promise { - return this.page.locator(byTestId('sql-statement-strip')).isVisible(); + async statementGutterVisible(): Promise { + return this.page.locator(byTestId('sql-statement-gutter')).isVisible(); } async confirmWriteIfShown(): Promise { diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts index 1d33c817f..58c4e54d0 100644 --- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -180,13 +180,11 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { expect(await driver.locator('[data-testid="sql-data-migrate-bar-0"]').count()).toBe(0); }); - it('shows the statement strip for multi-statement SQL', async () => { + it('shows a run checkbox on the left of each statement', async () => { await sql.setSql('SELECT 1 AS n;\nSELECT name FROM customers WHERE id = 1;'); - expect(await sql.statementStripVisible()).toBe(true); - const strip = await driver.locator('[data-testid="sql-statement-strip"]').innerText(); - // Jupyter-style cells label statements In [1] / In [2] (legacy used #1 / #2). - expect(strip).toMatch(/In\s*\[1\]|#1/); - expect(strip).toMatch(/In\s*\[2\]|#2/); + expect(await sql.statementGutterVisible()).toBe(true); + expect(await driver.locator('[data-testid="sql-statement-include-0"]').isVisible()).toBe(true); + expect(await driver.locator('[data-testid="sql-statement-include-1"]').isVisible()).toBe(true); }); it('prompts for confirmation before running UPDATE', async () => { diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx index a25d64b6b..786be2d5a 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx @@ -37,6 +37,7 @@ import { SelectColumnPicker, type SelectColumnPickerAnchor, } from './SelectColumnPicker'; +import { StatementRunGutter } from './StatementRunGutter'; export interface RevealRequest { startLine: number; @@ -47,8 +48,14 @@ export interface RevealRequest { interface Props { value: string; - /** Pre-split statements from the parent (kept for API compatibility / strip). */ statements: SplitStatement[]; + /** Statement indexes included in toolbar Run. */ + checkedStatements?: number[]; + onToggleStatement?: (index: number) => void; + /** Per-cell play, same pipeline as toolbar Run. */ + onRunCell?: (index: number) => void; + cellRunning?: boolean; + sqlNeedsDestination?: boolean; dialect: string; onChange: (value: string) => void; /** Ctrl/Cmd+Enter shortcut → run. */ @@ -57,17 +64,24 @@ interface Props { onSelectionChange?: (hasSelection: boolean) => void; /** Caret moved — offset in the model, so Run can label the right statement. */ onCaretChange?: (offset: number | null) => void; - /** Statement strip click → scroll/select that range. */ + /** Scroll and select a statement range. */ reveal?: RevealRequest | null; } /** * Editable Monaco pane for the SQL Editor. Notebook-style cell banding spans * each statement's line range; the glyph margin shows ✓ / ⚠ on the first line. + * Run checkboxes sit in a rail on the left of that line. * Model language is FoxScript (`foxscript`) once Monarch + packs load. */ export const SqlEditorPane: React.FC = ({ value, + statements, + checkedStatements = [], + onToggleStatement, + onRunCell, + cellRunning = false, + sqlNeedsDestination = false, dialect, onChange, onRun, @@ -96,6 +110,7 @@ export const SqlEditorPane: React.FC = ({ const [columnPickerAnchor, setColumnPickerAnchor] = useState( null ); + const [editorEpoch, setEditorEpoch] = useState(0); const editorOptions = useMemo( () => ({ ...MONACO_EDITOR_BASE_OPTIONS, @@ -144,8 +159,8 @@ export const SqlEditorPane: React.FC = ({ glyphMarginHoverMessage: { value: ok ? isCode - ? 'Code cell looks complete — use the strip ▶ to run this cell' - : 'Statement looks complete — use the strip ▶ to run this cell' + ? 'Code cell looks complete — use the ▶ on the left to run this cell' + : 'Statement looks complete — use the ▶ on the left to run this cell' : status.reasons.join(' · '), }, stickiness: 1, @@ -242,9 +257,20 @@ export const SqlEditorPane: React.FC = ({ it Chrome treats this origin's saved login as belonging to the textarea. */}
event.preventDefault()} > + onToggleStatement?.(index)} + onRunCell={onRunCell} + /> +
= ({ onMount={(editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + setEditorEpoch((n) => n + 1); const disarm = () => disarmPasswordAutofill(editor.getDomNode?.() ?? null); disarm(); // Monaco writes autocomplete="off" while building the textarea, and @@ -388,6 +415,7 @@ export const SqlEditorPane: React.FC = ({ }} options={editorOptions} /> +
{ const results = resultsByTab[tab.id]; const running = runningTabId === tab.id; - const [reveal, setReveal] = useState(null); const [editorPct, setEditorPct] = useState(EDITOR_PCT_DEFAULT); const [hasSelection, setHasSelection] = useState(false); const [caretOffset, setCaretOffset] = useState(null); @@ -348,10 +345,6 @@ export const SqlEditorView: React.FC = () => { const [formatNote, setFormatNote] = useState(null); const formatNoteTimer = useRef | null>(null); - const onReveal = (stmt: SplitStatement) => { - setReveal({ startLine: stmt.startLine, endLine: stmt.endLine, nonce: Date.now() }); - }; - const onFormat = () => { void (async () => { const before = tab.sql; @@ -947,12 +940,16 @@ export const SqlEditorView: React.FC = () => { key={tab.id} value={tab.sql} statements={statements} + checkedStatements={tab.checkedStatements} + onToggleStatement={toggleStatement} + onRunCell={(index) => void execute({ statementIndices: [index] })} + cellRunning={running} + sqlNeedsDestination={liveSelectedIds.length === 0} dialect={dialect} onChange={setSql} onRun={() => execute()} onSelectionChange={setHasSelection} onCaretChange={setCaretOffset} - reveal={reveal} />
@@ -967,16 +964,6 @@ export const SqlEditorView: React.FC = () => { className="h-1.5 shrink-0 cursor-row-resize bg-slate-900 hover:bg-cyan-500/40 active:bg-cyan-500/60 transition-colors border-y border-slate-800/80" /> - void execute({ statementIndices: [index] })} - /> -
{ + it('places a run checkbox on each statement and toggles the one that was clicked', () => { + const onToggle = vi.fn(); + const statements = splitSqlStatements('SELECT 1;\nSELECT 2;'); + const editor = { + getTopForLineNumber: (line: number) => (line - 1) * 20 + 8, + getScrollTop: () => 0, + getLayoutInfo: () => ({ height: 200 }), + onDidScrollChange: () => ({ dispose: () => {} }), + onDidContentSizeChange: () => ({ dispose: () => {} }), + onDidLayoutChange: () => ({ dispose: () => {} }), + }; + render( + + ); + const first = screen.getByTestId('sql-statement-include-0') as HTMLInputElement; + const second = screen.getByTestId('sql-statement-include-1') as HTMLInputElement; + expect(first.checked).toBe(false); + expect(second.checked).toBe(true); + fireEvent.click(first); + expect(onToggle).toHaveBeenCalledWith(0); + }); + + it('hides a statement that is scrolled out of the editor', async () => { + const statements = splitSqlStatements('SELECT 1;\nSELECT 2;'); + const editor = { + getTopForLineNumber: (line: number) => (line - 1) * 20, + getScrollTop: () => 0, + getLayoutInfo: () => ({ height: 10 }), + onDidScrollChange: () => ({ dispose: () => {} }), + onDidContentSizeChange: () => ({ dispose: () => {} }), + onDidLayoutChange: () => ({ dispose: () => {} }), + }; + render( + {}} + /> + ); + expect(screen.getByTestId('sql-statement-include-0')).toBeTruthy(); + await waitFor(() => { + expect(screen.queryByTestId('sql-statement-include-1')).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.tsx b/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.tsx new file mode 100644 index 000000000..b8fe32b15 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.tsx @@ -0,0 +1,143 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Run checkbox and per-cell play, one row per statement, sitting on the left + * of the editor so the statement list does not take a band of vertical space. + */ +import React, { useEffect, useState } from 'react'; +import { Play } from 'lucide-react'; +import { isCodeCellKind, type SplitStatement } from '@/shared/lib/sql-splitter'; +import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; + +const FALLBACK_LINE_PX = 20; +const EDITOR_PAD_PX = 8; + +/** Enough of Monaco to line a checkbox up with a statement's first line. */ +export interface GutterEditor { + getTopForLineNumber: (line: number) => number; + getScrollTop: () => number; + getLayoutInfo?: () => { height: number }; + onDidScrollChange: (listener: () => void) => { dispose: () => void }; + onDidContentSizeChange: (listener: () => void) => { dispose: () => void }; + onDidLayoutChange: (listener: () => void) => { dispose: () => void }; +} + +function fallbackTops(statements: SplitStatement[]): number[] { + return statements.map((stmt) => (stmt.startLine - 1) * FALLBACK_LINE_PX + EDITOR_PAD_PX); +} + +function topsFor(editor: GutterEditor, statements: SplitStatement[]): number[] { + const scroll = editor.getScrollTop(); + const viewH = editor.getLayoutInfo?.().height ?? 0; + if (!viewH) return fallbackTops(statements); + const lineH = editor.getTopForLineNumber(2) - editor.getTopForLineNumber(1); + const rowH = lineH > 0 ? lineH : FALLBACK_LINE_PX; + return statements.map((stmt) => { + const y = editor.getTopForLineNumber(stmt.startLine) - scroll; + if (y < -rowH || y > viewH) return Number.NaN; + return y + Math.max(0, (rowH - 16) / 2); + }); +} + +interface Props { + statements: SplitStatement[]; + checked: number[]; + editorRef: React.RefObject; + /** Bumped when Monaco finishes mounting, so the rail can attach to it. */ + editorEpoch: number; + running?: boolean; + sqlNeedsDestination?: boolean; + onToggle: (index: number) => void; + onRunCell?: (index: number) => void; +} + +export const StatementRunGutter: React.FC = ({ + statements, + checked, + editorRef, + editorEpoch, + running = false, + sqlNeedsDestination = false, + onToggle, + onRunCell, +}) => { + const [tops, setTops] = useState(() => fallbackTops(statements)); + + useEffect(() => { + const editor = editorRef.current; + if (!editor) { + setTops(fallbackTops(statements)); + return; + } + let frame = 0; + const refresh = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setTops(topsFor(editor, statements)); + }); + }; + refresh(); + const subs = [ + editor.onDidScrollChange(refresh), + editor.onDidContentSizeChange(refresh), + editor.onDidLayoutChange(refresh), + ]; + return () => { + if (frame) cancelAnimationFrame(frame); + subs.forEach((sub) => sub.dispose()); + }; + }, [editorRef, editorEpoch, statements]); + + return ( +
+ {statements.map((stmt, i) => { + const top = tops[i]; + if (top == null || Number.isNaN(top)) return null; + const isChecked = checked.includes(i); + const playBlocked = sqlNeedsDestination && !isCodeCellKind(stmt.kind); + return ( +
+ + onToggle(i)} + className="h-3.5 w-3.5 shrink-0 cursor-pointer accent-cyan-600" + title={ + checked.length === 0 + ? 'None checked → toolbar Run uses the cell at the caret' + : 'Include this cell in toolbar Run' + } + aria-label={`Include cell ${i + 1} in batch Run`} + /> +
+ ); + })} +
+ ); +}; diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index 646d0560a..088de0ca2 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -8,7 +8,7 @@ or `TestIds` for the same tree with autocomplete. `{name}` marks a part filled i time; "in FilterPicker" marks an ID a shared component draws from the prop written here. How to write a test with them: [WRITING_E2E.md](WRITING_E2E.md). -Every control has a test ID: **997** buttons, text boxes, selects and textareas. +Every control has a test ID: **999** buttons, text boxes, selects and textareas. ## access @@ -1347,6 +1347,10 @@ Every control has a test ID: **997** buttons, text boxes, selects and textareas. - `sql-variable-value-edit-{name}` · input - `sql-variable-value-input` · input · value - `sql-variables` · div +- **StatementRunGutter** · `features/sql-editor/components/StatementRunGutter.tsx` + - `sql-statement-gutter` · div · Statement run checks + - `sql-statement-include-{i}` · input + - `sql-statement-run-{i}` · button - **StatementStrip** · `features/sql-editor/components/StatementStrip.tsx` - `sql-statement-cell-{i}` · div - `sql-statement-copy` · button