diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index c44fb8c05..de1712bc6 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 */ @@ -2524,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}`, @@ -4735,6 +4747,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' @@ -4880,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/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/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] })} - /> -
(function Sq {schemaMissingHint}

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

{entry.error}

)} diff --git a/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.test.tsx b/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.test.tsx new file mode 100644 index 000000000..ec67c4106 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/StatementRunGutter.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { splitSqlStatements } from '@/shared/lib/sql-splitter'; +import { StatementRunGutter } from './StatementRunGutter'; + +describe('StatementRunGutter', () => { + 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 cf1bf315a..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: **996** buttons, text boxes, selects and textareas. +Every control has a test ID: **999** 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 @@ -1342,6 +1347,10 @@ Every control has a test ID: **996** 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