From 3de784ae23d98180aab1d66601a785b0e7b76f5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 00:41:36 +0000 Subject: [PATCH 01/17] =?UTF-8?q?fix(sql-editor):=20rename=20page=20wrap?= =?UTF-8?q?=20alias=20=E2=80=94=20DB2=20rejects=20leading=20=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB2 treats identifiers like _fox_page as conditional-compilation directives (SQL20521N). Use fox_page for all dialect page wraps. Co-authored-by: huy.phan9 --- apps/web/src/backend/api/sql-execute.test.ts | 3 ++- apps/web/src/backend/api/sql-page-wrap.test.ts | 10 +++++++++- apps/web/src/backend/api/sql-page-wrap.ts | 14 ++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) 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-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..15f22180 100644 --- a/apps/web/src/backend/api/sql-page-wrap.ts +++ b/apps/web/src/backend/api/sql-page-wrap.ts @@ -1,6 +1,9 @@ /** * 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 +14,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 +141,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. */ From 81e065b26ebd86968d102339d230882f3c5dfce5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 01:03:23 +0000 Subject: [PATCH 02/17] fix(sql-editor): include wrapped SQL in paging failure messages Makes DB2 alias issues (e.g. legacy _fox_page) visible in the UI so a stale API process is obvious after checkout. Co-authored-by: huy.phan9 --- apps/web/src/backend/api/sql-execute.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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; From 808ebd228a452418f5152906299a2bd131dbb245 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 04:46:42 +0000 Subject: [PATCH 03/17] fix(sql-editor): Data Peek fit + clearer number/link colors on paper Constrain the peek modal and grid height so wide tables scroll inside the viewport. Use high-contrast CSS vars for numeric cells and FK links (replace washed cyan on the light paper grid). Co-authored-by: huy.phan9 --- .../components/sql-editor/DataGrid.tsx | 24 +++++++++---------- .../components/sql-editor/DataPeekPanel.tsx | 13 ++++++---- apps/web/src/style.css | 11 +++++++++ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index ff8e1b4a..67c2961e 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -24,20 +24,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 +682,7 @@ 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" + className="underline decoration-dotted underline-offset-2 text-[var(--fox-grid-link)] hover:brightness-125 font-semibold" > {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..111c6832 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -74,7 +74,10 @@ const PeekGrid: React.FC<{ } return ( -
+
{isLast && linkColumns.size > 0 && ( -

+

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

)} @@ -112,12 +115,12 @@ export const DataPeekPanel: React.FC = () => { return createPortal(
e.stopPropagation()} >
@@ -165,7 +168,7 @@ export const DataPeekPanel: React.FC = () => {
-
+
{dataPeek.entries.map((e, i) => ( Date: Wed, 29 Jul 2026 04:53:39 +0000 Subject: [PATCH 04/17] feat(sql-editor): multi FK Data Peek panels + readable number links Stack sibling drills per FK column (order / technician / lifecycle can all stay open). FK cells keep the dark number color with underline only. Fit the peek modal to the viewport with compact child panels. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataGrid.tsx | 4 +- .../components/sql-editor/DataPeekPanel.tsx | 141 ++++++++++++------ .../sql-editor/dataPeekSubtree.test.ts | 28 ++++ .../src/frontend/store/useSqlEditorStore.ts | 62 +++++++- 4 files changed, 177 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 67c2961e..5ec504d5 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -682,7 +682,9 @@ 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-[var(--fox-grid-link)] hover:brightness-125 font-semibold" + // Keep the cell's type color (numbers stay dark navy). + // Underline alone marks the FK — pale cyan was unreadable on paper. + className={`underline decoration-dotted underline-offset-2 decoration-[var(--fox-grid-link)] font-semibold ${KIND_CELL_CLASS[kind]}`} > {text} diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index 111c6832..1d250d96 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronRight, Loader2, X } from 'lucide-react'; +import { Loader2, X } from 'lucide-react'; import { useSqlEditorStore, type DataPeekEntry } from '../../store/useSqlEditorStore'; import { foreignKeyLinksFor } from '../../lib/tablePreview'; import { DataGrid } from './DataGrid'; @@ -9,21 +9,19 @@ import type { TableSchema } from '../../lib/types'; /** * 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. + * rows without writing a query. Foreign-key cells are links — each FK column + * can open its own panel below (siblings stack; same column replaces). */ const PeekGrid: React.FC<{ entry: DataPeekEntry; tables: TableSchema[] | undefined; - isLast: boolean; -}> = ({ entry, tables, isLast }) => { + /** Shorter height when several panels share the modal. */ + compact: boolean; + showFkHint: boolean; + onClose?: () => void; +}> = ({ entry, tables, compact, showFkHint, onClose }) => { const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); - // 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,6 +54,10 @@ const PeekGrid: React.FC<{ [links, entry, drillDataPeek] ); + const heightClass = compact + ? 'h-[min(34vh,300px)]' + : 'h-[min(52vh,460px)]'; + if (entry.status === 'loading') { return (
@@ -75,20 +77,37 @@ const PeekGrid: React.FC<{ return (
+
+ + {entry.title} + + {onClose && ( + + )} +
0 ? linkColumns : undefined} onLinkClick={onLinkClick} /> - {isLast && linkColumns.size > 0 && ( + {showFkHint && linkColumns.size > 0 && (

- Underlined cells are foreign keys — click one to open the related rows below. + Underlined cells are foreign keys — each column opens its own panel below (e.g. order, + technician, and lifecycle can all stay open).

)}
@@ -112,48 +131,45 @@ export const DataPeekPanel: React.FC = () => { if (!dataPeek) return null; const tables = schemaCache[dataPeek.connectionId]?.tables; + const root = dataPeek.entries.find((e) => !e.parentId) ?? dataPeek.entries[0]; + const drills = dataPeek.entries.filter((e) => e.parentId); + const multi = dataPeek.entries.length > 1; 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 && ( - +
+ {dataPeek.entries.map((e) => ( + + {e.title} + {e.parentId && ( + )} - - + ))}
-
- {dataPeek.entries.map((e, i) => ( +
+ {root && ( - ))} + )} + {drills.length > 0 && ( +
+ {drills.map((e) => ( +
+ closeDataPeekFrom(e.id)} + /> +
+ ))} +
+ )}
, 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..c87e873f --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { removeDataPeekSubtree, type DataPeekEntry } from '../../store/useSqlEditorStore'; + +function entry( + id: string, + extra: Partial = {} +): DataPeekEntry { + return { + id, + title: id, + tableName: id, + 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']); + }); +}); diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 36d18d1e..8122f362 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -233,6 +233,13 @@ export interface DataPeekEntry { 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; } export interface DataPeekState { @@ -244,6 +251,32 @@ 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)); +} + +function dataPeekDrillKey( + fromEntryId: string, + fk: { referencedTable: string; columns?: string[] } +): string { + return `${fromEntryId}|${fk.referencedTable}|${(fk.columns ?? []).join(',')}`; +} + interface SqlEditorState { tabs: SqlTab[]; activeTabId: string; @@ -1368,6 +1401,7 @@ export const useSqlEditorStore = create()( 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}`, @@ -1375,12 +1409,17 @@ export const useSqlEditorStore = create()( sql: built.sql, params: built.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); }, @@ -1430,12 +1469,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: () => { From 8d1d36d75502f652d07885490e5ce54c67bd1144 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:04:50 +0000 Subject: [PATCH 05/17] feat(sql-editor): Data Peek filters, resize, and rust FK IDs Add per-panel WHERE / ORDER BY / LIMIT with safe clause checks, drag-to-resize grids, and high-contrast rust foreign-key link colors. Co-authored-by: huy.phan9 --- .cursor/rules/sql-editor-agent-memory.mdc | 8 +- .../components/sql-editor/DataGrid.tsx | 5 +- .../components/sql-editor/DataPeekPanel.tsx | 217 +++++++++++++++--- .../sql-editor/dataPeekSubtree.test.ts | 5 + .../web/src/frontend/lib/tablePreview.test.ts | 30 +++ apps/web/src/frontend/lib/tablePreview.ts | 57 +++++ .../src/frontend/store/useSqlEditorStore.ts | 123 +++++++++- apps/web/src/style.css | 5 +- 8 files changed, 401 insertions(+), 49 deletions(-) diff --git a/.cursor/rules/sql-editor-agent-memory.mdc b/.cursor/rules/sql-editor-agent-memory.mdc index 8851eccc..c35a9f33 100644 --- a/.cursor/rules/sql-editor-agent-memory.mdc +++ b/.cursor/rules/sql-editor-agent-memory.mdc @@ -32,14 +32,16 @@ FoxSchema = schema diff/migration + SQL Editor. Destinations checklist, Schema e - Password / Safe-mode confirm preserve `statementIndices` on resume. ### 1. Data peek (Cmd/Ctrl-click) -- **UI**: `DataPeekPanel.tsx` — modal grids + breadcrumb; Esc closes. +- **UI**: `DataPeekPanel.tsx` — modal grids; Esc closes. Each panel: WHERE / ORDER BY / LIMIT + drag resize. - **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. + - `composePeekSql` / `isSafePeekClause` — user WHERE/ORDER BY layered onto base (reject `;` / comments). - `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`. +- **Drill**: FK cell click → `drillDataPeek` stacks another grid (siblings by `drillKey`); use **row index**. +- **Store**: `dataPeek` / `openDataPeek` / `drillDataPeek` / `updateDataPeekFilters` / `setDataPeekPanelHeight` / `closeDataPeek` / `closeDataPeekFrom`. +- **FK color**: `--fox-grid-link` rust `#9a3412` + solid underline (not pale cyan). - Match drilled table names both qualified (`public.customers`) and bare against schema cache. ### 2. Node `sql` bridge (`-- @node` / `-- @nodets`) diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 5ec504d5..09db4b47 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -682,9 +682,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)} - // Keep the cell's type color (numbers stay dark navy). - // Underline alone marks the FK — pale cyan was unreadable on paper. - className={`underline decoration-dotted underline-offset-2 decoration-[var(--fox-grid-link)] font-semibold ${KIND_CELL_CLASS[kind]}`} + // 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 1d250d96..4735ca3f 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Loader2, X } from 'lucide-react'; import { useSqlEditorStore, type DataPeekEntry } from '../../store/useSqlEditorStore'; @@ -7,15 +7,157 @@ import { DataGrid } from './DataGrid'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import type { TableSchema } from '../../lib/types'; +const DEFAULT_HEIGHT_COMPACT = 280; +const DEFAULT_HEIGHT_ROOT = 420; + /** * 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. */ +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 = 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; - /** Shorter height when several panels share the modal. */ + /** Shorter default height when several panels share the modal. */ compact: boolean; showFkHint: boolean; onClose?: () => void; @@ -54,30 +196,13 @@ const PeekGrid: React.FC<{ [links, entry, drillDataPeek] ); - const heightClass = compact - ? 'h-[min(34vh,300px)]' - : 'h-[min(52vh,460px)]'; - - if (entry.status === 'loading') { - return ( -
- - Loading {entry.title}… -
- ); - } - - if (entry.status === 'error' || !entry.result) { - return ( -
- {entry.error ?? 'Preview failed'} -
- ); - } + const heightPx = + entry.panelHeightPx ?? (compact ? DEFAULT_HEIGHT_COMPACT : DEFAULT_HEIGHT_ROOT); return (
@@ -97,19 +222,41 @@ const PeekGrid: React.FC<{ )}
- 0 ? linkColumns : undefined} - onLinkClick={onLinkClick} - /> - {showFkHint && linkColumns.size > 0 && ( -

- Underlined cells are foreign keys — each column opens its own panel below (e.g. order, - technician, and lifecycle can all stay open). -

+ + + + {entry.status === 'loading' && ( +
+ + Loading {entry.title}… +
+ )} + + {(entry.status === 'error' || (entry.status === 'ready' && !entry.result)) && ( +
+ {entry.error ?? 'Preview failed'} +
+ )} + + {entry.status === 'ready' && entry.result && ( +
+ 0 ? linkColumns : undefined} + onLinkClick={onLinkClick} + /> + {showFkHint && linkColumns.size > 0 && ( +

+ Underlined rust-colored cells are foreign keys — click orderID and technicianID to open + both panels below. +

+ )} +
)} + +
); }; diff --git a/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts index c87e873f..1194bfe4 100644 --- a/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts +++ b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts @@ -9,6 +9,11 @@ function entry( id, title: id, tableName: id, + baseSql: 'SELECT 1', + baseParams: [], + whereClause: '', + orderByClause: '', + limit: 50, sql: 'SELECT 1', params: [], status: 'ready', diff --git a/apps/web/src/frontend/lib/tablePreview.test.ts b/apps/web/src/frontend/lib/tablePreview.test.ts index fcacf81e..a85d7c70 100644 --- a/apps/web/src/frontend/lib/tablePreview.test.ts +++ b/apps/web/src/frontend/lib/tablePreview.test.ts @@ -4,6 +4,8 @@ import { buildTablePreview, buildForeignKeyDrilldown, foreignKeyLinksFor, + composePeekSql, + isSafePeekClause, } from './tablePreview'; import type { ForeignKeyInfo, TableSchema } from './types'; @@ -96,3 +98,31 @@ 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)', + }); + }); +}); diff --git a/apps/web/src/frontend/lib/tablePreview.ts b/apps/web/src/frontend/lib/tablePreview.ts index 124500eb..b8bcaa01 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -113,3 +113,60 @@ export function foreignKeyLinksFor( } 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 8122f362..95247278 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -8,7 +8,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,6 +232,16 @@ 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; + /** Composed SQL actually executed. */ sql: string; params: unknown[]; status: 'loading' | 'ready' | 'error'; @@ -240,6 +254,14 @@ export interface DataPeekEntry { * 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 { @@ -382,6 +404,10 @@ 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; /** Internal: (re)run one peek entry's query. */ runDataPeekEntry: (entryId: string) => Promise; /** Create or overwrite a variable by name. Returns error string or null. */ @@ -1381,12 +1407,19 @@ 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, + sql: composed.sql, + params: composed.params, status: 'loading', }; set({ dataPeek: { connectionId, dialect: conn.dialect, entries: [entry] } }); @@ -1398,6 +1431,8 @@ 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(', '); @@ -1406,8 +1441,13 @@ export const useSqlEditorStore = create()( 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, + sql: composed.sql, + params: composed.params, status: 'loading', parentId: fromEntryId, drillKey, @@ -1423,6 +1463,76 @@ export const useSqlEditorStore = create()( 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, + status: 'error' as const, + error: composed.error, + } + : e + ), + }, + }); + return; + } + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId + ? { + ...e, + whereClause, + orderByClause, + limit, + 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(140, Math.round(heightPx))); + set({ + dataPeek: { + ...peek, + entries: peek.entries.map((e) => + e.id === entryId ? { ...e, panelHeightPx: clamped } : e + ), + }, + }); + }, + runDataPeekEntry: async (entryId) => { const peek = get().dataPeek; if (!peek) return; @@ -1439,13 +1549,14 @@ export const useSqlEditorStore = create()( }); }; try { + const pageSize = Math.min(5000, Math.max(1, entry.limit || DATA_PEEK_ROWS)); const { results } = await executeSql( { connectionId: peek.connectionId, password: get().sessionPasswords[peek.connectionId] || undefined, }, [entry.sql], - DATA_PEEK_ROWS, + pageSize, 0, [entry.params] ); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 4f4a29bb..7d3a0687 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -422,7 +422,8 @@ mark.fox-search-hl { --fox-grid-boolean: #5b21b6; --fox-grid-datetime: #115e59; --fox-grid-binary: #9a3412; - --fox-grid-link: #075985; + /* FK / ID references — rust, distinct from navy numbers. */ + --fox-grid-link: #9a3412; } [data-theme='light'] .fox-sql-grid { @@ -439,5 +440,5 @@ mark.fox-search-hl { --fox-grid-boolean: #5b21b6; --fox-grid-datetime: #115e59; --fox-grid-binary: #9a3412; - --fox-grid-link: #0c4a6e; + --fox-grid-link: #9a3412; } From b09385a35ec0190e2fa49f4f419f0dc6fc9d3816 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:10:04 +0000 Subject: [PATCH 06/17] fix(sql-editor): stack By cred Out grids vertically By credential mode no longer lays statement results side by side; each Out grid is full width with its own height resize handle. Co-authored-by: huy.phan9 --- .cursor/rules/sql-editor-agent-memory.mdc | 2 +- .../components/sql-editor/ResultsPanel.tsx | 319 ++++++++++++------ .../components/sql-editor/SqlEditorView.tsx | 2 +- 3 files changed, 216 insertions(+), 107 deletions(-) diff --git a/.cursor/rules/sql-editor-agent-memory.mdc b/.cursor/rules/sql-editor-agent-memory.mdc index c35a9f33..b889a0b6 100644 --- a/.cursor/rules/sql-editor-agent-memory.mdc +++ b/.cursor/rules/sql-editor-agent-memory.mdc @@ -28,7 +28,7 @@ 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) diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index d69b9355..c93e584e 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -81,10 +81,109 @@ 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, +}) => { + 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 + } + /> + ); + } + 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 +272,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, @@ -304,7 +414,7 @@ export const ResultsPanel: React.FC = ({ 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.
); } @@ -437,9 +547,8 @@ export const ResultsPanel: React.FC = ({ )} {run.status === 'done' && items.length > 0 && ( - {
@@ -341,16 +341,7 @@ export const DataPeekPanel: React.FC = () => { /> )} {drills.length > 0 && ( -
+
{drills.map((e) => (
Date: Wed, 29 Jul 2026 05:15:49 +0000 Subject: [PATCH 08/17] fix(sql-editor): enlarge Data Peek drill grids Give stacked FK panels a taller default height and a wider modal so sub-datagrids stay readable instead of cramped. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataPeekPanel.tsx | 39 +++++++++++-------- .../src/frontend/store/useSqlEditorStore.ts | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index e952b1e9..04e40d0e 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -7,8 +7,11 @@ import { DataGrid } from './DataGrid'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import type { TableSchema } from '../../lib/types'; -const DEFAULT_HEIGHT_COMPACT = 280; -const DEFAULT_HEIGHT_ROOT = 420; +const DEFAULT_HEIGHT_ROOT = 460; +/** FK drill panels stack full-width; keep them tall enough for a usable grid. */ +const DEFAULT_HEIGHT_DRILL = 400; +const MIN_PANEL_HEIGHT = 220; +const MAX_PANEL_HEIGHT = 900; /** * Quick data peek: Cmd/Ctrl-click a table in the schema explorer to see its @@ -127,7 +130,10 @@ const PeekResizeHandle: React.FC<{ const target = e.currentTarget; target.setPointerCapture(e.pointerId); const onMove = (ev: PointerEvent) => { - const next = startH.current + (ev.clientY - startY.current); + 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) => { @@ -146,10 +152,10 @@ const PeekResizeHandle: React.FC<{ aria-label="Resize data peek panel" data-testid={`data-peek-resize-${entryId}`} onPointerDown={onPointerDown} - className="h-2 shrink-0 cursor-ns-resize flex items-center justify-center group" + className="h-2.5 shrink-0 cursor-ns-resize flex items-center justify-center group" title="Drag to resize" > - +
); }; @@ -157,11 +163,11 @@ const PeekResizeHandle: React.FC<{ const PeekGrid: React.FC<{ entry: DataPeekEntry; tables: TableSchema[] | undefined; - /** Shorter default height when several panels share the modal. */ - compact: boolean; + /** Root table vs FK drill — drills get a taller default for usable grids. */ + variant: 'root' | 'drill'; showFkHint: boolean; onClose?: () => void; -}> = ({ entry, tables, compact, showFkHint, onClose }) => { +}> = ({ entry, tables, variant, showFkHint, onClose }) => { const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); const table = useMemo(() => { @@ -197,11 +203,11 @@ const PeekGrid: React.FC<{ ); const heightPx = - entry.panelHeightPx ?? (compact ? DEFAULT_HEIGHT_COMPACT : DEFAULT_HEIGHT_ROOT); + entry.panelHeightPx ?? (variant === 'drill' ? DEFAULT_HEIGHT_DRILL : DEFAULT_HEIGHT_ROOT); return (
@@ -280,16 +286,15 @@ export const DataPeekPanel: React.FC = () => { const tables = schemaCache[dataPeek.connectionId]?.tables; const root = dataPeek.entries.find((e) => !e.parentId) ?? dataPeek.entries[0]; const drills = dataPeek.entries.filter((e) => e.parentId); - const multi = dataPeek.entries.length > 1; return createPortal(
e.stopPropagation()} >
@@ -336,21 +341,21 @@ export const DataPeekPanel: React.FC = () => { )} {drills.length > 0 && ( -
+
{drills.map((e) => (
closeDataPeekFrom(e.id)} /> diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 95247278..26801323 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -1522,7 +1522,7 @@ export const useSqlEditorStore = create()( setDataPeekPanelHeight: (entryId, heightPx) => { const peek = get().dataPeek; if (!peek) return; - const clamped = Math.min(900, Math.max(140, Math.round(heightPx))); + const clamped = Math.min(900, Math.max(220, Math.round(heightPx))); set({ dataPeek: { ...peek, From a5a6eb798516da0be775ee3f719513295e73e941 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:19:38 +0000 Subject: [PATCH 09/17] fix(sql-editor): main vertical scroll for many Data Peek grids Keep each peek panel at a fixed height (no flex-shrink) so opening many FK drills stacks into a scrollable body instead of crushing grids. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataPeekPanel.tsx | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index 04e40d0e..81fdffc0 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -7,10 +7,10 @@ import { DataGrid } from './DataGrid'; import { SQL_ICON_STROKE } from './sqlIconStyle'; import type { TableSchema } from '../../lib/types'; -const DEFAULT_HEIGHT_ROOT = 460; -/** FK drill panels stack full-width; keep them tall enough for a usable grid. */ -const DEFAULT_HEIGHT_DRILL = 400; -const MIN_PANEL_HEIGHT = 220; +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; /** @@ -207,7 +207,7 @@ const PeekGrid: React.FC<{ return (
@@ -255,8 +255,8 @@ const PeekGrid: React.FC<{ /> {showFkHint && linkColumns.size > 0 && (

- Underlined rust-colored cells are foreign keys — click orderID and technicianID to open - both panels stacked below. + Underlined rust-colored cells are foreign keys — click several to open more panels; + scroll this window to move between them.

)}
@@ -272,6 +272,8 @@ export const DataPeekPanel: React.FC = () => { const closeDataPeek = useSqlEditorStore((s) => s.closeDataPeek); const closeDataPeekFrom = useSqlEditorStore((s) => s.closeDataPeekFrom); const schemaCache = useSqlEditorStore((s) => s.schemaCache); + const scrollRef = useRef(null); + const lastEntryCount = useRef(0); useEffect(() => { if (!dataPeek) return; @@ -282,10 +284,33 @@ 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 root = dataPeek.entries.find((e) => !e.parentId) ?? dataPeek.entries[0]; - const drills = dataPeek.entries.filter((e) => e.parentId); + // Keep open order: root first, then drills as opened (supports many stacked panels). + const ordered = [ + ...dataPeek.entries.filter((e) => !e.parentId), + ...dataPeek.entries.filter((e) => e.parentId), + ]; + const rootId = ordered.find((e) => !e.parentId)?.id; return createPortal(
{
-
- {root && ( - - )} - {drills.length > 0 && ( -
- {drills.map((e) => ( +
+
+ {ordered.map((e) => { + const isRoot = e.id === rootId; + return (
closeDataPeekFrom(e.id)} + variant={isRoot ? 'root' : 'drill'} + showFkHint={isRoot} + onClose={isRoot ? undefined : () => closeDataPeekFrom(e.id)} />
- ))} -
- )} + ); + })} +
, From 343a3b6e29efa836011fd48b66883a19af7b7202 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:22:52 +0000 Subject: [PATCH 10/17] feat(sql-editor): Prev/Next paging on Data Peek grids Track pageIndex per peek panel, pass OFFSET to /sql/execute, and show Prev/Next controls on ORDER_TECHNICIAN and drill grids. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataPeekPanel.tsx | 13 ++++++-- .../sql-editor/dataPeekSubtree.test.ts | 1 + .../src/frontend/store/useSqlEditorStore.ts | 32 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index 81fdffc0..e449412b 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -169,6 +169,7 @@ const PeekGrid: React.FC<{ onClose?: () => void; }> = ({ entry, tables, variant, showFkHint, onClose }) => { const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); + const pageDataPeekEntry = useSqlEditorStore((s) => s.pageDataPeekEntry); const table = useMemo(() => { if (!tables) return undefined; @@ -231,25 +232,31 @@ const PeekGrid: React.FC<{ - {entry.status === 'loading' && ( + {entry.status === 'loading' && !entry.result && (
Loading {entry.title}…
)} - {(entry.status === 'error' || (entry.status === 'ready' && !entry.result)) && ( + {(entry.status === 'error' || (entry.status !== 'loading' && !entry.result)) && (
{entry.error ?? 'Preview failed'}
)} - {entry.status === 'ready' && entry.result && ( + {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} /> diff --git a/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts index 1194bfe4..a4d9b5c0 100644 --- a/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts +++ b/apps/web/src/frontend/components/sql-editor/dataPeekSubtree.test.ts @@ -14,6 +14,7 @@ function entry( whereClause: '', orderByClause: '', limit: 50, + pageIndex: 0, sql: 'SELECT 1', params: [], status: 'ready', diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 26801323..0c50baff 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -241,6 +241,8 @@ export interface DataPeekEntry { 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[]; @@ -408,6 +410,8 @@ interface SqlEditorState { updateDataPeekFilters: (entryId: string, patch: DataPeekFilterPatch) => Promise; /** Persist a dragged panel height. */ setDataPeekPanelHeight: (entryId: string, heightPx: 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. */ @@ -1418,6 +1422,7 @@ export const useSqlEditorStore = create()( whereClause: '', orderByClause: '', limit: DATA_PEEK_ROWS, + pageIndex: 0, sql: composed.sql, params: composed.params, status: 'loading', @@ -1446,6 +1451,7 @@ export const useSqlEditorStore = create()( whereClause: '', orderByClause: '', limit: DATA_PEEK_ROWS, + pageIndex: 0, sql: composed.sql, params: composed.params, status: 'loading', @@ -1488,6 +1494,7 @@ export const useSqlEditorStore = create()( whereClause, orderByClause, limit, + pageIndex: 0, status: 'error' as const, error: composed.error, } @@ -1507,6 +1514,7 @@ export const useSqlEditorStore = create()( whereClause, orderByClause, limit, + pageIndex: 0, sql: composed.sql, params: composed.params, status: 'loading' as const, @@ -1533,6 +1541,26 @@ export const useSqlEditorStore = create()( }); }, + 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; @@ -1550,6 +1578,8 @@ 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, @@ -1557,7 +1587,7 @@ export const useSqlEditorStore = create()( }, [entry.sql], pageSize, - 0, + offset, [entry.params] ); const result = results[0]; From 5f849334a9450faac9fd94a7e6c1f59cec256131 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:26:53 +0000 Subject: [PATCH 11/17] feat(sql-editor): drag to rearrange Data Peek grids Add a grip handle on each peek panel so stacked datagrids can be reordered in the main vertical scroll. Co-authored-by: huy.phan9 --- .../components/sql-editor/DataPeekPanel.tsx | 100 +++++++++++++++--- .../sql-editor/dataPeekSubtree.test.ts | 15 ++- .../src/frontend/store/useSqlEditorStore.ts | 31 ++++++ 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index e449412b..faf2e6a9 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { 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'; @@ -166,8 +166,30 @@ const PeekGrid: React.FC<{ /** 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, onClose }) => { +}> = ({ + 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); @@ -208,11 +230,41 @@ const PeekGrid: React.FC<{ 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} @@ -278,9 +330,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; @@ -312,12 +367,7 @@ export const DataPeekPanel: React.FC = () => { if (!dataPeek) return null; const tables = schemaCache[dataPeek.connectionId]?.tables; - // Keep open order: root first, then drills as opened (supports many stacked panels). - const ordered = [ - ...dataPeek.entries.filter((e) => !e.parentId), - ...dataPeek.entries.filter((e) => e.parentId), - ]; - const rootId = ordered.find((e) => !e.parentId)?.id; + const entries = dataPeek.entries; return createPortal(
{ Data peek
- {dataPeek.entries.map((e) => ( + {entries.map((e) => ( { ))}
+ + Drag ⋮⋮ to arrange +
diff --git a/apps/web/src/frontend/lib/tablePreview.test.ts b/apps/web/src/frontend/lib/tablePreview.test.ts index a85d7c70..69224d6b 100644 --- a/apps/web/src/frontend/lib/tablePreview.test.ts +++ b/apps/web/src/frontend/lib/tablePreview.test.ts @@ -4,6 +4,7 @@ import { buildTablePreview, buildForeignKeyDrilldown, foreignKeyLinksFor, + foreignKeyLinksForSql, composePeekSql, isSafePeekClause, } from './tablePreview'; @@ -126,3 +127,37 @@ describe('composePeekSql', () => { }); }); }); + +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 b8bcaa01..87cccf75 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -1,5 +1,5 @@ /** - * Queries behind the schema-explorer data peek (Cmd/Ctrl-click a table). + * 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 @@ -114,6 +114,108 @@ 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 table = raw.trim().replace(/^["`\[]|["`\]]$/g, '').replace(/""/g, '"'); + // Strip wrapping quotes more carefully via same rules as aliases helper. + 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. diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index f43046c4..b9914aa4 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -419,6 +419,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, @@ -1454,6 +1463,34 @@ export const useSqlEditorStore = create()( 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] } }); + await get().runDataPeekEntry(entry.id); + }, + drillDataPeek: async (fromEntryId, fk, values) => { const peek = get().dataPeek; if (!peek) return; 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 From d772e7b418bd674e8b21897b41b58f6b9c5df966 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:35:06 +0000 Subject: [PATCH 14/17] chore: drop unused ident in tableNamesFromSql Co-authored-by: huy.phan9 --- apps/web/src/frontend/lib/tablePreview.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/frontend/lib/tablePreview.ts b/apps/web/src/frontend/lib/tablePreview.ts index 87cccf75..d9864e74 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -147,8 +147,6 @@ export function tableNamesFromSql(sql: string): string[] { while ((m = re.exec(sql)) !== null) { const raw = m[1]; if (!raw) continue; - const table = raw.trim().replace(/^["`\[]|["`\]]$/g, '').replace(/""/g, '"'); - // Strip wrapping quotes more carefully via same rules as aliases helper. const cleaned = stripSqlIdent(raw); if (!cleaned) continue; const key = cleaned.toLowerCase(); From 525452ab20e34470dede42e2b9c984ce6e376ac7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:38:22 +0000 Subject: [PATCH 15/17] chore: assert copyright NOTICE and SPDX headers Sign SQL Editor / Data Peek sources and root NOTICE for Huy Ph authorship. Keep public PR write-ups short (no long internal plans). Co-authored-by: huy.phan9 --- CONTRIBUTING.md | 8 +++++++- NOTICE | 15 +++++++++++++++ README.md | 6 +++++- apps/web/src/backend/api/sql-page-wrap.ts | 4 ++++ .../components/sql-editor/DataGrid.tsx | 7 +++++++ .../components/sql-editor/DataPeekPanel.tsx | 16 ++++++++++++---- .../components/sql-editor/ResultsPanel.tsx | 8 ++++++++ apps/web/src/frontend/lib/tablePreview.ts | 4 ++++ .../src/frontend/store/useSqlEditorStore.ts | 7 +++++++ docs/COPYRIGHT_HEADER.txt | 18 ++++++++++++++++++ 10 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 NOTICE create mode 100644 docs/COPYRIGHT_HEADER.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57fe46d4..d07466fa 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 Ph `` (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/NOTICE b/NOTICE new file mode 100644 index 00000000..e8777171 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +Fox Schema (foxschema) +Copyright 2024-2026 Huy Ph +Copyright 2024-2026 tedious-code contributors + +This product includes software developed by Huy Ph 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..68c2b19e 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 Ph `` 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-page-wrap.ts b/apps/web/src/backend/api/sql-page-wrap.ts index 15f22180..f3b46d62 100644 --- a/apps/web/src/backend/api/sql-page-wrap.ts +++ b/apps/web/src/backend/api/sql-page-wrap.ts @@ -1,4 +1,8 @@ /** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Ph + * 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. * diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 09db4b47..07f0dfb0 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 Ph + * 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'; diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index f2117d82..b446f028 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,3 +1,14 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Ph + * 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 { GripVertical, Loader2, X } from 'lucide-react'; @@ -14,10 +25,7 @@ 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 — 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. + * Quick data peek UI (see file header). */ const PeekFilterBar: React.FC<{ entry: DataPeekEntry; diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index df13189a..0fb30104 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -1,3 +1,11 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Ph + * 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 { useSqlEditorStore, type CredentialRun } from '../../store/useSqlEditorStore'; diff --git a/apps/web/src/frontend/lib/tablePreview.ts b/apps/web/src/frontend/lib/tablePreview.ts index d9864e74..d8b5d9d1 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -1,4 +1,8 @@ /** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Ph + * 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 diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index b9914aa4..ed155306 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 Ph + * 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'; diff --git a/docs/COPYRIGHT_HEADER.txt b/docs/COPYRIGHT_HEADER.txt new file mode 100644 index 00000000..5f56ea99 --- /dev/null +++ b/docs/COPYRIGHT_HEADER.txt @@ -0,0 +1,18 @@ +/* + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Ph + * + * 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 + */ From 1e133f52f9cd375b68e6cce3e90d373aa1ed0f0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:38:27 +0000 Subject: [PATCH 16/17] chore: align LICENSE copyright notice with NOTICE Co-authored-by: huy.phan9 --- LICENSE | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index aabf3e0e..6dd4a753 100644 --- a/LICENSE +++ b/LICENSE @@ -180,7 +180,8 @@ END OF TERMS AND CONDITIONS ## Copyright Notice ``` -Copyright 2026 huyplb +Copyright 2024-2026 Huy Ph +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 Ph * * 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 */ ``` From f64d50b3a5a32cc400223f92b331d5aff9b8a4b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 05:39:14 +0000 Subject: [PATCH 17/17] chore: correct copyright name to Huy Phan Co-authored-by: huy.phan9 --- CONTRIBUTING.md | 2 +- LICENSE | 4 ++-- NOTICE | 4 ++-- README.md | 2 +- apps/web/src/backend/api/sql-page-wrap.ts | 2 +- apps/web/src/frontend/components/sql-editor/DataGrid.tsx | 2 +- apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx | 2 +- apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx | 2 +- apps/web/src/frontend/lib/tablePreview.ts | 2 +- apps/web/src/frontend/store/useSqlEditorStore.ts | 2 +- docs/COPYRIGHT_HEADER.txt | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d07466fa..e0374821 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ A few rules that have bitten people before (the full set is in [CLAUDE.md](CLAUD 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 Ph `` (see [NOTICE](NOTICE)). + 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 6dd4a753..be1d449a 100644 --- a/LICENSE +++ b/LICENSE @@ -180,7 +180,7 @@ END OF TERMS AND CONDITIONS ## Copyright Notice ``` -Copyright 2024-2026 Huy Ph +Copyright 2024-2026 Huy Phan Copyright 2024-2026 tedious-code contributors Licensed under the Apache License, Version 2.0 (the "License"); @@ -208,7 +208,7 @@ Add the following boilerplate notice at the top of each source file ``` /* * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * 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. diff --git a/NOTICE b/NOTICE index e8777171..914fc1a5 100644 --- a/NOTICE +++ b/NOTICE @@ -1,8 +1,8 @@ Fox Schema (foxschema) -Copyright 2024-2026 Huy Ph +Copyright 2024-2026 Huy Phan Copyright 2024-2026 tedious-code contributors -This product includes software developed by Huy Ph and 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. diff --git a/README.md b/README.md index 68c2b19e..d9887c0b 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Maintainers: [docs/PUBLISH.md](docs/PUBLISH.md) · [docs/ARCHITECTURE.md](docs/A ## License -Copyright 2024–2026 Huy Ph `` and [tedious-code](https://github.com/tedious-code) +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 diff --git a/apps/web/src/backend/api/sql-page-wrap.ts b/apps/web/src/backend/api/sql-page-wrap.ts index f3b46d62..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,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * * Wrap a statement so the engine returns a page (LIMIT/OFFSET). diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 07f0dfb0..a2756524 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -1,6 +1,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * * Result DataGrid (paper surface, paging, FK drill links for Data Peek). diff --git a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx index b446f028..a949ac00 100644 --- a/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataPeekPanel.tsx @@ -1,6 +1,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * 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 diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 0fb30104..32af4264 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -1,6 +1,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * * Results for one SQL Editor tab (By cred / Side-by-side layouts). diff --git a/apps/web/src/frontend/lib/tablePreview.ts b/apps/web/src/frontend/lib/tablePreview.ts index d8b5d9d1..3b1a5f33 100644 --- a/apps/web/src/frontend/lib/tablePreview.ts +++ b/apps/web/src/frontend/lib/tablePreview.ts @@ -1,6 +1,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * 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). diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index ed155306..f40a15f1 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -1,6 +1,6 @@ /** * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * * SQL Editor client store (tabs, execute, schema cache, Data Peek). diff --git a/docs/COPYRIGHT_HEADER.txt b/docs/COPYRIGHT_HEADER.txt index 5f56ea99..e376c282 100644 --- a/docs/COPYRIGHT_HEADER.txt +++ b/docs/COPYRIGHT_HEADER.txt @@ -1,6 +1,6 @@ /* * Fox Schema (foxschema) - * Copyright 2024-2026 Huy Ph + * 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.