diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 561b904..64352db 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -23,6 +23,7 @@ import { AppSettingsStore } from '../modules/app-settings.module'; import { SignupModule } from '../modules/signup.module'; import { rateLimit } from './rate-limit'; import { runStatements, clampMaxRows, MAX_STATEMENTS, MAX_STATEMENT_LENGTH } from './sql-execute'; +import { clampOffset } from './sql-page-wrap'; import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config'; import { createMetadataStore } from '../database/stores/registry'; import { keySchemeInfo } from '../cores/crypto'; @@ -365,7 +366,11 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // caps only. Rate-limited: each call can hold a DB connection for a while. const sqlExecuteLimiter = rateLimit({ windowMs: 60 * 1000, max: 60 }); router.post('/sql/execute', sqlExecuteLimiter, async (req: Request, res: Response) => { - const { statements, maxRows, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown }; + const { statements, maxRows, offset, ...ref } = req.body as ConnectionRef & { + statements?: unknown; + maxRows?: unknown; + offset?: unknown; + }; if (!Array.isArray(statements) || statements.length === 0) { res.status(400).json({ error: 'statements[] is required.' }); return; @@ -393,7 +398,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt resolved.option, statements as string[], clampMaxRows(maxRows), - resolved.schema + resolved.schema, + clampOffset(offset) ); res.json({ results }); } catch (error: unknown) { diff --git a/apps/web/src/backend/api/sql-execute.ts b/apps/web/src/backend/api/sql-execute.ts index 328910f..245485a 100644 --- a/apps/web/src/backend/api/sql-execute.ts +++ b/apps/web/src/backend/api/sql-execute.ts @@ -1,4 +1,5 @@ import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/core'; +import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap'; /** * Helpers behind POST /api/sql/execute (SQL Editor). One request = one @@ -16,6 +17,8 @@ export interface StatementResultOk { rowCount: number; /** True when the driver returned more rows than maxRows and the tail was dropped. */ truncated: boolean; + /** True when another page is available (page probe). */ + hasNext?: boolean; durationMs: number; } export interface StatementResultErr { @@ -85,7 +88,8 @@ export async function runStatements( option: ConnectionOptions, statements: string[], maxRows: number, - schema?: string + schema?: string, + offset = 0 ): Promise { const schemaName = (schema ?? option.schema)?.trim() || ''; const optionWithSchema: ConnectionOptions = schemaName @@ -102,15 +106,47 @@ export async function runStatements( for (const sql of statements) { const started = Date.now(); try { + const paged = wrapSqlForPage(sql, dialect, offset, maxRows); const raw = await ConnectionFactory.executeOnConnection>( dialect, connection, - sql + paged ); - results.push({ ok: true, ...shapeRows(raw, maxRows), durationMs: Date.now() - started }); + const shaped = shapeRows(raw, maxRows + 1); + const page = trimPageProbe(shaped, maxRows); + results.push({ + ok: true, + columns: page.columns, + rows: page.rows, + rowCount: page.rowCount, + truncated: page.truncated, + hasNext: page.hasNext, + durationMs: Date.now() - started, + }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - results.push({ ok: false, error: message, durationMs: Date.now() - started }); + // Fallback: run unwrapped (e.g. non-SELECT / dialect wrap rejection). + try { + const raw = await ConnectionFactory.executeOnConnection>( + dialect, + connection, + sql + ); + const shaped = shapeRows(raw, maxRows); + results.push({ + ok: true, + ...shaped, + hasNext: false, + durationMs: Date.now() - started, + }); + } catch (inner: unknown) { + const message = inner instanceof Error ? inner.message : String(inner); + const wrapMsg = error instanceof Error ? error.message : String(error); + results.push({ + ok: false, + error: offset > 0 ? `${message} (page wrap: ${wrapMsg})` : message, + durationMs: Date.now() - started, + }); + } } } return results; diff --git a/apps/web/src/backend/api/sql-page-wrap.test.ts b/apps/web/src/backend/api/sql-page-wrap.test.ts new file mode 100644 index 0000000..8ea5781 --- /dev/null +++ b/apps/web/src/backend/api/sql-page-wrap.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap'; + +describe('sql-page-wrap', () => { + it('wraps postgres-style with LIMIT/OFFSET and +1 probe', () => { + expect(wrapSqlForPage('SELECT 1;', 'postgres', 40, 20)).toBe( + 'SELECT * FROM (SELECT 1) AS _fox_page LIMIT 21 OFFSET 40' + ); + }); + + it('wraps sqlserver with ORDER BY OFFSET FETCH', () => { + const sql = wrapSqlForPage('SELECT id FROM t', 'sqlserver', 0, 10); + expect(sql).toContain('OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY'); + expect(sql).toContain('ORDER BY (SELECT NULL)'); + }); + + it('trimPageProbe drops probe row and sets hasNext', () => { + const shaped = { + columns: ['a'], + rows: [[1], [2], [3]], + rowCount: 3, + truncated: false, + }; + const t = trimPageProbe(shaped, 2); + expect(t.rows).toEqual([[1], [2]]); + expect(t.hasNext).toBe(true); + expect(t.truncated).toBe(true); + }); +}); diff --git a/apps/web/src/backend/api/sql-page-wrap.ts b/apps/web/src/backend/api/sql-page-wrap.ts new file mode 100644 index 0000000..1df1c03 --- /dev/null +++ b/apps/web/src/backend/api/sql-page-wrap.ts @@ -0,0 +1,55 @@ +/** + * Wrap a statement so the engine returns a page (LIMIT/OFFSET). + * Fetches `limit + 1` rows so the caller can detect `hasNext` without a COUNT. + */ + +export function clampOffset(v: unknown): number { + const n = typeof v === 'number' ? Math.floor(v) : Number.NaN; + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(n, 1_000_000); +} + +/** + * Best-effort page wrap. Dialects without OFFSET still get a subquery + LIMIT + * when offset is 0; non-zero offset uses the closest dialect syntax. + */ +export function wrapSqlForPage( + sql: string, + dialect: string, + offset: number, + limit: number +): string { + const trimmed = sql.trim().replace(/;+\s*$/, ''); + const d = dialect.toLowerCase(); + const inner = trimmed; + const fetchLimit = limit + 1; // +1 probe row + + if (d === 'sqlserver' || d === 'mssql') { + // SQL Server requires ORDER BY for OFFSET/FETCH. + return `SELECT * FROM (${inner}) AS _fox_page ORDER BY (SELECT NULL) OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`; + } + if (d === 'oracle') { + return `SELECT * FROM (${inner}) _fox_page OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`; + } + if (d === 'db2') { + return `SELECT * FROM (${inner}) AS _fox_page OFFSET ${offset} ROWS FETCH FIRST ${fetchLimit} ROWS ONLY`; + } + // Postgres, MySQL, MariaDB, SQLite, Cockroach, Yugabyte, TiDB, DuckDB, ClickHouse-ish + return `SELECT * FROM (${inner}) AS _fox_page LIMIT ${fetchLimit} OFFSET ${offset}`; +} + +/** After shaping, drop the probe row and set truncated/hasNext. */ +export function trimPageProbe( + shaped: T, + pageSize: number +): T & { hasNext: boolean } { + const hasNext = shaped.rows.length > pageSize; + const rows = hasNext ? shaped.rows.slice(0, pageSize) : shaped.rows; + return { + ...shaped, + rows, + rowCount: rows.length, + truncated: hasNext || shaped.truncated, + hasNext, + }; +} diff --git a/apps/web/src/frontend/api/sqlApi.ts b/apps/web/src/frontend/api/sqlApi.ts index b6d174f..9ebb2dd 100644 --- a/apps/web/src/frontend/api/sqlApi.ts +++ b/apps/web/src/frontend/api/sqlApi.ts @@ -3,7 +3,15 @@ import type { ConnectionRef } from './schemaApi'; /** One statement's outcome from POST /sql/execute (mirrors backend sql-execute.ts). */ export type SqlStatementResult = - | { ok: true; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; durationMs: number } + | { + ok: true; + columns: string[]; + rows: unknown[][]; + rowCount: number; + truncated: boolean; + hasNext?: boolean; + durationMs: number; + } | { ok: false; error: string; durationMs: number }; /** @@ -14,13 +22,14 @@ export type SqlStatementResult = export async function executeSql( ref: ConnectionRef, statements: string[], - maxRows?: number + maxRows?: number, + offset?: number ): Promise<{ results: SqlStatementResult[] }> { const res = await fetch(`${getApiBase()}/sql/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ ...ref, statements, maxRows }), + body: JSON.stringify({ ...ref, statements, maxRows, offset }), }); const text = await res.text(); let data: { results?: SqlStatementResult[]; error?: string }; diff --git a/apps/web/src/frontend/components/ProfileMenu.tsx b/apps/web/src/frontend/components/ProfileMenu.tsx index 6921a5c..17651a3 100644 --- a/apps/web/src/frontend/components/ProfileMenu.tsx +++ b/apps/web/src/frontend/components/ProfileMenu.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { LogOut, Palette, ChevronDown, ArrowUpCircle, Globe } from 'lucide-react'; import { useAuthStore } from '../store/authStore'; import { SettingsPanel } from './SettingsPanel'; @@ -9,11 +10,16 @@ export const ProfileMenu: React.FC = () => { const [open, setOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); const [update, setUpdate] = useState(null); + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); const ref = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); useEffect(() => { const onClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + const t = e.target as Node; + if (ref.current?.contains(t) || menuRef.current?.contains(t)) return; + setOpen(false); }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); @@ -27,31 +33,45 @@ export const ProfileMenu: React.FC = () => { }; }, []); + const placeMenu = () => { + const btn = buttonRef.current; + if (!btn) return; + const r = btn.getBoundingClientRect(); + setMenuPos({ top: r.bottom + 8, right: window.innerWidth - r.right }); + }; + + useLayoutEffect(() => { + if (!open) { + setMenuPos(null); + return; + } + placeMenu(); + const onReposition = () => placeMenu(); + window.addEventListener('resize', onReposition); + window.addEventListener('scroll', onReposition, true); + return () => { + window.removeEventListener('resize', onReposition); + window.removeEventListener('scroll', onReposition, true); + }; + }, [open]); + if (!user) return null; const updateAvailable = !!update?.updateAvailable; - return ( -
- - - {open && ( -
+ const menu = open && menuPos + ? createPortal( +

Signed in as

-

{user.email}

+

+ {user.email} +

{updateAvailable && ( @@ -67,6 +87,7 @@ export const ProfileMenu: React.FC = () => { )} -
- )} +
, + document.body + ) + : null; + + return ( +
+ + + {menu} setShowSettings(false)} />
diff --git a/apps/web/src/frontend/components/SettingsPanel.tsx b/apps/web/src/frontend/components/SettingsPanel.tsx index 2e45192..c9ba0f0 100644 --- a/apps/web/src/frontend/components/SettingsPanel.tsx +++ b/apps/web/src/frontend/components/SettingsPanel.tsx @@ -57,7 +57,7 @@ export const SettingsPanel: React.FC = ({ open, onClose }) => { return createPortal(
= ({ value, dialect, editable = const editorRef = useRef(null); const decoRef = useRef(null); const monacoTheme = useUiStore((s) => s.resolvedMode) === 'light' ? MONACO_THEME_LIGHT : MONACO_THEME; + const fontSizePref = useUiStore((s) => s.fontSize); + const monacoFontSize = MONACO_FONT_PX[fontSizePref] ?? MONACO_FONT_PX.md; + const options = useMemo( + () => ({ + ...BASE_OPTIONS, + fontSize: monacoFontSize, + readOnly: !editable, + domReadOnly: !editable, + }), + [monacoFontSize, editable] + ); const apply = useCallback(() => { if (editorRef.current) decoRef.current = decorate(editorRef.current, highlight ?? '', decoRef.current); @@ -57,6 +67,10 @@ export const SqlEditor: React.FC = ({ value, dialect, editable = apply(); }, [apply, value]); + useEffect(() => { + editorRef.current?.updateOptions?.({ fontSize: monacoFontSize }); + }, [monacoFontSize]); + return ( = ({ value, dialect, editable = editorRef.current = editor; apply(); }} - options={{ ...BASE_OPTIONS, readOnly: !editable, domReadOnly: !editable }} + options={options} /> ); }; @@ -99,6 +113,8 @@ export const SqlDiffEditor: React.FC = ({ original, modified, dial const decoOrigRef = useRef(null); const isLight = useUiStore((s) => s.resolvedMode) === 'light'; const monacoTheme = isLight ? MONACO_DIFF_THEME_LIGHT[status] : MONACO_DIFF_THEME[status]; + const fontSizePref = useUiStore((s) => s.fontSize); + const monacoFontSize = MONACO_FONT_PX[fontSizePref] ?? MONACO_FONT_PX.md; const apply = useCallback(() => { const diff = diffRef.current; @@ -111,6 +127,13 @@ export const SqlDiffEditor: React.FC = ({ original, modified, dial apply(); }, [apply, orig, mod]); + useEffect(() => { + const diff = diffRef.current; + if (!diff) return; + diff.getModifiedEditor()?.updateOptions?.({ fontSize: monacoFontSize }); + diff.getOriginalEditor()?.updateOptions?.({ fontSize: monacoFontSize }); + }, [monacoFontSize]); + return ( = ({ original, modified, dial }} options={{ ...BASE_OPTIONS, + fontSize: monacoFontSize, readOnly: true, renderSideBySide: !inline, ignoreTrimWhitespace: false, diff --git a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx index 4824892..df968a1 100644 --- a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx +++ b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx @@ -38,7 +38,7 @@ export const ConnectionChecklist: React.FC = () => { return (
{connections.length === 0 ? ( -

+

No saved connections yet — add one via the Credentials button in the toolbar.

) : ( @@ -67,16 +67,16 @@ export const ConnectionChecklist: React.FC = () => { {connections.map((c) => (