(null);
+ const [page, setPage] = useState(1);
+
+ const filtered = React.useMemo(() => applyFilter(rows, filter), [rows, filter]);
+ const sorted = React.useMemo(() => applySort(filtered, sort), [filtered, sort]);
+ const paged = React.useMemo(() => paginate(sorted, { page, pageSize: PAGE_SIZE }), [sorted, page]);
+
+ // reset page when filter or sort changes
+ React.useEffect(() => {
+ setPage(1);
+ }, [filter, sort]);
+
+ // reset sort/page when the underlying data changes (per spec section 6/12)
+ React.useEffect(() => {
+ if (resetToken === undefined) return;
+ setSort(null);
+ setPage(1);
+ }, [resetToken]);
+
+ const handleHeaderClick = (columnIndex: number) => {
+ setSort((curr) => nextSortState(curr, columnIndex));
+ };
+
+ const handleCellContextMenu = (e: React.MouseEvent, cell: Cell) => {
+ e.preventDefault();
+ e.stopPropagation();
+ navigator.clipboard
+ .writeText(cellToCopyString(cell))
+ .then(() => toast.success('Copied to clipboard'))
+ .catch(() => toast.error('Failed to copy'));
+ };
+
+ const sortIndicator = (i: number) => {
+ if (!sort || sort.columnIndex !== i) return '';
+ return sort.direction === 'asc' ? ' ▲' : ' ▼';
+ };
+
+ if (filtered.length === 0) {
+ return (
+
+ {filter ? `No rows match "${filter}"` : 'No rows'}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {columns.map((col, i) => (
+ | handleHeaderClick(i)}
+ title={`Sort by ${col}`}
+ >
+ {col}{sortIndicator(i)}
+ |
+ ))}
+
+
+
+ {paged.rows.map((row, rowIdx) => (
+
+ {row.map((cell, ci) => {
+ const hasSub = isNested(cell);
+ return (
+ | handleCellContextMenu(e, cell)}
+ title={typeof cell === 'string' ? cell : undefined}
+ >
+ {renderCell(cell)}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ {paged.totalPages > 1 && (
+
+
+ Showing {paged.start}–{paged.end} of {paged.totalRows}
+
+
+ )}
+ >
+ );
+};
+
+interface ObjectTableProps {
+ rows: [string, Cell][];
+ filter: string;
+ resetToken?: number;
+}
+
+const ObjectTable: React.FC = ({ rows, filter, resetToken }) => {
+ // Reuse the array-table pipeline by treating rows as Cell[][]
+ const cellRows: Cell[][] = rows.map(([k, v]) => [k, v]);
+ return ;
+};
+
+interface TableViewProps {
+ shape: TableShape;
+ filter: string;
+ resetToken?: number;
+}
+
+const TableView: React.FC = ({ shape, filter, resetToken }) => {
+ if (shape.kind === 'array') {
+ if (shape.rows.length === 0) {
+ return No rows
;
+ }
+ return ;
+ }
+ if (shape.kind === 'object') {
+ if (shape.rows.length === 0) {
+ return No fields
;
+ }
+ return ;
+ }
+ return null;
+};
+
+export default TableView;
diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx
new file mode 100644
index 0000000..ce69354
--- /dev/null
+++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/ValueRenderer.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+
+interface ValueRendererProps {
+ value: unknown;
+ depth: number;
+}
+
+const MAX_DEPTH = 12;
+
+const isPlainObject = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+const isComplex = (v: unknown): boolean =>
+ isPlainObject(v) || (Array.isArray(v) && v.length > 0);
+
+const collectColumns = (objects: Record[]): string[] => {
+ const cols: string[] = [];
+ const seen = new Set();
+ for (const obj of objects) {
+ for (const key of Object.keys(obj)) {
+ if (!seen.has(key)) {
+ seen.add(key);
+ cols.push(key);
+ }
+ }
+ }
+ return cols;
+};
+
+const ValueRenderer: React.FC = ({ value, depth }) => {
+ if (depth > MAX_DEPTH) {
+ return …;
+ }
+
+ if (value === null || value === undefined) {
+ return —;
+ }
+
+ if (typeof value === 'boolean') {
+ return <>{value ? 'true' : 'false'}>;
+ }
+
+ if (typeof value === 'number' || typeof value === 'string') {
+ return <>{String(value)}>;
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return [ ];
+ }
+
+ if (value.every(isPlainObject)) {
+ const cols = collectColumns(value);
+ return (
+
+
+
+ |
+ {cols.map((c) => (
+ {c} |
+ ))}
+
+
+
+ {value.map((row, i) => (
+
+ | {i} |
+ {cols.map((c) => {
+ const v = row[c];
+ const complex = isComplex(v);
+ return (
+
+
+ |
+ );
+ })}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {value.map((v, i) => {
+ const complex = isComplex(v);
+ return (
+
+ | {i} |
+
+
+ |
+
+ );
+ })}
+
+
+ );
+ }
+
+ if (isPlainObject(value)) {
+ const entries = Object.entries(value);
+ if (entries.length === 0) {
+ return {'{ }'};
+ }
+ return (
+
+
+ {entries.map(([k, v]) => {
+ const complex = isComplex(v);
+ return (
+
+ | {k} |
+
+
+ |
+
+ );
+ })}
+
+
+ );
+ }
+
+ return <>{String(value)}>;
+};
+
+export default ValueRenderer;
diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts
new file mode 100644
index 0000000..ff648d4
--- /dev/null
+++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.spec.ts
@@ -0,0 +1,114 @@
+// flatten.spec.ts
+import { describe, test, expect } from 'vitest';
+import { flatten } from './flatten';
+
+describe('flatten', () => {
+ test('array of homogeneous objects → array shape with union columns', () => {
+ const input = [
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bob' }
+ ];
+ const result = flatten(input);
+ expect(result.kind).toBe('array');
+ if (result.kind !== 'array') return;
+ expect(result.columns).toEqual(['id', 'name']);
+ expect(result.rows).toEqual([
+ [1, 'Alice'],
+ [2, 'Bob']
+ ]);
+ });
+
+ test('array with missing keys → null fill', () => {
+ const r = flatten([{ a: 1 }, { b: 2 }]);
+ expect(r.kind).toBe('array');
+ if (r.kind !== 'array') return;
+ expect(r.columns).toEqual(['a', 'b']);
+ expect(r.rows).toEqual([
+ [1, null],
+ [null, 2]
+ ]);
+ });
+
+ test('array of primitives → [index, value] columns', () => {
+ const r = flatten(['x', 'y', 'z']);
+ expect(r.kind).toBe('array');
+ if (r.kind !== 'array') return;
+ expect(r.columns).toEqual(['index', 'value']);
+ expect(r.rows).toEqual([[0, 'x'], [1, 'y'], [2, 'z']]);
+ });
+
+ test('array of mixed types → [index, value] with nested cells for objects/arrays', () => {
+ const r = flatten([1, 'x', { a: 1 }]);
+ expect(r.kind).toBe('array');
+ if (r.kind !== 'array') return;
+ expect(r.columns).toEqual(['index', 'value']);
+ expect(r.rows[0]).toEqual([0, 1]);
+ expect(r.rows[1]).toEqual([1, 'x']);
+ const nested = r.rows[2][1] as { __nested: true; preview: string; raw: unknown };
+ expect(nested.__nested).toBe(true);
+ expect(nested.preview).toBe('{ a }');
+ expect(nested.raw).toEqual({ a: 1 });
+ });
+
+ test('single object → object shape with key/value rows in source order', () => {
+ const r = flatten({ z: 1, a: 2 });
+ expect(r.kind).toBe('object');
+ if (r.kind !== 'object') return;
+ expect(r.rows).toEqual([['z', 1], ['a', 2]]);
+ });
+
+ test('empty array → array shape with no columns/rows', () => {
+ const r = flatten([]);
+ expect(r).toEqual({ kind: 'array', columns: [], rows: [] });
+ });
+
+ test('empty object → object shape with no rows', () => {
+ const r = flatten({});
+ expect(r).toEqual({ kind: 'object', rows: [] });
+ });
+
+ test('primitive at root → unsupported', () => {
+ expect(flatten(42)).toEqual({
+ kind: 'unsupported',
+ reason: 'Top-level value is not an object or array'
+ });
+ expect(flatten('hello')).toEqual({
+ kind: 'unsupported',
+ reason: 'Top-level value is not an object or array'
+ });
+ });
+
+ test('null / undefined at root → unsupported "No data"', () => {
+ expect(flatten(null)).toEqual({ kind: 'unsupported', reason: 'No data' });
+ expect(flatten(undefined)).toEqual({ kind: 'unsupported', reason: 'No data' });
+ });
+
+ test('nested object cell preview shows up to 3 keys then ellipsis', () => {
+ const r = flatten([{ a: { x: 1, y: 2, z: 3, w: 4 } }]);
+ if (r.kind !== 'array') throw new Error('expected array');
+ const cell = r.rows[0][0] as { preview: string };
+ expect(cell.preview).toBe('{ x, y, z, … }');
+ });
+
+ test('nested array cell preview shows item count', () => {
+ const r = flatten([{ items: [1, 2, 3, 4, 5] }]);
+ if (r.kind !== 'array') throw new Error('expected array');
+ const cell = r.rows[0][0] as { preview: string };
+ expect(cell.preview).toBe('[ 5 items ]');
+ });
+
+ test('circular reference → [Circular] preview without throwing', () => {
+ const obj: Record = { id: 1 };
+ obj.self = obj;
+ const r = flatten([obj]);
+ if (r.kind !== 'array') throw new Error('expected array');
+ const cell = r.rows[0][1] as { preview: string };
+ expect(cell.preview).toBe('[Circular]');
+ });
+
+ test('undefined inside object becomes null cell', () => {
+ const r = flatten([{ a: undefined, b: 1 }]);
+ if (r.kind !== 'array') throw new Error('expected array');
+ expect(r.rows[0]).toEqual([null, 1]);
+ });
+});
diff --git a/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts
new file mode 100644
index 0000000..e53c319
--- /dev/null
+++ b/src/webview/components/ResponsePane/QueryResult/QueryResultPreview/JsonTablePreview/flatten.ts
@@ -0,0 +1,95 @@
+// flatten.ts
+export type Cell =
+ | string
+ | number
+ | boolean
+ | null
+ | { __nested: true; preview: string; raw: unknown };
+
+export type TableShape =
+ | { kind: 'array'; columns: string[]; rows: Cell[][] }
+ | { kind: 'object'; rows: [string, Cell][] }
+ | { kind: 'unsupported'; reason: string };
+
+const PRIMITIVE = new Set(['string', 'number', 'boolean']);
+
+const isPlainObject = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+// Single-depth: toCell does not recurse into nested values. It produces a
+// preview string for any object/array and returns the raw value untouched.
+// The `seen` WeakSet only guards against the row object itself being reached
+// via a back-reference (caller pre-seeds it with the ancestor row object).
+const toCell = (value: unknown, seen: WeakSet