diff --git a/.cursor/rules/sql-editor-agent-memory.mdc b/.cursor/rules/sql-editor-agent-memory.mdc index 8851eccc..9fb2aefb 100644 --- a/.cursor/rules/sql-editor-agent-memory.mdc +++ b/.cursor/rules/sql-editor-agent-memory.mdc @@ -28,19 +28,19 @@ FoxSchema = schema diff/migration + SQL Editor. Destinations checklist, Schema e - **Cell banding** in Monaco: `.fox-cell-band-sql` / `.fox-cell-band-code` + left edge. - **Statement strip**: Jupyter-like `In [n]:`, kind badge, per-cell **▶** → `execute({ statementIndices: [i] })`. - Toolbar Run still uses strip checkboxes / selection; per-cell Play ignores both. -- Results side-by-side labels: `Out [n]: …` (`sql-result-stmt-${i}`). +- Results labels: `Out [n]: …` (`sql-result-stmt-${i}` in side-by-side; By cred stacks Out grids vertically per credential). - Password / Safe-mode confirm preserve `statementIndices` on resume. -### 1. Data peek (Cmd/Ctrl-click) -- **UI**: `DataPeekPanel.tsx` — modal grids + breadcrumb; Esc closes. -- **Trigger**: Schema explorer Cmd/Ctrl-click on TABLE / VIEW / MQT → `openDataPeek`. -- **Queries**: `tablePreview.ts` builds SQL via `sqlTag` / `renderSqlQuery` (bound params, never string paste). - - `buildTablePreview` → `SELECT * FROM ` (paging/cap from `/sql/execute`). - - `buildForeignKeyDrilldown` → parent WHERE with bound cell values; null if FK cols unusable. - - `foreignKeyLinksFor` — case-insensitive column match (Oracle/Db2 fold). -- **Drill**: FK cell click → `drillDataPeek` stacks another grid; use **row index** (not 1-based display #). -- **Store**: `useSqlEditorStore` `dataPeek` / `openDataPeek` / `drillDataPeek` / `closeDataPeek` / `closeDataPeekFrom`. -- Match drilled table names both qualified (`public.customers`) and bare against schema cache. +### 1. Data peek (Cmd/Ctrl-click + result FK) +- **UI**: `DataPeekPanel.tsx` — modal grids; Esc closes. Each panel: WHERE / ORDER BY / LIMIT + drag resize + arrange grip. Mounted from `SqlEditorView` (not only Schema). +- **Triggers**: + - Schema explorer Cmd/Ctrl-click TABLE / VIEW / MQT → `openDataPeek`. + - Editor result FK cell (rust underline) → `openDataPeekFromFk` (`ResultsPanel` / `foreignKeyLinksForSql`). +- **Queries**: `tablePreview.ts` — `buildTablePreview`, `buildForeignKeyDrilldown`, `composePeekSql`, `foreignKeyLinksFor` / `foreignKeyLinksForSql` / `tablesFromSql`. +- **Drill**: FK cell → `drillDataPeek` stacks panels (`drillKey` siblings); use **row index**. +- **Store**: `dataPeek` / `openDataPeek` / `openDataPeekFromFk` / `drillDataPeek` / `updateDataPeekFilters` / `pageDataPeekEntry` / `reorderDataPeekEntries` / `setDataPeekPanelHeight` / close APIs. +- **FK color**: `--fox-grid-link` rust `#9a3412` + solid underline. +- Match drilled table names both qualified and bare against schema cache. ### 2. Node `sql` bridge (`-- @node` / `-- @nodets`) **Design**: worker has **no DB handle**. Cell calls `sql\`...\``; worker renders to `{ text, params }` and posts `cell-query` to the API parent; parent runs SQL and replies `cell-query-result`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57fe46d4..e0374821 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,13 @@ A few rules that have bitten people before (the full set is in [CLAUDE.md](CLAUD 1. Branch off `main`. 2. Keep the change focused; update docs and tests alongside code. 3. Ensure the correctness gates pass (`tsc --noEmit` + `vitest run`). -4. Write a clear PR description: what changed, why, and how you verified it. +4. Write a **short, user-facing** PR description: what the user gets, why it + matters, and how you verified it. Prefer a limited summary on GitHub — do + **not** paste long internal implementation plans, agent transcripts, or + speculative design dumps into public PR bodies. +5. Keep copyright / `NOTICE` / `LICENSE` intact. Prefer the SPDX header in + [docs/COPYRIGHT_HEADER.txt](docs/COPYRIGHT_HEADER.txt) on new source files. + Authorship: Huy Phan `` (see [NOTICE](NOTICE)). Bug reports and feature ideas → open a GitHub issue. Security vulnerabilities → **do not** open a public issue; follow [SECURITY.md](SECURITY.md). diff --git a/LICENSE b/LICENSE index aabf3e0e..be1d449a 100644 --- a/LICENSE +++ b/LICENSE @@ -180,7 +180,8 @@ END OF TERMS AND CONDITIONS ## Copyright Notice ``` -Copyright 2026 huyplb +Copyright 2024-2026 Huy Phan +Copyright 2024-2026 tedious-code contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -195,16 +196,19 @@ See the License for the specific language governing permissions and limitations under the License. ``` +Also see the project root `NOTICE` file. + --- ## How to Apply This License to Your Files -Add the following boilerplate notice at the top of each source file: +Add the following boilerplate notice at the top of each source file +(also in `docs/COPYRIGHT_HEADER.txt`): ``` /* - * foxSchema - * Copyright 2026 huyplb@gmail.com + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -217,6 +221,8 @@ Add the following boilerplate notice at the top of each source file: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 */ ``` diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..914fc1a5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +Fox Schema (foxschema) +Copyright 2024-2026 Huy Phan +Copyright 2024-2026 tedious-code contributors + +This product includes software developed by Huy Phan and contributors +to the Fox Schema project (https://github.com/tedious-code/foxschema). + +Licensed under the Apache License, Version 2.0. +See the LICENSE file in the project root for full license text. + +Attribution notice +------------------ +Redistribution or reuse of this work must retain copyright notices and +the Apache License terms. Removing copyright, NOTICE, or LICENSE files +to conceal authorship is not permitted under the License. diff --git a/README.md b/README.md index f5359bca..d9887c0b 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,8 @@ Maintainers: [docs/PUBLISH.md](docs/PUBLISH.md) · [docs/ARCHITECTURE.md](docs/A ## License -Apache-2.0 +Copyright 2024–2026 Huy Phan `` and [tedious-code](https://github.com/tedious-code) +contributors. See [NOTICE](NOTICE) and [LICENSE](LICENSE) (Apache-2.0). + +Keep copyright / NOTICE / LICENSE when you fork or redistribute — stripping +authorship notices is not allowed under the License. diff --git a/apps/web/src/backend/api/sql-execute.test.ts b/apps/web/src/backend/api/sql-execute.test.ts index 924f04aa..ed2388bb 100644 --- a/apps/web/src/backend/api/sql-execute.test.ts +++ b/apps/web/src/backend/api/sql-execute.test.ts @@ -180,7 +180,8 @@ describe('runStatements against a real SQLite file', () => { expect(results[0].error).toMatch(/wrap boom/); } expect(spy).toHaveBeenCalledTimes(1); - expect(String(spy.mock.calls[0]![2])).toContain('_fox_page'); + expect(String(spy.mock.calls[0]![2])).toContain('fox_page'); + expect(String(spy.mock.calls[0]![2])).not.toContain('_fox_page'); } finally { spy.mockRestore(); } diff --git a/apps/web/src/backend/api/sql-execute.ts b/apps/web/src/backend/api/sql-execute.ts index 34c7fafd..5b160166 100644 --- a/apps/web/src/backend/api/sql-execute.ts +++ b/apps/web/src/backend/api/sql-execute.ts @@ -144,8 +144,8 @@ export async function runStatements( continue; } + const paged = wrapSqlForPage(sql, dialect, offset, maxRows); try { - const paged = wrapSqlForPage(sql, dialect, offset, maxRows); const raw = await ConnectionFactory.executeOnConnection>( dialect, connection, @@ -167,7 +167,9 @@ export async function runStatements( const wrapMsg = error instanceof Error ? error.message : String(error); // Fail closed for all pageable statements: raw fallback can materialize // an unbounded result set before shapeRows truncates, and ignores OFFSET. - pushErr(`Paging failed: ${wrapMsg}`); + // Include the wrapped SQL so DB2 alias / dialect issues are diagnosable. + const preview = paged.length > 240 ? `${paged.slice(0, 240)}…` : paged; + pushErr(`Paging failed: ${wrapMsg}\nWrapped SQL: ${preview}`); } } 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 index 852d179f..ff534572 100644 --- a/apps/web/src/backend/api/sql-page-wrap.test.ts +++ b/apps/web/src/backend/api/sql-page-wrap.test.ts @@ -9,10 +9,18 @@ import { 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' + 'SELECT * FROM (SELECT 1) AS fox_page LIMIT 21 OFFSET 40' ); }); + it('wraps db2 without a leading-underscore alias (SQL20521N)', () => { + const sql = wrapSqlForPage('select * from USER order by id DESC', 'db2', 0, 200); + expect(sql).toBe( + 'SELECT * FROM (select * from USER order by id DESC) AS fox_page OFFSET 0 ROWS FETCH FIRST 201 ROWS ONLY' + ); + expect(sql).not.toMatch(/_fox_page/); + }); + it('wraps sqlserver without ORDER BY using dummy 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'); diff --git a/apps/web/src/backend/api/sql-page-wrap.ts b/apps/web/src/backend/api/sql-page-wrap.ts index 3e8123a6..bf123c77 100644 --- a/apps/web/src/backend/api/sql-page-wrap.ts +++ b/apps/web/src/backend/api/sql-page-wrap.ts @@ -1,6 +1,13 @@ /** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * * Wrap a statement so the engine returns a page (LIMIT/OFFSET). * Fetches `limit + 1` rows so the caller can detect `hasNext` without a COUNT. + * + * Alias must not start with `_` — DB2 treats `_…` as a conditional-compilation + * directive (SQL20521N). Keep a plain letter-led name for all dialects. */ import { statementVerb } from '@foxschema/core'; @@ -11,6 +18,9 @@ const PAGEABLE_VERBS = new Set(['select', 'values']); /** Dialects that use T-SQL OFFSET/FETCH (not MySQL/Postgres LIMIT). */ const TSQL_DIALECTS = new Set(['sqlserver', 'mssql', 'azuresql']); +/** Derived-table alias for page wraps (no leading underscore — see file header). */ +const PAGE_ALIAS = 'fox_page'; + export function clampOffset(v: unknown): number { const n = typeof v === 'number' ? Math.floor(v) : Number.NaN; if (!Number.isFinite(n) || n < 0) return 0; @@ -135,16 +145,16 @@ export function wrapSqlForPage( if (hasTopLevelOrderBy(inner)) { return `${inner} ${fetch}`; } - return `SELECT * FROM (${inner}) AS _fox_page ORDER BY (SELECT NULL) ${fetch}`; + return `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} ORDER BY (SELECT NULL) ${fetch}`; } if (d === 'oracle') { - return `SELECT * FROM (${inner}) _fox_page OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`; + return `SELECT * FROM (${inner}) ${PAGE_ALIAS} 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`; + return `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} 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}`; + return `SELECT * FROM (${inner}) AS ${PAGE_ALIAS} LIMIT ${fetchLimit} OFFSET ${offset}`; } /** After shaping, drop the probe row and set truncated/hasNext. */ diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index ff8e1b4a..a2756524 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -1,3 +1,10 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Result DataGrid (paper surface, paging, FK drill links for Data Peek). + */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, Download, GripVertical, RefreshCw } from 'lucide-react'; import type { SqlStatementResult } from '../../api/sqlApi'; @@ -24,20 +31,20 @@ const LONG_TEXT_NAME = type CellKind = 'null' | 'number' | 'boolean' | 'datetime' | 'binary' | 'string'; const KIND_CELL_CLASS: Record = { - null: 'italic text-[#94a3b8]', - number: 'text-[#1d4ed8]', - boolean: 'text-[#7c3aed]', - datetime: 'text-[#0f766e]', - binary: 'text-[#b45309]', - string: 'text-[#1e293b]', + null: 'italic text-[var(--fox-grid-muted)]', + number: 'tabular-nums text-[var(--fox-grid-number)] font-semibold', + boolean: 'text-[var(--fox-grid-boolean)]', + datetime: 'text-[var(--fox-grid-datetime)]', + binary: 'text-[var(--fox-grid-binary)]', + string: 'text-[var(--fox-grid-ink)]', }; const KIND_HEADER_CLASS: Record, string> = { - number: 'text-[#1d4ed8]', - boolean: 'text-[#7c3aed]', - datetime: 'text-[#0f766e]', - binary: 'text-[#b45309]', - string: 'text-[#64748b]', + number: 'text-[var(--fox-grid-number)]', + boolean: 'text-[var(--fox-grid-boolean)]', + datetime: 'text-[var(--fox-grid-datetime)]', + binary: 'text-[var(--fox-grid-binary)]', + string: 'text-[var(--fox-grid-muted)]', }; const KIND_LABEL: Record, string> = { @@ -682,7 +689,8 @@ export const DataGrid: React.FC<{ // 1-based number shown in the # column and would // read the NEXT row's value. onClick={() => onLinkClick?.(colIdx, i)} - className="underline decoration-dotted underline-offset-2 text-cyan-300 hover:text-cyan-200" + // Rust ID color + solid underline — pale cyan was unreadable. + className="underline decoration-solid underline-offset-2 decoration-2 decoration-[var(--fox-grid-link)] text-[var(--fox-grid-link)] font-bold hover:brightness-110" > {text} diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index bbafd5ad..a949ac00 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,29 +1,206 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Quick data peek: Cmd/Ctrl-click a table in the schema explorer to see its + * rows without writing a query. Foreign-key cells are links — each FK column + * can open its own panel below (siblings stack; same column replaces). + * Each panel has WHERE / ORDER BY / LIMIT and a drag resize handle. + * Editor result grids can open the same peek via rust FK cell clicks. + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronRight, Loader2, X } from 'lucide-react'; +import { GripVertical, Loader2, X } from 'lucide-react'; import { useSqlEditorStore, type DataPeekEntry } from '../../store/useSqlEditorStore'; import { foreignKeyLinksFor } from '../../lib/tablePreview'; import { DataGrid } from './DataGrid'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import type { TableSchema } from '../../lib/types'; +const DEFAULT_HEIGHT_ROOT = 360; +/** FK drill panels stack full-width; main body scrolls when many are open. */ +const DEFAULT_HEIGHT_DRILL = 320; +const MIN_PANEL_HEIGHT = 200; +const MAX_PANEL_HEIGHT = 900; + /** - * Quick data peek: Cmd/Ctrl-click a table in the schema explorer to see its - * rows without writing a query. Foreign-key cells are links — clicking one - * appends the parent's rows as another grid below, so you can follow a - * relationship a couple of hops without leaving the popup. + * Quick data peek UI (see file header). */ +const PeekFilterBar: React.FC<{ + entry: DataPeekEntry; + disabled?: boolean; +}> = ({ entry, disabled }) => { + const updateDataPeekFilters = useSqlEditorStore((s) => s.updateDataPeekFilters); + const [where, setWhere] = useState(entry.whereClause); + const [orderBy, setOrderBy] = useState(entry.orderByClause); + const [limit, setLimit] = useState(String(entry.limit)); + + useEffect(() => { + setWhere(entry.whereClause); + setOrderBy(entry.orderByClause); + setLimit(String(entry.limit)); + }, [entry.id, entry.whereClause, entry.orderByClause, entry.limit]); + + const apply = useCallback(() => { + const parsed = Number.parseInt(limit, 10); + void updateDataPeekFilters(entry.id, { + whereClause: where, + orderByClause: orderBy, + limit: Number.isFinite(parsed) ? parsed : entry.limit, + }); + }, [entry.id, entry.limit, where, orderBy, limit, updateDataPeekFilters]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + apply(); + } + }; + + return ( +
+ + + + +
+ ); +}; + +const PeekResizeHandle: React.FC<{ + entryId: string; + heightPx: number; +}> = ({ entryId, heightPx }) => { + const setDataPeekPanelHeight = useSqlEditorStore((s) => s.setDataPeekPanelHeight); + const startY = useRef(0); + const startH = useRef(heightPx); + + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + startY.current = e.clientY; + startH.current = heightPx; + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + const onMove = (ev: PointerEvent) => { + const next = Math.min( + MAX_PANEL_HEIGHT, + Math.max(MIN_PANEL_HEIGHT, startH.current + (ev.clientY - startY.current)) + ); + setDataPeekPanelHeight(entryId, next); + }; + const onUp = (ev: PointerEvent) => { + target.releasePointerCapture(ev.pointerId); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; + + return ( +
+ +
+ ); +}; + const PeekGrid: React.FC<{ entry: DataPeekEntry; tables: TableSchema[] | undefined; - isLast: boolean; -}> = ({ entry, tables, isLast }) => { + /** Root table vs FK drill — drills get a taller default for usable grids. */ + variant: 'root' | 'drill'; + showFkHint: boolean; + dragIndex: number; + isDragging: boolean; + isDragOver: boolean; + onDragStart: (index: number) => void; + onDragOver: (index: number) => void; + onDragLeave: (index: number) => void; + onDrop: (fromIndex: number, toIndex: number) => void; + onDragEnd: () => void; + onClose?: () => void; +}> = ({ + entry, + tables, + variant, + showFkHint, + dragIndex, + isDragging, + isDragOver, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, + onClose, +}) => { const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); + const pageDataPeekEntry = useSqlEditorStore((s) => s.pageDataPeekEntry); - // A drilled entry's table comes from `fk.referencedTable`, which several - // catalogs report schema-qualified ("public.customers") while the cache is - // keyed on the bare name. Match on both, or the second hop silently loses - // its own FK links. const table = useMemo(() => { if (!tables) return undefined; const wanted = entry.tableName.toLowerCase(); @@ -56,38 +233,103 @@ const PeekGrid: React.FC<{ [links, entry, drillDataPeek] ); - if (entry.status === 'loading') { - return ( -
- - Loading {entry.title}… -
- ); - } + const heightPx = + entry.panelHeightPx ?? (variant === 'drill' ? DEFAULT_HEIGHT_DRILL : DEFAULT_HEIGHT_ROOT); - if (entry.status === 'error' || !entry.result) { - return ( -
- {entry.error ?? 'Preview failed'} + return ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + onDragOver(dragIndex); + }} + onDragLeave={() => onDragLeave(dragIndex)} + onDrop={(e) => { + e.preventDefault(); + const from = + Number.parseInt(e.dataTransfer.getData('text/plain'), 10); + if (Number.isFinite(from)) onDrop(from, dragIndex); + }} + > +
+ + + {entry.title} + + {onClose && ( + + )}
- ); - } - return ( -
- 0 ? linkColumns : undefined} - onLinkClick={onLinkClick} - /> - {isLast && linkColumns.size > 0 && ( -

- Underlined cells are foreign keys — click one to open the related rows below. -

+ + + {entry.status === 'loading' && !entry.result && ( +
+ + Loading {entry.title}… +
)} + + {(entry.status === 'error' || (entry.status !== 'loading' && !entry.result)) && ( +
+ {entry.error ?? 'Preview failed'} +
+ )} + + {entry.result?.ok && ( +
+ 0} + hasNextPage={Boolean(entry.result.hasNext ?? entry.result.truncated)} + onPrevPage={() => void pageDataPeekEntry(entry.id, Math.max(0, entry.pageIndex - 1))} + onNextPage={() => void pageDataPeekEntry(entry.id, entry.pageIndex + 1)} + linkColumns={linkColumns.size > 0 ? linkColumns : undefined} + onLinkClick={onLinkClick} + /> + {showFkHint && linkColumns.size > 0 && ( +

+ Underlined rust-colored cells are foreign keys — click several to open more panels. + Drag the ⋮⋮ handle to arrange; scroll to move between them. +

+ )} +
+ )} + +
); }; @@ -96,7 +338,12 @@ export const DataPeekPanel: React.FC = () => { const dataPeek = useSqlEditorStore((s) => s.dataPeek); const closeDataPeek = useSqlEditorStore((s) => s.closeDataPeek); const closeDataPeekFrom = useSqlEditorStore((s) => s.closeDataPeekFrom); + const reorderDataPeekEntries = useSqlEditorStore((s) => s.reorderDataPeekEntries); const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const scrollRef = useRef(null); + const lastEntryCount = useRef(0); + const [dragFrom, setDragFrom] = useState(null); + const [dragOver, setDragOver] = useState(null); useEffect(() => { if (!dataPeek) return; @@ -107,52 +354,69 @@ export const DataPeekPanel: React.FC = () => { return () => window.removeEventListener('keydown', onKey); }, [dataPeek, closeDataPeek]); + // When a new FK panel opens, scroll the main peek body so it comes into view. + useEffect(() => { + if (!dataPeek) { + lastEntryCount.current = 0; + return; + } + const count = dataPeek.entries.length; + if (count > lastEntryCount.current) { + const last = dataPeek.entries[count - 1]; + requestAnimationFrame(() => { + const el = last + ? scrollRef.current?.querySelector(`[data-testid="data-peek-grid-${last.id}"]`) + : null; + el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } + lastEntryCount.current = count; + }, [dataPeek]); + if (!dataPeek) return null; const tables = schemaCache[dataPeek.connectionId]?.tables; + const entries = dataPeek.entries; return createPortal(
e.stopPropagation()} >
- + Data peek - {/* Breadcrumb of the drill path; click a crumb to go back to it. */} -
- {dataPeek.entries.map((e, i) => ( - - {i > 0 && ( - +
+ {entries.map((e) => ( + + {e.title} + {e.parentId && ( + )} - - + ))}
+ + Drag ⋮⋮ to arrange +
-
- {dataPeek.entries.map((e, i) => ( - - ))} +
+
+ {entries.map((e, index) => { + const isRoot = !e.parentId; + return ( +
+ { + if (dragOver !== i) setDragOver(i); + }} + onDragLeave={(i) => { + if (dragOver === i) setDragOver(null); + }} + onDrop={(fromIdx, toIndex) => { + if (fromIdx !== toIndex) { + reorderDataPeekEntries(fromIdx, toIndex); + } + setDragFrom(null); + setDragOver(null); + }} + onDragEnd={() => { + setDragFrom(null); + setDragOver(null); + }} + onClose={isRoot ? undefined : () => closeDataPeekFrom(e.id)} + /> +
+ ); + })} +
, diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index d69b9355..32af4264 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -1,11 +1,20 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Results for one SQL Editor tab (By cred / Side-by-side layouts). + * Foreign-key cells can open Data Peek when schema FKs match the statement. + */ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Loader2, Database, AlertCircle, GripVertical, RefreshCw } from 'lucide-react'; -import type { CredentialRun } from '../../store/useSqlEditorStore'; +import { useSqlEditorStore, type CredentialRun } from '../../store/useSqlEditorStore'; import type { ResultsLayout } from '../../store/sqlEditorTabLogic'; import { DataGrid, PANE_DEFAULT_H_PX, PANE_DEFAULT_PX, PANE_MIN_H_PX, PANE_MIN_PX } from './DataGrid'; import type { SqlStatementResult } from '../../api/sqlApi'; import { detectCodeCell } from '../../lib/codeCellRunner'; import { CODE_CELL_KIND_LABEL } from '../../lib/sql-splitter'; +import { foreignKeyLinksForSql } from '../../lib/tablePreview'; import { SQL_ICON_STROKE } from './sqlIconStyle'; interface Props { @@ -69,6 +78,8 @@ type PaneItem = exportName: string; connectionId: string; statementIndex: number; + /** SQL that produced this grid — used for FK → Data Peek links. */ + statementSql?: string; } | { key: string; kind: 'running'; label: string; connectionId: string } | { key: string; kind: 'error'; label: string; error: string; connectionId: string }; @@ -81,10 +92,153 @@ function equalWidths(count: number, containerW: number): number[] { return Array.from({ length: count }, () => each); } +const PaneBody: React.FC<{ + item: PaneItem; + refreshing?: boolean; + onRefresh?: (connectionId: string) => void; + onPage?: Props['onPage']; + pageState?: Props['pageState']; + syncScrollRow?: number | null; + onSyncScrollRow?: (row: number | null) => void; +}> = ({ + item, + refreshing, + onRefresh, + onPage, + pageState, + syncScrollRow = null, + onSyncScrollRow, +}) => { + const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const openDataPeekFromFk = useSqlEditorStore((s) => s.openDataPeekFromFk); + + const fkLinks = useMemo(() => { + if (item.kind !== 'grid' || !item.result.ok || !item.statementSql) return []; + const tables = schemaCache[item.connectionId]?.tables; + return foreignKeyLinksForSql(item.statementSql, tables, item.result.columns); + }, [item, schemaCache]); + + const linkColumns = useMemo(() => { + if (fkLinks.length === 0) return undefined; + const map = new Map(); + for (const l of fkLinks) map.set(l.columnIndex, l.fk.referencedTable); + return map; + }, [fkLinks]); + + const onLinkClick = useCallback( + (colIdx: number, rowIdx: number) => { + if (item.kind !== 'grid' || !item.result.ok) return; + const link = fkLinks.find((l) => l.columnIndex === colIdx); + if (!link) return; + const row = item.result.rows[rowIdx]; + if (!row) return; + void openDataPeekFromFk( + item.connectionId, + link.fk, + link.valueIndexes.map((i) => row[i]) + ); + }, + [item, fkLinks, openDataPeekFromFk] + ); + + if (item.kind === 'grid') { + const pageKey = `${item.connectionId}:${item.statementIndex}`; + const page = pageState?.[pageKey]; + const pageIndex = page?.pageIndex ?? 0; + return ( +
+ onRefresh(item.connectionId) : undefined} + syncScrollRow={onSyncScrollRow ? syncScrollRow : null} + onSyncScrollRow={onSyncScrollRow} + pageIndex={pageIndex} + pageSize={page?.pageSize} + hasPrevPage={!refreshing && Boolean(page) && pageIndex > 0} + hasNextPage={!refreshing && Boolean(page?.hasNext)} + pageLoading={Boolean(refreshing || page?.loading)} + onPrevPage={ + onPage && page && !refreshing + ? () => + onPage({ + connectionId: item.connectionId, + statementIndex: item.statementIndex, + pageIndex: Math.max(0, pageIndex - 1), + }) + : undefined + } + onNextPage={ + onPage && page && !refreshing + ? () => + onPage({ + connectionId: item.connectionId, + statementIndex: item.statementIndex, + pageIndex: pageIndex + 1, + }) + : undefined + } + linkColumns={linkColumns} + onLinkClick={linkColumns ? onLinkClick : undefined} + /> + {linkColumns && linkColumns.size > 0 && ( +

+ Underlined rust cells are foreign keys — click one to open Data Peek (related rows). +

+ )} +
+ ); + } + if (item.kind === 'running') { + return ( +
+ {' '} + {item.label} +
+ ); + } + return ( +
+
+
+ {item.label} +
+ {onRefresh && ( + + )} +
+
+ + {item.error} +
+
+ ); +}; + /** - * Horizontal row of result panes. Every table has its own drag grip on the - * right edge — widths are independent (growing one does not crush neighbors; - * the row scrolls when panes exceed the viewport). + * Horizontal row of result panes (side-by-side layout). Every table has its own + * drag grip on the right edge — widths are independent. */ const ResizablePaneRow: React.FC<{ items: PaneItem[]; @@ -173,102 +327,38 @@ const ResizablePaneRow: React.FC<{ style={{ height: rowHeight, minHeight: PANE_MIN_H_PX }} data-testid="sql-result-pane-row" > - {items.map((item, i) => { - const pageKey = - item.kind === 'grid' ? `${item.connectionId}:${item.statementIndex}` : ''; - const page = pageKey ? pageState?.[pageKey] : undefined; - const pageIndex = page?.pageIndex ?? 0; - return ( - -
- {item.kind === 'grid' && ( - onRefresh(item.connectionId) : undefined} - syncScrollRow={syncScroll ? syncRow : null} - onSyncScrollRow={syncScroll ? setSyncRow : undefined} - pageIndex={pageIndex} - pageSize={page?.pageSize} - hasPrevPage={!refreshing && Boolean(page) && pageIndex > 0} - hasNextPage={!refreshing && Boolean(page?.hasNext)} - pageLoading={Boolean(refreshing || page?.loading)} - onPrevPage={ - onPage && page && !refreshing - ? () => - onPage({ - connectionId: item.connectionId, - statementIndex: item.statementIndex, - pageIndex: Math.max(0, pageIndex - 1), - }) - : undefined - } - onNextPage={ - onPage && page && !refreshing - ? () => - onPage({ - connectionId: item.connectionId, - statementIndex: item.statementIndex, - pageIndex: pageIndex + 1, - }) - : undefined - } - /> - )} - {item.kind === 'running' && ( -
- {item.label} -
- )} - {item.kind === 'error' && ( -
-
-
- {item.label} -
- {onRefresh && ( - - )} -
-
- - {item.error} -
-
- )} -
-
startPaneResize(i, e)} - className="w-2 shrink-0 cursor-col-resize self-stretch mx-0.5 rounded-sm bg-slate-800 hover:bg-cyan-600/70 active:bg-cyan-500 flex items-center justify-center group" - > - -
-
- ); - })} + {items.map((item, i) => ( + +
+ +
+
startPaneResize(i, e)} + className="w-2 shrink-0 cursor-col-resize self-stretch mx-0.5 rounded-sm bg-slate-800 hover:bg-cyan-600/70 active:bg-cyan-500 flex items-center justify-center group" + > + +
+
+ ))}
void; + onPage?: Props['onPage']; + pageState?: Props['pageState']; +}> = ({ items, refreshing, onRefresh, onPage, pageState }) => { + const [heights, setHeights] = useState(() => items.map(() => PANE_DEFAULT_H_PX)); + + useEffect(() => { + setHeights((prev) => { + if (prev.length === items.length) return prev; + if (prev.length < items.length) { + return [ + ...prev, + ...Array.from({ length: items.length - prev.length }, () => PANE_DEFAULT_H_PX), + ]; + } + return prev.slice(0, items.length); + }); + }, [items.length]); + + const startHeightResize = useCallback((index: number, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const startH = heights[index] ?? PANE_DEFAULT_H_PX; + bindAxisDrag('row-resize', (ev) => { + const next = Math.max(PANE_MIN_H_PX, startH + (ev.clientY - startY)); + setHeights((prev) => { + if (prev[index] === next) return prev; + const copy = [...prev]; + copy[index] = next; + return copy; + }); + }); + }, [heights]); + + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => ( +
+
+ +
+
startHeightResize(i, e)} + className="h-1.5 shrink-0 cursor-row-resize bg-slate-800 hover:bg-cyan-500/40 active:bg-cyan-500/60 transition-colors rounded-sm mt-1" + /> +
+ ))} +
+ ); +}; + +/** + * Results for one tab. `byCredential` stacks credentials, and statement grids + * under each credential are also stacked vertically (not side by side). + * `sideBySide` stacks statements with credential grids as columns. */ export const ResultsPanel: React.FC = ({ runs, @@ -303,8 +468,15 @@ export const ResultsPanel: React.FC = ({ const outTestId = (i: number) => statementIndices?.[i] ?? i; if (runs.length === 0) { return ( -
- Run a query to see results here — one row per checked credential. +
+
+ + Run a query to see results here — one section per checked credential. +
+

+ Tip: after Run, underlined rust foreign-key cells open Data Peek. + Or Cmd/Ctrl-click a table in Schema to peek without writing SQL. +

); } @@ -363,6 +535,7 @@ export const ResultsPanel: React.FC = ({ exportName: `${run.name}-q${i + 1}`, connectionId: run.connectionId, statementIndex: i, + statementSql: statements[i], }); } return ( @@ -405,6 +578,7 @@ export const ResultsPanel: React.FC = ({ exportName: `${run.name}-q${i + 1}`, connectionId: run.connectionId, statementIndex: i, + statementSql: statements[i], })) : []; @@ -437,9 +611,8 @@ export const ResultsPanel: React.FC = ({ )} {run.status === 'done' && items.length > 0 && ( - {
); }; diff --git a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx index b112d8a9..60d2964a 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlSchemaExplorer.tsx @@ -8,7 +8,6 @@ import { filterCallParameters, insertAtCursor } from './sqlEditorBridge'; import type { DbObjectType, TableSchema } from '../../lib/types'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import { TableBlueprintModal, type BlueprintMode } from './TableBlueprintModal'; -import { DataPeekPanel } from './DataPeekPanel'; import { isScriptableObject, objectSourceScript } from './objectSourceScript'; /** Imperative API for the Schema section header (New table). */ @@ -357,7 +356,6 @@ export const SqlSchemaExplorer = forwardRef(function Sq /> )} - )}
diff --git a/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts new file mode 100644 index 00000000..eab3898d --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + moveDataPeekEntry, + removeDataPeekSubtree, + type DataPeekEntry, +} from '../../store/useSqlEditorStore'; + +function entry( + id: string, + extra: Partial = {} +): DataPeekEntry { + return { + id, + title: id, + tableName: id, + baseSql: 'SELECT 1', + baseParams: [], + whereClause: '', + orderByClause: '', + limit: 50, + pageIndex: 0, + sql: 'SELECT 1', + params: [], + status: 'ready', + ...extra, + }; +} + +describe('removeDataPeekSubtree', () => { + it('keeps sibling drills when removing one FK panel', () => { + const root = entry('root'); + const tech = entry('tech', { parentId: 'root', drillKey: 'root|TECH|TECHNICIANID' }); + const order = entry('order', { parentId: 'root', drillKey: 'root|ORDER|ORDERID' }); + const techUser = entry('user', { parentId: 'tech', drillKey: 'tech|USER|USERID' }); + const next = removeDataPeekSubtree([root, tech, order, techUser], 'tech'); + expect(next.map((e) => e.id)).toEqual(['root', 'order']); + }); +}); + +describe('moveDataPeekEntry', () => { + it('reorders panels so users can arrange stacked grids', () => { + const list = [entry('root'), entry('a', { parentId: 'root' }), entry('b', { parentId: 'root' })]; + expect(moveDataPeekEntry(list, 2, 0).map((e) => e.id)).toEqual(['b', 'root', 'a']); + expect(moveDataPeekEntry(list, 0, 2).map((e) => e.id)).toEqual(['a', 'b', 'root']); + expect(moveDataPeekEntry(list, 1, 1)).toBe(list); + }); +}); diff --git a/apps/web/src/frontend/lib/tablePreview.test.ts b/apps/web/src/frontend/lib/tablePreview.test.ts index fcacf81e..69224d6b 100644 --- a/apps/web/src/frontend/lib/tablePreview.test.ts +++ b/apps/web/src/frontend/lib/tablePreview.test.ts @@ -4,6 +4,9 @@ import { buildTablePreview, buildForeignKeyDrilldown, foreignKeyLinksFor, + foreignKeyLinksForSql, + composePeekSql, + isSafePeekClause, } from './tablePreview'; import type { ForeignKeyInfo, TableSchema } from './types'; @@ -96,3 +99,65 @@ describe('foreignKeyLinksFor', () => { expect(foreignKeyLinksFor(undefined, ['a'])).toEqual([]); }); }); + +describe('composePeekSql', () => { + it('ANDs a WHERE onto a base SELECT', () => { + const q = composePeekSql('SELECT * FROM "t"', [], { where: 'ARCHIVED = false' }); + expect(q).toEqual({ + sql: 'SELECT * FROM "t" WHERE (ARCHIVED = false)', + params: [], + }); + }); + + it('ANDs onto an existing FK WHERE and keeps bound params', () => { + const q = composePeekSql('SELECT * FROM "TECHNICIAN" WHERE "ID" = $1', [34], { + where: 'ARCHIVED = false', + orderBy: 'CREATEDAT DESC', + }); + expect(q).toEqual({ + sql: 'SELECT * FROM "TECHNICIAN" WHERE "ID" = $1 AND (ARCHIVED = false) ORDER BY CREATEDAT DESC', + params: [34], + }); + }); + + it('rejects multi-statement and comment filters', () => { + expect(isSafePeekClause('a = 1; DROP TABLE t')).toBe(false); + expect(composePeekSql('SELECT * FROM t', [], { where: '1=1; --' })).toEqual({ + error: 'WHERE must be a single predicate (no ; or comments)', + }); + }); +}); + +describe('foreignKeyLinksForSql', () => { + const orders = { + name: 'orders', + objectType: 'TABLE', + columns: [], + indices: [], + foreignKeys: [ + { + name: 'fk1', + columns: ['customer_id'], + referencedTable: 'customers', + referencedColumns: ['id'], + }, + ], + } as unknown as TableSchema; + + it('links result FK columns from the statement’s FROM table', () => { + const links = foreignKeyLinksForSql( + 'SELECT id, customer_id FROM orders', + [orders], + ['id', 'customer_id'] + ); + expect(links).toHaveLength(1); + expect(links[0]!.columnIndex).toBe(1); + expect(links[0]!.fk.referencedTable).toBe('customers'); + }); + + it('returns nothing when schema is missing the table', () => { + expect( + foreignKeyLinksForSql('SELECT * FROM orders', [], ['customer_id']) + ).toEqual([]); + }); +}); diff --git a/apps/web/src/frontend/lib/tablePreview.ts b/apps/web/src/frontend/lib/tablePreview.ts index 124500eb..3b1a5f33 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -1,5 +1,9 @@ /** - * Queries behind the schema-explorer data peek (Cmd/Ctrl-click a table). + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Queries behind data peek (schema Cmd/Ctrl-click, or FK click in editor results). * * Everything here goes through the `sql` template engine, so a drill-down value * taken from a grid cell is bound, never pasted into the statement — the value @@ -113,3 +117,160 @@ export function foreignKeyLinksFor( } return links; } + +/** Resolve a table name (qualified or bare) against the schema cache. */ +export function findCachedTable( + tables: TableSchema[] | undefined, + name: string +): TableSchema | undefined { + if (!tables?.length || !name.trim()) return undefined; + const wanted = name.toLowerCase(); + const bare = wanted.includes('.') ? wanted.slice(wanted.lastIndexOf('.') + 1) : wanted; + return ( + tables.find((t) => t.name.toLowerCase() === wanted) ?? + tables.find((t) => { + const n = t.name.toLowerCase(); + return (n.includes('.') ? n.slice(n.lastIndexOf('.') + 1) : n) === bare; + }) + ); +} + +/** + * Tables referenced in a statement (FROM/JOIN/UPDATE/INTO only). + * Uses a stricter scan than {@link extractTableAliases} so + * `SELECT a, b FROM t` is not mistaken for a comma-FROM list. + */ +export function tableNamesFromSql(sql: string): string[] { + if (!sql.trim()) return []; + const ident = + '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*(?:\\.[A-Za-z_][\\w$]*)*)'; + const re = new RegExp(`\\b(?:FROM|JOIN|UPDATE|INTO)\\s+(${ident})`, 'gi'); + const names: string[] = []; + const seen = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const raw = m[1]; + if (!raw) continue; + const cleaned = stripSqlIdent(raw); + if (!cleaned) continue; + const key = cleaned.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + names.push(cleaned); + } + return names; +} + +function stripSqlIdent(raw: string): string { + const s = raw.trim(); + if (s.length >= 2) { + const a = s[0]; + const b = s[s.length - 1]; + if ((a === '"' && b === '"') || (a === '`' && b === '`') || (a === '[' && b === ']')) { + return s.slice(1, -1).replace(/""/g, '"'); + } + } + return s; +} + +/** + * Tables referenced in a statement, matched against the schema cache — used to + * offer FK links on editor result grids. + */ +export function tablesFromSql( + sql: string, + tables: TableSchema[] | undefined +): TableSchema[] { + if (!sql.trim() || !tables?.length) return []; + const out: TableSchema[] = []; + const seen = new Set(); + for (const name of tableNamesFromSql(sql)) { + const table = findCachedTable(tables, name); + if (!table) continue; + const key = table.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(table); + } + return out; +} + +/** + * FK links for a result set given the statement SQL + schema tables. + * First table that claims a column wins when joins share FK column names. + */ +export function foreignKeyLinksForSql( + sql: string, + tables: TableSchema[] | undefined, + resultColumns: string[] +): FkColumnLink[] { + const matched = tablesFromSql(sql, tables); + if (matched.length === 0) return []; + const links: FkColumnLink[] = []; + const claimed = new Set(); + for (const table of matched) { + for (const link of foreignKeyLinksFor(table, resultColumns)) { + if (claimed.has(link.columnIndex)) continue; + claimed.add(link.columnIndex); + links.push(link); + } + } + return links; +} + +/** + * Free-text WHERE / ORDER BY for Data Peek. Rejects multi-statement and + * comment tricks so a filter box can't smuggle a second command. + */ +export function isSafePeekClause(clause: string): boolean { + const t = clause.trim(); + if (!t) return true; + if (/[;]/.test(t)) return false; + if (/--/.test(t)) return false; + if (/\/\*|\*\//.test(t)) return false; + return true; +} + +export interface PeekFilterClauses { + /** Predicate only — no leading WHERE. ANDed onto the base query. */ + where?: string; + /** Sort list only — no leading ORDER BY. */ + orderBy?: string; +} + +/** + * Layer optional user filters onto a peek base query (`SELECT * FROM …` + * or a bound FK drill). Params stay those of the base; the filter text is + * not parameterized (same trust model as typing SQL in the editor). + */ +export function composePeekSql( + baseSql: string, + baseParams: unknown[], + filters: PeekFilterClauses = {} +): PreviewQuery | { error: string } { + const where = (filters.where ?? '').trim(); + const orderBy = (filters.orderBy ?? '').trim(); + if (!isSafePeekClause(where)) { + return { error: 'WHERE must be a single predicate (no ; or comments)' }; + } + if (!isSafePeekClause(orderBy)) { + return { error: 'ORDER BY must be a sort list (no ; or comments)' }; + } + + let sql = baseSql.trim().replace(/;+\s*$/, ''); + if (where) { + // Simple detection is enough: peek bases are SELECT * FROM … [WHERE …]. + if (/\bWHERE\b/i.test(sql)) { + sql = `${sql} AND (${where})`; + } else { + sql = `${sql} WHERE (${where})`; + } + } + if (orderBy) { + if (/\bORDER\s+BY\b/i.test(sql)) { + return { error: 'Base peek already has ORDER BY — clear it before adding one' }; + } + sql = `${sql} ORDER BY ${orderBy}`; + } + return { sql, params: baseParams }; +} diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 36d18d1e..f40a15f1 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -1,3 +1,10 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * SQL Editor client store (tabs, execute, schema cache, Data Peek). + */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { executeSql, type SqlStatementResult } from '../api/sqlApi'; @@ -8,7 +15,11 @@ import { isMutatingDmlStatement, isWriteStatement, splitSqlStatements } from '.. import type { CodeCellLast } from '../lib/codeCellExec'; import { detectCodeCell, runCodeCell } from '../lib/codeCellRunner'; import { buildSampleBookmarks } from '../lib/sqlEditorSamples'; -import { buildForeignKeyDrilldown, buildTablePreview } from '../lib/tablePreview'; +import { + buildForeignKeyDrilldown, + buildTablePreview, + composePeekSql, +} from '../lib/tablePreview'; import type { ForeignKeyInfo } from '../lib/types'; import { mergeVaultSecretsIntoVariables } from '../lib/mergeVaultSecrets'; import { @@ -228,11 +239,38 @@ export interface DataPeekEntry { title: string; /** Table the rows came from — drives the FK links on this grid. */ tableName: string; + /** Bound base query before user WHERE / ORDER BY filters. */ + baseSql: string; + baseParams: unknown[]; + /** User filter text (no leading WHERE). */ + whereClause: string; + /** User sort text (no leading ORDER BY). */ + orderByClause: string; + /** Rows/page for this peek panel (sent as execute page size). */ + limit: number; + /** 0-based page for server OFFSET paging. */ + pageIndex: number; + /** Composed SQL actually executed. */ sql: string; params: unknown[]; status: 'loading' | 'ready' | 'error'; result?: SqlStatementResult; error?: string; + /** Parent peek this drill opened from (root has no parent). */ + parentId?: string; + /** + * Stable slot for sibling drills from the same parent + FK column. + * Re-clicking the same FK replaces this panel; other FK columns stack. + */ + drillKey?: string; + /** Optional manual panel height (px); drag handle updates this. */ + panelHeightPx?: number; +} + +export interface DataPeekFilterPatch { + whereClause?: string; + orderByClause?: string; + limit?: number; } export interface DataPeekState { @@ -244,6 +282,53 @@ export interface DataPeekState { /** Rows fetched per peek grid — a peek is a glance, not a report. */ const DATA_PEEK_ROWS = 50; +/** Drop an entry and any drills that descend from it. */ +export function removeDataPeekSubtree( + entries: DataPeekEntry[], + rootId: string +): DataPeekEntry[] { + const drop = new Set([rootId]); + let grew = true; + while (grew) { + grew = false; + for (const e of entries) { + if (e.parentId && drop.has(e.parentId) && !drop.has(e.id)) { + drop.add(e.id); + grew = true; + } + } + } + return entries.filter((e) => !drop.has(e.id)); +} + +/** Move one peek panel in the stacked list (visual arrange). */ +export function moveDataPeekEntry( + entries: DataPeekEntry[], + fromIndex: number, + toIndex: number +): DataPeekEntry[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= entries.length || + toIndex >= entries.length + ) { + return entries; + } + const next = [...entries]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved!); + return next; +} + +function dataPeekDrillKey( + fromEntryId: string, + fk: { referencedTable: string; columns?: string[] } +): string { + return `${fromEntryId}|${fk.referencedTable}|${(fk.columns ?? []).join(',')}`; +} + interface SqlEditorState { tabs: SqlTab[]; activeTabId: string; @@ -341,6 +426,15 @@ interface SqlEditorState { */ dataPeek: DataPeekState | null; openDataPeek: (connectionId: string, tableName: string) => Promise; + /** + * Open Data Peek from an editor-result FK cell (no schema Cmd/Ctrl-click). + * Seeds the stack with the parent rows for that key. + */ + openDataPeekFromFk: ( + connectionId: string, + fk: ForeignKeyInfo, + values: unknown[] + ) => Promise; drillDataPeek: ( fromEntryId: string, fk: ForeignKeyInfo, @@ -349,6 +443,14 @@ interface SqlEditorState { closeDataPeek: () => void; /** Drop `entryId` and every grid below it (click a breadcrumb to go back). */ closeDataPeekFrom: (entryId: string) => void; + /** Apply WHERE / ORDER BY / LIMIT on one peek panel and re-run. */ + updateDataPeekFilters: (entryId: string, patch: DataPeekFilterPatch) => Promise; + /** Persist a dragged panel height. */ + setDataPeekPanelHeight: (entryId: string, heightPx: number) => void; + /** Drag panels to rearrange the vertical stack. */ + reorderDataPeekEntries: (fromIndex: number, toIndex: number) => void; + /** Load another OFFSET page for one peek panel. */ + pageDataPeekEntry: (entryId: string, pageIndex: number) => Promise; /** Internal: (re)run one peek entry's query. */ runDataPeekEntry: (entryId: string) => Promise; /** Create or overwrite a variable by name. Returns error string or null. */ @@ -1348,12 +1450,48 @@ export const useSqlEditorStore = create()( const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId); if (!conn) return; const built = buildTablePreview(tableName, conn.dialect); + const composed = composePeekSql(built.sql, built.params, {}); + if ('error' in composed) return; const entry: DataPeekEntry = { id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: tableName, tableName, - sql: built.sql, - params: built.params, + baseSql: built.sql, + baseParams: built.params, + whereClause: '', + orderByClause: '', + limit: DATA_PEEK_ROWS, + pageIndex: 0, + sql: composed.sql, + params: composed.params, + status: 'loading', + }; + set({ dataPeek: { connectionId, dialect: conn.dialect, entries: [entry] } }); + await get().runDataPeekEntry(entry.id); + }, + + openDataPeekFromFk: async (connectionId, fk, values) => { + const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId); + if (!conn) return; + const built = buildForeignKeyDrilldown(fk, values, conn.dialect); + if (!built) return; + const composed = composePeekSql(built.sql, built.params, {}); + if ('error' in composed) return; + const label = (fk.referencedColumns ?? []) + .map((c, i) => `${c} = ${String(values[i])}`) + .join(', '); + const entry: DataPeekEntry = { + id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + title: `${fk.referencedTable} · ${label}`, + tableName: fk.referencedTable, + baseSql: built.sql, + baseParams: built.params, + whereClause: '', + orderByClause: '', + limit: DATA_PEEK_ROWS, + pageIndex: 0, + sql: composed.sql, + params: composed.params, status: 'loading', }; set({ dataPeek: { connectionId, dialect: conn.dialect, entries: [entry] } }); @@ -1365,25 +1503,139 @@ export const useSqlEditorStore = create()( if (!peek) return; const built = buildForeignKeyDrilldown(fk, values, peek.dialect); if (!built) return; + const composed = composePeekSql(built.sql, built.params, {}); + if ('error' in composed) return; const label = (fk.referencedColumns ?? []) .map((c, i) => `${c} = ${String(values[i])}`) .join(', '); + const drillKey = dataPeekDrillKey(fromEntryId, fk); const entry: DataPeekEntry = { id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: `${fk.referencedTable} · ${label}`, tableName: fk.referencedTable, - sql: built.sql, - params: built.params, + baseSql: built.sql, + baseParams: built.params, + whereClause: '', + orderByClause: '', + limit: DATA_PEEK_ROWS, + pageIndex: 0, + sql: composed.sql, + params: composed.params, status: 'loading', + parentId: fromEntryId, + drillKey, }; - // Drilling from a grid mid-stack replaces everything below it, so the - // stack always reads as one path rather than a branching history. - const from = peek.entries.findIndex((e) => e.id === fromEntryId); - const kept = from >= 0 ? peek.entries.slice(0, from + 1) : peek.entries; - set({ dataPeek: { ...peek, entries: [...kept, entry] } }); + // Same FK column → replace that panel (and its children). Other FK + // columns from the same parent stay open as sibling peeks. + let entries = peek.entries; + const existing = entries.find((e) => e.drillKey === drillKey); + if (existing) { + entries = removeDataPeekSubtree(entries, existing.id); + } + set({ dataPeek: { ...peek, entries: [...entries, entry] } }); await get().runDataPeekEntry(entry.id); }, + updateDataPeekFilters: async (entryId, patch) => { + const peek = get().dataPeek; + if (!peek) return; + const entry = peek.entries.find((e) => e.id === entryId); + if (!entry) return; + const whereClause = patch.whereClause ?? entry.whereClause; + const orderByClause = patch.orderByClause ?? entry.orderByClause; + let limit = patch.limit ?? entry.limit; + if (!Number.isFinite(limit) || limit < 1) limit = 1; + limit = Math.min(5000, Math.floor(limit)); + const composed = composePeekSql(entry.baseSql, entry.baseParams, { + where: whereClause, + orderBy: orderByClause, + }); + if ('error' in composed) { + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId + ? { + ...e, + whereClause, + orderByClause, + limit, + pageIndex: 0, + status: 'error' as const, + error: composed.error, + } + : e + ), + }, + }); + return; + } + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId + ? { + ...e, + whereClause, + orderByClause, + limit, + pageIndex: 0, + sql: composed.sql, + params: composed.params, + status: 'loading' as const, + error: undefined, + } + : e + ), + }, + }); + await get().runDataPeekEntry(entryId); + }, + + setDataPeekPanelHeight: (entryId, heightPx) => { + const peek = get().dataPeek; + if (!peek) return; + const clamped = Math.min(900, Math.max(220, Math.round(heightPx))); + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId ? { ...e, panelHeightPx: clamped } : e + ), + }, + }); + }, + + reorderDataPeekEntries: (fromIndex, toIndex) => { + const peek = get().dataPeek; + if (!peek) return; + const entries = moveDataPeekEntry(peek.entries, fromIndex, toIndex); + if (entries === peek.entries) return; + set({ dataPeek: { ...peek, entries } }); + }, + + pageDataPeekEntry: async (entryId, pageIndex) => { + const peek = get().dataPeek; + if (!peek) return; + const entry = peek.entries.find((e) => e.id === entryId); + if (!entry) return; + const nextPage = Math.max(0, Math.floor(pageIndex)); + if (nextPage === entry.pageIndex && entry.status === 'ready') return; + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId + ? { ...e, pageIndex: nextPage, status: 'loading' as const, error: undefined } + : e + ), + }, + }); + await get().runDataPeekEntry(entryId); + }, + runDataPeekEntry: async (entryId) => { const peek = get().dataPeek; if (!peek) return; @@ -1400,14 +1652,17 @@ export const useSqlEditorStore = create()( }); }; try { + const pageSize = Math.min(5000, Math.max(1, entry.limit || DATA_PEEK_ROWS)); + const pageIndex = Math.max(0, entry.pageIndex || 0); + const offset = pageIndex * pageSize; const { results } = await executeSql( { connectionId: peek.connectionId, password: get().sessionPasswords[peek.connectionId] || undefined, }, [entry.sql], - DATA_PEEK_ROWS, - 0, + pageSize, + offset, [entry.params] ); const result = results[0]; @@ -1430,12 +1685,19 @@ export const useSqlEditorStore = create()( closeDataPeekFrom: (entryId) => { const peek = get().dataPeek; if (!peek) return; - const idx = peek.entries.findIndex((e) => e.id === entryId); - if (idx <= 0) { + const target = peek.entries.find((e) => e.id === entryId); + if (!target) return; + // Closing the root dismisses the whole peek. + if (!target.parentId) { set({ dataPeek: null }); return; } - set({ dataPeek: { ...peek, entries: peek.entries.slice(0, idx) } }); + set({ + dataPeek: { + ...peek, + entries: removeDataPeekSubtree(peek.entries, entryId), + }, + }); }, installSampleBookmarks: () => { diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 99de8412..7d3a0687 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -417,6 +417,13 @@ mark.fox-search-hl { --fox-grid-border-soft: #e2e8f0; --fox-grid-ink: #1e293b; --fox-grid-muted: #64748b; + /* High-contrast type colors on paper (avoid washed cyan/blue). */ + --fox-grid-number: #1e3a8a; + --fox-grid-boolean: #5b21b6; + --fox-grid-datetime: #115e59; + --fox-grid-binary: #9a3412; + /* FK / ID references — rust, distinct from navy numbers. */ + --fox-grid-link: #9a3412; } [data-theme='light'] .fox-sql-grid { @@ -429,4 +436,9 @@ mark.fox-search-hl { --fox-grid-border-soft: #94a3b8; --fox-grid-ink: #0f172a; --fox-grid-muted: #334155; + --fox-grid-number: #1e3a8a; + --fox-grid-boolean: #5b21b6; + --fox-grid-datetime: #115e59; + --fox-grid-binary: #9a3412; + --fox-grid-link: #9a3412; } diff --git a/docs/COPYRIGHT_HEADER.txt b/docs/COPYRIGHT_HEADER.txt new file mode 100644 index 00000000..e376c282 --- /dev/null +++ b/docs/COPYRIGHT_HEADER.txt @@ -0,0 +1,18 @@ +/* + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0d813f92..4e748900 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -131,13 +131,15 @@ Tips: locally; result grids are not. - **Schema explorer** — browse objects on the left; click a name to insert it at the cursor. Autocomplete uses the checked connections’ schemas when available. -- **Data peek (Cmd/Ctrl-click)** — hold **Cmd** (macOS) or **Ctrl** (Windows/Linux) - and click a table, view or MQT in the schema explorer to see its rows straight - away, without writing a query. Foreign-key cells are underlined: click one and - the related parent rows open as another grid **below**, so you can follow a - relationship a few hops. The breadcrumb at the top walks back, **Esc** closes. - Values are sent as bind parameters, so a cell containing a quote is handled - correctly. Peeks fetch 50 rows and are read-only — nothing is written. +- **Data peek** — two ways in: + - **Schema:** hold **Cmd** (macOS) or **Ctrl** (Windows/Linux) and click a + table, view or MQT to see its rows without writing a query. + - **Results:** after Run, foreign-key cells are underlined in rust — click one + to open the related parent rows in Data Peek. + In the peek window you can follow more FKs (panels stack and scroll), edit + WHERE / ORDER BY / LIMIT, use Prev/Next, drag ⋮⋮ to rearrange, and resize. + **Esc** closes. Values are bind parameters. Peeks fetch a page of rows and are + read-only. - **Format** — pretty-print the buffer. **Clear** removes results for the active tab. - **Bookmarks** — save reusable snippets from the sidebar. - **Variables** — named values reused as `${{name}}` or `${{name.col}}` (table