Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}`
Expand Down
4 changes: 2 additions & 2 deletions apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ export class SqlEditorPage {
return this.page.locator('[data-testid="sql-editor-tabs"] [role="tab"]').count();
}

async statementStripVisible(): Promise<boolean> {
return this.page.locator(byTestId('sql-statement-strip')).isVisible();
async statementGutterVisible(): Promise<boolean> {
return this.page.locator(byTestId('sql-statement-gutter')).isVisible();
}

async confirmWriteIfShown(): Promise<boolean> {
Expand Down
10 changes: 4 additions & 6 deletions apps/e2e/src/tests/sql-editor-sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* 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(<MissingDriverBanner dialect="redis" onInstalled={onInstalled} />);

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(
<MissingDriverBanner dialect="postgres" error="connection refused" />
);
await waitFor(() => expect(checkDriver).toHaveBeenCalled());
expect(screen.queryByTestId('sql-install-driver')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* 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<DriverInfo | null>(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 (
<div
className="flex flex-col gap-1.5 rounded-md border border-amber-500/30 bg-amber-950/20 px-2 py-1.5"
data-testid={`${testId}-banner`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-amber-200">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" strokeWidth={SQL_ICON_STROKE} />
<span className="truncate">Driver “{packageName}” is not installed</span>
</span>
<button
type="button"
data-testid={testId}
onClick={() => void install()}
disabled={installing}
title="Install the driver package on the server"
className="inline-flex shrink-0 items-center gap-1 rounded bg-amber-400 px-2 py-0.5 text-[11px] font-bold on-accent-fg hover:bg-amber-300 disabled:cursor-wait disabled:bg-slate-700 disabled:text-slate-400"
>
{installing ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={SQL_ICON_STROKE} />
) : (
<Download className="h-3 w-3" strokeWidth={SQL_ICON_STROKE} />
)}
{installing ? 'Installing…' : 'Install driver'}
</button>
</div>
{fail ? <p className="text-[11px] text-rose-300 break-words">{fail}</p> : null}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1870,9 +1871,17 @@ const CredentialResultsSection: React.FC<{
</header>

{run.status === 'error' && (
<div className="flex items-start gap-2 text-xs text-rose-400 bg-rose-950/40 border border-rose-500/20 rounded-md px-3 py-2 max-w-2xl">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-rose-400" strokeWidth={SQL_ICON_STROKE} />
<span className="break-all">{run.error}</span>
<div className="flex max-w-2xl flex-col gap-2">
<div className="flex items-start gap-2 text-xs text-rose-400 bg-rose-950/40 border border-rose-500/20 rounded-md px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-rose-400" strokeWidth={SQL_ICON_STROKE} />
<span className="break-all">{run.error}</span>
</div>
<MissingDriverBanner
dialect={run.dialect}
error={run.error}
testId={`sql-install-driver-${run.connectionId}`}
onInstalled={() => onRefresh?.(run.connectionId)}
/>
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
SelectColumnPicker,
type SelectColumnPickerAnchor,
} from './SelectColumnPicker';
import { StatementRunGutter } from './StatementRunGutter';

export interface RevealRequest {
startLine: number;
Expand All @@ -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. */
Expand All @@ -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<Props> = ({
value,
statements,
checkedStatements = [],
onToggleStatement,
onRunCell,
cellRunning = false,
sqlNeedsDestination = false,
dialect,
onChange,
onRun,
Expand Down Expand Up @@ -96,6 +110,7 @@ export const SqlEditorPane: React.FC<Props> = ({
const [columnPickerAnchor, setColumnPickerAnchor] = useState<SelectColumnPickerAnchor | null>(
null
);
const [editorEpoch, setEditorEpoch] = useState(0);
const editorOptions = useMemo(
() => ({
...MONACO_EDITOR_BASE_OPTIONS,
Expand Down Expand Up @@ -144,8 +159,8 @@ export const SqlEditorPane: React.FC<Props> = ({
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,
Expand Down Expand Up @@ -242,9 +257,20 @@ export const SqlEditorPane: React.FC<Props> = ({
it Chrome treats this origin's saved login as belonging to the textarea. */}
<form
autoComplete="off"
className="contents"
className="flex h-full min-h-0 w-full"
onSubmit={(event) => event.preventDefault()}
>
<StatementRunGutter
statements={statements}
checked={checkedStatements}
editorRef={editorRef}
editorEpoch={editorEpoch}
running={cellRunning}
sqlNeedsDestination={sqlNeedsDestination}
onToggle={(index) => onToggleStatement?.(index)}
onRunCell={onRunCell}
/>
<div className="h-full min-w-0 flex-1">
<Editor
height="100%"
theme={monacoTheme}
Expand All @@ -254,6 +280,7 @@ export const SqlEditorPane: React.FC<Props> = ({
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
Expand Down Expand Up @@ -388,6 +415,7 @@ export const SqlEditorPane: React.FC<Props> = ({
}}
options={editorOptions}
/>
</div>
</form>
<SelectColumnPicker
open={columnPickerOpen}
Expand Down
Loading
Loading