feat: initial Gridline implementation (#1)

* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)

* feat: shared types and validation/filter utilities (Task 2)

* feat: Rust models, SQLite store, and migrations (Task 3)

* feat: Tauri commands for connections and folders (Task 4)

* feat: tag, settings, import/export Tauri commands (Task 5)

* feat: frontend command wrappers and Zustand stores (Task 6)

* feat: useSearch debounce and useFilteredConnections hooks (Task 7)

* feat: App view router with load-on-mount (Task 8)

* feat: UI primitives Button, Input, Badge, Card (Task 9)

* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)

* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)

* fix: resolve TypeScript errors across utils, Input, and test files

* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)

* feat: Tauri file dialog integration for import/export (Task 13)

* feat: error/loading states and full App router integration (Task 14)

* chore: final integration gate — full test + build green (Task 15)

* fix: move folders inline, rearrange ActionRow layout, add borders to buttons

* feat: upgrade to Tailwind v4, apply new color palette

* Landing page restyle, folder explorer, selection/delete, and icon update

- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app

* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering

- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables

* deps: install motion for animations

* feat(types): add system theme option

* feat(settings): default stored theme to system

* feat(ui): add Toggle primitive

* feat(ui): add Select primitive

* feat(ui): add SettingsRow and SettingsSection primitives

* fix(ui): improve SettingsRow and SettingsSection quality

* feat(ui): add ThemePicker primitive

* feat(ui): add AnimatedModal primitive with motion

* refactor(dialogs): use AnimatedModal for enter/exit animations

* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal

* feat(settings): redesign page with multi-section layout

* refactor(settings): split tabs, improve accessibility and animation

* fix(tauri): set window background color to dark canvas

* fix(settings): remove fake traffic lights, add header background, transparent title bar

* fix(settings): move Back into sidebar, add Settings heading, dynamic window title

* fix(tauri): show native window title so Home/Settings labels are visible

* fix(ui): disable overscroll on both axes

* test(dialogs): verify CreateFolderDialog shows and saves tags

* chore: ignore .worktrees directory

* refactor: centralize db type icons and labels

* feat: add connection string parser and detection

* test: add edge cases for connection string parser

* fix: preserve absolute paths in SQLite connection strings

* types: add new connection form fields

* feat: add folder path label helper

* test: cover missing folder in path label helper

* feat: add stub connection string parse and test commands

* feat: add stub test connection command

* feat: add new connection form primitives

* feat: add connection form shell

* feat: add shared form type and simple connection form

* fix: keep SimpleConnectionForm fully controlled

* style: format and tighten types for simple connection form

* feat: add detailed connection form

* fix: export DetailedConnectionFormProps and assert port update

* feat: add new connection screen container

* fix: clean up useEffect dependencies in new connection screen

* fix: address quality feedback on new connection screen

* feat: wire new connection screen into app and remove old form

* feat: open new connection screen from pasted db url in search

* test: add home search URL shortcut integration test

* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown

Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.

* refactor: remove db type icon and label from connection form shell

Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.

* feat: home screen redesign and connection form updates

- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components

* fix: hide folders during search, update placeholder, and set window titles

- ConnectionGrid now hides folders when hasSearch is true so only
  matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
  a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.

* fix: shorter placeholder, dynamic window title, and startup flash

- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
  the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
  before CSS loads.

* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)

* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)

* feat: migrate connections table with SSH/SSL columns (Task 1c)

* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2

* feat: connection pool manager with LRU eviction (Task 2a)

* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)

* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)

* feat: db viewer Rust commands and AppState refactor (Task 2d)

* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)

* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)

* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)

* feat: tooltip UI primitive (Task 4a)

* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)

* feat: DbViewerScreen shell with sidebar navigation (Task 4c)

* feat: toolbar, table tree, and overflow menu (Task 4d)

* fix: align TableInfo interface between frontend types and test files

* feat: tab bar, data grid, and pagination controls (Task 4e)

* feat: changes queue panel with cancel per change (Task 4f)

* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)

* feat: error banner, guard dialogs for destructive actions (Task 5b)

* feat: changes queue commit all with per-change IPC and error handling (Task 5c)

* chore: suppress expected dead-code warnings during multi-phase development

* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)

* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message

* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend

* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)

* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags

* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)

* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings

* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail

* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching

* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)

* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll

* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix

* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden

* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently

* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly

* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll

* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside

* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion

* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce

* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention

* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch

* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg

* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped

* chore: rename sidebar label from DB Viewer to Explorer

* feat: resizable table panel (180-600px) with drag handle on right edge

* feat: double-click panel resize handle resets to default 280px

* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width

* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching

* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout

* feat: add environment label to connections (production/staging/development) with badge on cards

* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store

* style: monospace font for table data cells

* style: use Space Mono (font-heading) for table data cells

* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data

* fix: auto-refresh defaults to off, click opens dropdown to pick interval

* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank

* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'

* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)

* feat: selected row count in toolbar with clear button

* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)

* docs: add changes-queue-before-execution rule to AGENTS.md guardrails

* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)

* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)

* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size

* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown

* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior

* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)

* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding

* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)

* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border

- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame

* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button

- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation

* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row

- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator

* fix(db-viewer): proper FK/enum detection and improved schema tree display

Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
  USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
  PRAGMA foreign_key_list for FK detection

Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
  full type name on hover tooltip
- Column icons use shrink-0 to maintain size

* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers

* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style

- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
  and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline

* feat(db-viewer): FK preview popover with inline filter

- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)

* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views

- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output

* feat(db-viewer): smart default sort adds newest-first sorting automatically

- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override

* feat(db-viewer): search tables input with slide animation and tree filtering

- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active

* docs: add comprehensive implementation status to AGENTS.md

- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with /🟡/ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
2026-07-28 19:01:00 +08:00
committed by GitHub
parent 346372c552
commit d872e429e4
186 changed files with 23311 additions and 194 deletions
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("ChangesQueuePanel", () => {
beforeEach(() => {
useDbViewerStore.setState({ changesQueue: [] });
});
it("shows nothing when queue is empty", () => {
const { container } = render(<ChangesQueuePanel />);
expect(container.textContent).toBe("");
});
it("shows pending changes", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
expect(screen.getByText(/users/i)).toBeInTheDocument();
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelBtn);
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,199 @@
import { useState, useCallback } from "react";
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as cmd from "../../lib/commands";
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
import type { ChangeItem } from "../../lib/types";
const statusBg: Record<QueueStatus, string> = {
pending: "bg-accent/5",
committed: "bg-green-500/5",
failed: "bg-red-500/5",
cancelled: "bg-surface-raised/50",
};
function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
function StatusIndicator({ status }: { status: QueueStatus }) {
switch (status) {
case "pending":
return (
<div className="flex items-center gap-1.5 text-amber-400">
<span className="h-2 w-2 rounded-full bg-amber-400" />
<span>Pending</span>
</div>
);
case "committed":
return (
<div className="flex items-center gap-1.5 text-green-500">
<Check className="h-4 w-4" />
<span>Committed</span>
</div>
);
case "failed":
return (
<div className="flex items-center gap-1.5 text-red-500">
<X className="h-4 w-4" />
<span>Failed</span>
</div>
);
case "cancelled":
return (
<div className="flex items-center gap-1.5 text-text-muted">
<span>Cancelled</span>
</div>
);
default:
return null;
}
}
export function ChangesQueuePanel() {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const cancelChange = useDbViewerStore((state) => state.cancelChange);
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const [expanded, setExpanded] = useState(true);
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
if (!connectionId) {
notify("No active connection", "error");
return;
}
const pending = useDbViewerStore.getState().changesQueue.filter(
(c) => c.status === "pending",
);
if (pending.length === 0) return;
let committedCount = 0;
for (const change of pending) {
try {
const payload = {
id: change.id,
type: change.type,
sql: change.sql,
status: "pending" as const,
description: change.description ?? null,
} satisfies ChangeItem;
await cmd.executeChange(connectionId, payload);
markChangeCommitted(change.id);
committedCount++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
markChangeFailed(change.id, msg);
notify(`Change failed: ${msg}`, "error");
break;
}
}
if (committedCount > 0) {
notify(`${committedCount} change(s) committed`, "success");
}
}, [markChangeCommitted, markChangeFailed, notify]);
if (changesQueue.length === 0) {
return null;
}
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
const processedCount = changesQueue.filter(
(c) => c.status === "committed" || c.status === "failed",
).length;
const changeWord = pendingCount === 1 ? "change" : "changes";
return (
<div className="border-t border-border bg-surface">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronUp className="h-4 w-4 text-text-muted" />
)}
<span className="font-medium">
Changes Queue ({pendingCount} pending {changeWord}, {processedCount}{" "}
processed)
</span>
{pendingCount > 0 && (
<span className="rounded-full bg-accent/20 px-2 py-0.5 text-xs text-accent-muted">
{pendingCount}
</span>
)}
</div>
<button
type="button"
disabled={pendingCount === 0}
onClick={(e) => {
e.stopPropagation();
handleCommitAll();
}}
className="rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Commit All
</button>
</button>
{expanded && (
<div className="max-h-48 overflow-y-auto">
{changesQueue.map((change) => (
<ChangeRow
key={change.id}
change={change}
onCancel={() => cancelChange(change.id)}
/>
))}
</div>
)}
</div>
);
}
function ChangeRow({
change,
onCancel,
}: {
change: QueueItem;
onCancel: () => void;
}) {
return (
<div
className={`flex items-center justify-between px-4 py-2 text-sm ${statusBg[change.status]}`}
>
<div className="flex items-center gap-3">
<span className="rounded-md bg-surface-raised px-2 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
</span>
<span className="text-text">
{change.table ? change.table : "-"}
</span>
</div>
<div className="flex items-center gap-3">
<StatusIndicator status={change.status} />
{change.status === "pending" && (
<button
type="button"
aria-label="Cancel"
onClick={onCancel}
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
describe("ConnectionDropBanner", () => {
it("shows error message", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByText("Connection lost")).toBeInTheDocument();
});
it("shows reconnect button", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument();
});
it("calls onRetry when reconnect clicked", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={onRetry}
onDismiss={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /reconnect/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("calls onDismiss when close button clicked", async () => {
const onDismiss = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,35 @@
import { AlertTriangle, X } from "lucide-react";
interface ConnectionDropBannerProps {
error: string;
onRetry: () => void;
onDismiss?: () => void;
}
export function ConnectionDropBanner({ error, onRetry, onDismiss }: ConnectionDropBannerProps) {
return (
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/20 rounded-md px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<AlertTriangle size={18} className="text-red-400 shrink-0" />
<span className="text-red-300 text-sm truncate">{error}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={onRetry}
className="text-sm px-3 py-1.5 rounded-md bg-red-500/20 text-red-200 hover:bg-red-500/30 transition-colors cursor-pointer"
>
Reconnect
</button>
<button
type="button"
aria-label="Dismiss error"
onClick={onDismiss}
className="p-1.5 rounded-md text-red-300 hover:bg-red-500/20 transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataGrid } from "./DataGrid";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { QueryResult } from "../../lib/types";
const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
rows: [
[1, "Alice", "alice@example.com"],
[2, "Bob", null],
],
total_rows: 2, page: 1, page_size: 50,
};
describe("DataGrid", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no active tab", () => {
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument();
});
it("shows loading state", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabLoading(tabId, true);
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("shows error message in red", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabError(tabId, "Connection failed");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
const error = screen.getByText(/Connection failed/i);
expect(error).toBeInTheDocument();
expect(error).toHaveClass("text-red-500");
});
it("shows loading state when first opening a tab", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders column headers and row data when loaded", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("NULL")).toBeInTheDocument();
});
it("renders NULL values as italic muted text", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
const nullCell = screen.getByText("NULL");
expect(nullCell).toHaveClass("italic");
expect(nullCell).toHaveClass("text-text-muted");
});
});
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Key, Braces } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "./FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "./JsonCellPopover";
// TODO: Replace this plain HTML table with @tanstack/react-virtual for large
// result sets so we can render millions of rows without DOM overhead.
type ColumnWidths = Record<string, number>;
type TabColumnWidths = Record<string, ColumnWidths>;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
const CHECKBOX_COL_WIDTH = 40;
interface DataGridProps {
connectionId: string;
rows: unknown[][];
hiddenColumns?: Set<string>;
selectedRows: Set<number>;
onSelectionChange: (selected: Set<number>) => void;
}
export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const [colWidths, setColWidths] = useState<TabColumnWidths>({});
// FK preview popover state
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
// JSON cell popover state
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── helpers ────────────────────────────────────────────
const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null;
const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {};
const getWidth = useCallback(
(colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH,
[widths],
);
// ── selection logic ────────────────────────────────────
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length;
const checkboxRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const toggleAll = () => {
if (allSelected) {
onSelectionChange(new Set());
} else {
onSelectionChange(new Set(rows.map((_, i) => i)));
}
};
const toggleRow = (rowIndex: number) => {
const next = new Set(selectedRows);
if (next.has(rowIndex)) next.delete(rowIndex);
else next.add(rowIndex);
onSelectionChange(next);
};
// ── resize handler (ref-based to avoid stale closures) ─
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(colName: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
const onMove = (ev: MouseEvent) => {
if (!resizeRef.current) return;
const delta = ev.clientX - resizeRef.current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta));
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next },
}));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[activeTabId, getWidth],
);
// ── FK row-click handler ───────────────────────────────
const handleFkClick = useCallback(
(col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const schema = activeTab?.schema ?? "public";
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[activeTab],
);
// ── empty / loading / error states ─────────────────────
if (!activeTabId) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (activeTab.loading && !activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading...
</div>
);
}
if (activeTab.error) {
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-red-500">
{activeTab.error}
</div>
);
}
if (!activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading table data...
</div>
);
}
const { columns } = activeTab.data;
// Filter visible columns
const visibleColumns = hiddenColumns
? columns.filter((c) => !hiddenColumns.has(c.name))
: columns;
return (
<div
className="flex-1 overflow-auto min-w-0 relative"
style={{ overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
{/* Loading indicator bar when refreshing with existing data */}
{activeTab.loading && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 animate-pulse" />
)}
<table
className="border-collapse text-left text-sm"
style={{ tableLayout: "fixed", width: "100%" }}
>
<colgroup>
{/* Checkbox column */}
<col style={{ width: CHECKBOX_COL_WIDTH, minWidth: CHECKBOX_COL_WIDTH }} />
{visibleColumns.map((col) => (
<col key={col.name} style={{ width: getWidth(col.name) }} />
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-surface">
<tr>
{/* Header checkbox */}
<th
scope="col"
className="border-b border-r border-border px-0 py-2 w-[40px]"
>
<div className="flex items-center justify-center">
<input
ref={checkboxRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</th>
{visibleColumns.map((col) => (
<th
key={col.name}
scope="col"
role="columnheader"
className="group relative border-b border-r border-border px-3 py-2 font-heading text-text-muted last:border-r-0"
style={{ width: getWidth(col.name), maxWidth: getWidth(col.name) }}
>
<div className="truncate flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
<span className="text-text text-xs">{col.name}</span>
<span className="ml-1 text-[10px] text-text-muted/60" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
{/* resize handle */}
<div
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
onMouseDown={(e) => startResize(col.name, e)}
onDoubleClick={() => {
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH },
}));
}}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const isSelected = selectedRows.has(rowIndex);
return (
<tr
key={rowIndex}
className={`border-b border-border hover:bg-surface/50 ${isSelected ? "bg-accent/5" : ""}`}
>
{/* Row checkbox */}
<td className="border-r border-border px-0 py-2" style={{ overflow: "hidden" }}>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(rowIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</td>
{visibleColumns.map((col) => {
const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined;
const isNull = cell === null || cell === undefined;
const isFk = col.is_fk && col.fk_ref && !isNull;
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
};
return (
<td key={col.name} className="border-r border-border px-3 py-2 last:border-r-0 font-heading text-xs" style={{ overflow: "hidden" }}>
<div
className={`truncate max-w-full select-text ${isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
title={isNull ? "NULL" : isFk ? `FK → ${col!.fk_ref![0]}.${col!.fk_ref![1]}: ${String(cell)}` : isJson ? "Click to view JSON" : String(cell)}
onClick={isFk ? (e) => handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined}
>
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</span>
) : (
String(cell)
)}
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{/* FK preview popover */}
{fkPreview && (
<FkPreviewPopover
connectionId={fkPreview.connectionId}
schema={fkPreview.schema}
table={fkPreview.table}
column={fkPreview.column}
value={fkPreview.value}
anchorRect={fkPreview.anchorRect}
onClose={() => setFkPreview(null)}
/>
)}
{/* JSON cell popover */}
{jsonPopover && (
<JsonCellPopover
value={jsonPopover.value}
anchorRect={jsonPopover.anchorRect}
onClose={() => setJsonPopover(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.setState({
tabs: [], activeTabId: null, changesQueue: [],
databases: ["mydb"], schemas: ["public"],
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
currentDatabase: "mydb", currentSchema: "public",
});
});
it("renders the sidebar", () => {
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
});
+408
View File
@@ -0,0 +1,408 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { TableTree } from "./TableTree";
import { TabBar } from "./TabBar";
import { DataGrid } from "./DataGrid";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
export interface DbViewerScreenProps {
connectionId: string;
onHome: () => void;
onSettings: () => void;
}
// ─── client-side filter/sort helpers ─────────────────────
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = { id: string; column: string; order: "asc" | "desc" };
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
if (rules.length === 0) return rows;
return rows.filter((row) =>
rules.every((rule) => {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) return true;
const cell = row[ci];
const str = cell === null || cell === undefined ? "" : String(cell);
switch (rule.operator) {
case "null": return cell === null;
case "notnull": return cell !== null;
case "eq": return str === rule.value;
case "neq": return str !== rule.value;
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
case "gt": return Number(str) > Number(rule.value);
case "lt": return Number(str) < Number(rule.value);
default: return true;
}
}),
);
}
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
if (rules.length === 0) return rows;
return [...rows].sort((a, b) => {
for (const rule of rules) {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) continue;
const va = a[ci];
const vb = b[ci];
const cmp =
va === null && vb === null ? 0
: va === null ? -1
: vb === null ? 1
: String(va).localeCompare(String(vb), undefined, { numeric: true });
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
}
return 0;
});
}
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
const [sortRules, setSortRules] = useState<SortRule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const smartSortApplied = useRef<Set<string>>(new Set());
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
const settings = useSettingsStore((s) => s.settings);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
setDefaultPageSize(settings.table_page_size);
}
}, [settings?.table_page_size, setDefaultPageSize]);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null);
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
});
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const schemas = useDbViewerStore((s) => s.schemas);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set());
const fetchData = useCallback(async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return;
fetchingRef.current.add(tab.id);
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
tab.table,
tab.page,
tab.pageSize,
);
setTabData(tab.id, result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setTabError(tab.id, msg);
} finally {
fetchingRef.current.delete(tab.id);
}
}, [connectionId, setTabData, setTabError]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
if (state.activeTabId) {
state.closeTab(state.activeTabId);
} else {
onHome();
}
});
useEffect(() => {
if (!activeTab) return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (smartSortApplied.current.has(activeTab.id)) return;
const cols = activeTab.data.columns;
const getColType = (name: string) => {
const col = cols.find((c) => c.name.toLowerCase() === name.toLowerCase());
return col?.data_type.toLowerCase() ?? "";
};
const isNumeric = (name: string) => {
const t = getColType(name);
return ["integer", "int", "int2", "int4", "int8", "smallint", "bigint",
"serial", "bigserial", "smallserial", "tinyint", "mediumint",
"numeric", "decimal", "real", "float", "float4", "float8",
"double precision", "double", "number"].includes(t);
};
const isTimestamp = (name: string) => {
const t = getColType(name);
return ["timestamp", "timestamptz", "timestamp without time zone",
"timestamp with time zone", "date", "datetime", "datetime2",
"smalldatetime"].some((pt) => t.includes(pt));
};
// Find the first column name that exists and passes type checks
const findCol = (candidates: string[], numericOnly = false): string | undefined => {
for (const cand of candidates) {
const match = cols.find((c) => c.name.toLowerCase() === cand.toLowerCase());
if (!match) continue;
if (numericOnly && !isNumeric(match.name)) continue;
return match.name;
}
return undefined;
};
const findBySuffix = (suffixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (suffixes.some((s) => name.endsWith(s))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
const findByPrefix = (prefixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (prefixes.some((p) => name.startsWith(p))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
// Priority-ordered rules: each returns [columnName | undefined, order]
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
// Tier 1: Explicit recency columns
() => [findCol(["updated_at", "modified_at", "changed_at", "altered_at", "revised_at"]), "desc"],
() => [findCol(["created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at"]), "desc"],
() => [findCol(["deleted_at", "removed_at", "expired_at", "archived_at"]), "desc"],
// Tier 2: Generic date/timestamp columns (DESC = newest)
() => {
const col = cols.find((c) => isTimestamp(c.name));
return col ? [col.name, "desc"] : [undefined, "desc"];
},
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
() => [findBySuffix(["_at"]), "desc"],
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
() => [findBySuffix(["_on"]), "desc"],
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
() => [findByPrefix(["last_"]), "desc"],
// Tier 6: Numeric ID (DESC = highest/newest)
() => [findCol(["id", "uid", "pk"], true), "desc"],
// Tier 7: Any *_id suffix (numeric FKs usually increment)
() => [findBySuffix(["_id"], true), "desc"],
// Tier 8: Sequence/order columns (ASC = natural order)
() => [findCol(["seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order"], true), "asc"],
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
() => [findCol(["rank", "ranking", "priority", "weight", "score", "rating"], true), "desc"],
// Tier 10: Version/revision tracking (DESC = latest)
() => [findCol(["version", "revision", "rev", "build", "release"], true), "desc"],
// Tier 11: Count/quantity (DESC = most)
() => [findCol(["count", "total", "amount", "quantity", "qty", "num", "number", "no"], true), "desc"],
];
for (const rule of rules) {
const [colName, order] = rule();
if (colName) {
smartSortApplied.current.add(activeTab.id);
setSortRules([{ id: crypto.randomUUID(), column: colName, order }]);
return;
}
}
}, [activeTab]);
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
setFilterRules((prev) => {
const exists = prev.some((r) => r.column === column && r.value === value);
if (exists) return prev;
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
});
}, [activeTab?.columnFilter]);
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
const stillExists = filterRules.some((r) => r.column === column && r.value === value);
if (!stillExists) {
clearColumnFilter(activeTab.id);
}
}, [filterRules, activeTab, clearColumnFilter]);
// Refresh: clear data so auto-fetch effect re-fetches
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
),
}));
}, []);
const rawRows = activeTab?.data?.rows ?? [];
const columns = activeTab?.data?.columns ?? [];
const processedRows = useMemo(() => {
let result = rawRows;
result = applyFilters(result, columns, filterRules);
result = applySorts(result, columns, sortRules);
return result;
}, [rawRows, columns, filterRules, sortRules]);
const onPanelResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth };
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const w = Math.max(180, Math.min(600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX)));
setTablePanelWidth(w);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [tablePanelWidth]);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
else if (view === "settings") onSettings();
},
[onHome, onSettings],
);
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="border-r border-border flex flex-col shrink-0" style={{ width: tablePanelWidth }}>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(col)) next.delete(col); else next.add(col);
return next;
})
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={setFilterRules}
sortRules={sortRules}
onSortChange={setSortRules}
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
onClearSelection={() => setSelectedRows(new Set())}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
</div>
</div>
</div>
<ChangesQueuePanel />
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { TooltipProvider } from "../ui/Tooltip";
describe("DbViewerSidebar", () => {
it("renders all navigation icons", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
});
it("calls onNavigate when home is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/home/i));
expect(onNavigate).toHaveBeenCalledWith("home");
});
it("calls onNavigate when settings is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/settings/i));
expect(onNavigate).toHaveBeenCalledWith("settings");
});
});
@@ -0,0 +1,57 @@
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
}
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
}
export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{ id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: <Grid2x2 size={20} />, stub: true },
{ id: "functions", label: "Functions coming soon", icon: <FunctionSquare size={20} />, stub: true },
{ id: "triggers", label: "Triggers coming soon", icon: <GitBranch size={20} />, stub: true },
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
];
function renderItem(item: NavItem) {
const isActive = currentView === item.id;
const baseClass = "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
const activeClass = "text-accent";
const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
const stubClass = "opacity-40 cursor-not-allowed";
return (
<Tooltip key={item.id} content={item.label} side="right">
<button
type="button"
aria-label={item.label}
disabled={item.stub}
onClick={() => onNavigate(item.id)}
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
>
{item.icon}
</button>
</Tooltip>
);
}
return (
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">{topItems.map(renderItem)}</div>
<div className="flex flex-col gap-2">{bottomItems.map(renderItem)}</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
const defaultProps = {
databases: [] as string[],
currentDatabase: null as string | null,
setCurrentDatabase: () => {},
schemas: [] as string[],
currentSchema: null as string | null,
setCurrentSchema: () => {},
searchQuery: "",
onSearchChange: () => {},
};
describe("DbViewerToolbar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders Tables label", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByText("Tables")).toBeInTheDocument();
});
it("renders database dropdown when multiple databases", () => {
render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
databases={["mydb", "otherdb"]}
currentDatabase="mydb"
/>
</TooltipProvider>,
);
expect(screen.getByText("mydb")).toBeInTheDocument();
});
it("renders refresh and create table buttons", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,195 @@
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
export function DbViewerToolbar({
databases,
currentDatabase,
setCurrentDatabase,
schemas,
currentSchema,
setCurrentSchema,
onEdit,
connectionId,
searchQuery,
onSearchChange,
}: {
databases: string[];
currentDatabase: string | null;
setCurrentDatabase: (db: string | null) => void;
schemas: string[];
currentSchema: string | null;
setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void;
connectionId?: string;
searchQuery: string;
onSearchChange: (q: string) => void;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) onSearchChange(""); // clear when closing
return next;
});
}, [onSearchChange]);
// Cleanup result timer on unmount
useEffect(() => {
return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
}, []);
const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return;
setRefreshing(true);
setResult('idle');
try {
const dbs = await cmd.getDatabases(connectionId);
const scs = await cmd.getSchemas(connectionId);
const tbls = await cmd.getTables(connectionId);
populate(dbs, scs, tbls);
setResult('success');
} catch {
setResult('error');
} finally {
setRefreshing(false);
resultTimer.current = setTimeout(() => setResult('idle'), 1500);
}
}, [connectionId, refreshing, populate]);
return (
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
<button
aria-label="Edit Connection"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Pencil size={14} />
</button>
</Tooltip>
)}
<Tooltip content={result === 'success' ? 'Refreshed' : result === 'error' ? 'Refresh failed' : 'Refresh Database'} side="bottom">
<button
aria-label="Refresh"
onClick={handleRefresh}
disabled={refreshing}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
{refreshing ? (
<RefreshCw size={14} className="animate-spin" />
) : result === 'success' ? (
<Check size={14} className="text-emerald-400" />
) : result === 'error' ? (
<AlertCircle size={14} className="text-red-400" />
) : (
<RefreshCw size={14} />
)}
</button>
</Tooltip>
<Tooltip content="Create Table" side="bottom">
<button
aria-label="Create Table"
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
>
<Plus size={14} />
</button>
</Tooltip>
<Tooltip content="Search Tables" side="bottom">
<button
aria-label="Search Tables"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</Tooltip>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search size={12} className="absolute left-2.5 text-text-muted pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter tables…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({ value: d, label: d }))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={setCurrentSchema}
options={schemas.map((s) => ({ value: s, label: s }))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,123 @@
import { useState, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
interface EditConnectionModalProps {
connection: Connection;
open: boolean;
onClose: () => void;
onSaved: (updated: Connection) => void;
}
export function EditConnectionModal({
connection,
open,
onClose,
onSaved,
}: EditConnectionModalProps) {
const [form, setForm] = useState<ConnectionFormData>(() => ({
name: connection.name,
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
folder_id: connection.folder_id,
tag_ids: [...connection.tag_ids],
connection_string: "",
db_type: connection.db_type,
host: connection.host,
port: connection.port,
username: connection.username,
password: null,
database: connection.database ?? null,
use_keychain: false,
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const loadAll = useConnectionStore((s) => s.loadAll);
const notify = useNotificationStore((s) => s.notify);
const handleSave = useCallback(async () => {
if (!form.name.trim()) return;
setSaving(true);
try {
const input: ConnectionInput = {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password: form.password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
};
const updated = await updateConnection(connection.id, input);
if (form.password) {
await saveConnectionPassword(connection.id, form.password).catch(() => {});
}
notify("Connection updated", "success");
onSaved(updated);
onClose();
loadAll();
} catch (e) {
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setSaving(false);
}
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
const handleTest = useCallback(async () => {
setTesting(true);
try {
// Fetch password from keychain if not provided in form
let password = form.password;
if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
}
const result = await testConnection({
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
});
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setTesting(false);
}
}, [form, notify, connection.id]);
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={handleTest} disabled={testing}>
{testing ? "Testing..." : "Test"}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
import * as cmd from "../../lib/commands";
import type { QueryResult } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
import { useDbViewerStore } from "../../stores/dbViewerStore";
interface FkPreviewPopoverProps {
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
onClose: () => void;
}
export function FkPreviewPopover({
connectionId,
schema,
table,
column,
value,
anchorRect,
onClose,
}: FkPreviewPopoverProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const openTab = useDbViewerStore((s) => s.openTab);
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
const handleOpen = () => {
openTab(schema, table);
// Find the newly created tab and apply the column filter
const newTab = useDbViewerStore.getState().tabs.find(
(t) => t.schema === schema && t.table === table,
);
if (newTab) {
setColumnFilter(newTab.id, column, value);
}
onClose();
};
// Fetch the referenced row
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
cmd
.getFkPreview(connectionId, schema, table, column, value)
.then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch((e: any) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [connectionId, schema, table, column, value]);
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay to avoid closing immediately from the same click that opened it
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
// Compute position to keep popover within viewport
const popoverWidth = 360;
const popoverMaxHeight = 320;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
// Flip horizontally if off-screen
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
// Flip vertically if not enough space below
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Key size={12} className="text-amber-400 shrink-0" />
<span className="text-xs font-heading text-text truncate">
{schema}.{table}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleOpen}
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
title="Open table in new tab"
>
<ExternalLink size={11} />
<span>Open</span>
</button>
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
<Loader2 size={14} className="animate-spin" />
Loading...
</div>
)}
{error && (
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
{error}
</div>
)}
{data && data.rows.length === 0 && !loading && (
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
No matching row found
</div>
)}
{data && data.rows.length > 0 && (
<table className="w-full text-xs">
<tbody>
{data.columns.map((col, ci) => {
const cell = data.rows[0][ci];
const isNull = cell === null || cell === undefined;
return (
<tr
key={col.name}
className="border-b border-border last:border-0 hover:bg-surface/30"
>
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
<div className="flex items-center gap-1">
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
<span className="truncate">{col.name}</span>
<span
className="text-[10px] text-text-muted/50 shrink-0"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</span>
</div>
</td>
<td className="px-3 py-1.5 text-text">
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : (
<span className="break-all">{String(cell)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,163 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Braces, Copy, Check, X } from "lucide-react";
interface JsonCellPopoverProps {
value: unknown;
anchorRect: DOMRect | null;
onClose: () => void;
}
function safeJsonParse(value: unknown): object | null {
if (typeof value === "object" && value !== null) return value as object;
if (typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null ? parsed : null;
} catch {
return null;
}
}
function formatJson(obj: object): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
const [copied, setCopied] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const parsed = safeJsonParse(value);
const rawText = typeof value === "string" ? value : JSON.stringify(value);
const formattedText = parsed ? formatJson(parsed) : rawText;
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
const popoverWidth = 420;
const popoverMaxHeight = 360;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
const handleCopy = async () => {
const text = tab === "formatted" ? formattedText : rawText;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Braces size={12} className="text-accent shrink-0" />
<span className="text-xs font-heading text-text">JSON</span>
</div>
<div className="flex items-center gap-1">
{/* Tabs */}
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
<button
onClick={() => setTab("formatted")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Formatted
</button>
<button
onClick={() => setTab("raw")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Raw
</button>
</div>
{/* Copy */}
<button
onClick={handleCopy}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
title="Copy to clipboard"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
{/* Close */}
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div
className="overflow-auto p-3"
style={{ maxHeight: popoverMaxHeight - 41 }}
>
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
{tab === "formatted" ? formattedText : rawText}
</pre>
</div>
</div>,
document.body,
);
}
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
const parsed = safeJsonParse(value);
if (!parsed) return { label: "", isJson: false };
if (Array.isArray(parsed)) {
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
}
const keys = Object.keys(parsed);
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "./TabBar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
const user = userEvent.setup();
describe("TabBar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no tabs", () => {
render(<TabBar />);
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
});
it("renders open tab names", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
render(<TabBar />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("posts")).toBeInTheDocument();
});
it("sets active tab when clicked", async () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
await user.click(screen.getByText("users"));
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
});
it("closes tab when close button clicked", async () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
const closeButton = screen.getByRole("button", {
name: /close users/i,
});
await user.click(closeButton);
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
expect(
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
).toBeUndefined();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
export function TabBar() {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
if (tabs.length === 0) {
return (
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
No tables open
</div>
);
}
return (
<div
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
role="tablist"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<button
type="button"
onClick={() => setActiveTab(tab.id)}
className="flex-1 text-left outline-none cursor-pointer"
>
{tab.table}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
}
+875
View File
@@ -0,0 +1,875 @@
import { useState, useRef, useEffect } from "react";
import {
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { Tooltip } from "../ui/Tooltip";
import type { ColumnInfo } from "../../lib/types";
const AUTO_REFRESH_OPTIONS = [
{ label: "Off", value: 0 },
{ label: "5s", value: 5000 },
{ label: "10s", value: 10_000 },
{ label: "30s", value: 30_000 },
{ label: "1m", value: 60_000 },
{ label: "5m", value: 300_000 },
] as const;
const PAGE_SIZES = [50, 100, 200] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
{ label: "CSV", ext: "csv" },
{ label: "SQL", ext: "sql" },
{ label: "Markdown", ext: "md" },
] as const;
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = {
id: string;
column: string;
order: "asc" | "desc";
};
// ─── helpers ────────────────────────────────────────────
function exportData(
rows: unknown[][],
columns: ColumnInfo[],
format: string,
tableName: string,
) {
const headers = columns.map((c) => c.name);
let content: string;
let mime: string;
switch (format) {
case "json": {
const jsonRows = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
content = JSON.stringify(jsonRows, null, 2);
mime = "application/json";
break;
}
case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of rows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
content = csvRows.join("\n");
mime = "text/csv";
break;
}
case "sql": {
const lines = [`-- ${tableName}`];
for (const row of rows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
content = lines.join("\n");
mime = "application/sql";
break;
}
case "md": {
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
for (const row of rows) {
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
}
content = mdRows.join("\n");
mime = "text/markdown";
break;
}
default:
return;
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}.${format === "md" ? "md" : format}`;
a.click();
URL.revokeObjectURL(url);
}
// ─── sub-components ─────────────────────────────────────
function DropdownMenu({
open,
setOpen,
align,
children,
}: {
open: boolean;
setOpen: (v: boolean) => void;
align?: "left" | "right";
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
return (
<div
ref={ref}
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{children}
</div>
);
}
function FilterModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.operator}
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="eq">=</option>
<option value="neq"></option>
<option value="contains">contains</option>
<option value="starts">starts with</option>
<option value="ends">ends with</option>
<option value="gt">&gt;</option>
<option value="lt">&lt;</option>
<option value="null">is null</option>
<option value="notnull">not null</option>
</select>
{rule.operator !== "null" && rule.operator !== "notnull" && (
<input
type="text"
value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
)}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add filter
</button>
</div>
);
}
function SortModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: SortRule[];
onChange: (rules: SortRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<SortRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.order}
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="asc">ASC</option>
<option value="desc">DESC</option>
</select>
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add sort
</button>
</div>
);
}
// ─── bulk actions dropdown ──────────────────────────────
function BulkActionsDropdown({
columns,
selectedRows,
schema,
table,
onClearSelection,
}: {
columns: ColumnInfo[];
selectedRows: unknown[][];
schema: string;
table: string;
onClearSelection: () => void;
}) {
const [open, setOpen] = useState(false);
const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
setOpen(false);
};
const handleCopyJSON = () => {
const json = selectedRows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
copyToClipboard(JSON.stringify(json, null, 2));
};
const handleCopyCSV = () => {
const headers = columns.map((c) => c.name);
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of selectedRows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
copyToClipboard(csvRows.join("\n"));
};
const handleCopySQL = () => {
const headers = columns.map((c) => c.name);
const lines: string[] = [];
for (const row of selectedRows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
copyToClipboard(lines.join("\n"));
};
const handleDeleteSelected = () => {
const pkCol = columns.find((c) => c.is_pk);
for (const row of selectedRows) {
const pk: Record<string, unknown> = {};
if (pkCol) {
const ci = columns.findIndex((c) => c.name === pkCol.name);
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
}
addChange({
type: "delete",
schema,
table,
primaryKey: pk,
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
description: `Delete row from ${table}`,
});
}
setOpen(false);
onClearSelection();
};
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} />
</button>
<DropdownMenu open={open} setOpen={setOpen} align="right">
<button
type="button"
onClick={handleCopyJSON}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileJson size={13} className="text-text-muted" />
Copy as JSON
</button>
<button
type="button"
onClick={handleCopyCSV}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileText size={13} className="text-text-muted" />
Copy as CSV
</button>
<button
type="button"
onClick={handleCopySQL}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<Terminal size={13} className="text-text-muted" />
Copy as SQL INSERT
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={handleDeleteSelected}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
>
<Trash2 size={13} />
Delete selected rows
</button>
</DropdownMenu>
</div>
);
}
// ─── main component ─────────────────────────────────────
interface TableControlsProps {
connectionId: string;
schema: string;
table: string;
columns: ColumnInfo[];
rows: unknown[][];
hiddenColumns: Set<string>;
onToggleColumn: (col: string) => void;
onRefresh: () => void;
filterRules: FilterRule[];
onFilterChange: (rules: FilterRule[]) => void;
sortRules: SortRule[];
onSortChange: (rules: SortRule[]) => void;
selectedCount: number;
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
}
export function TableControls({
connectionId: _connectionId,
schema,
table,
columns,
rows,
hiddenColumns,
onToggleColumn,
onRefresh,
filterRules,
onFilterChange,
sortRules,
onSortChange,
selectedCount,
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
}: TableControlsProps) {
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage);
const setPageSize = useDbViewerStore((s) => s.setPageSize);
const openTab = useDbViewerStore((s) => s.openTab);
const addChange = useDbViewerStore((s) => s.addChange);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const cancelChange = useDbViewerStore((s) => s.cancelChange);
const activeTab = tabs.find((t) => t.id === activeTabId);
// local state
const [filterOpen, setFilterOpen] = useState(false);
const [sortOpen, setSortOpen] = useState(false);
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
// auto-refresh timer
useEffect(() => {
if (autoRefresh === 0) return;
const id = setInterval(onRefresh, autoRefresh);
return () => clearInterval(id);
}, [autoRefresh, onRefresh]);
// pagination
const totalRows = activeTab?.data?.total_rows ?? rows.length;
const pageSize = activeTab?.pageSize ?? 50;
const currentPage = activeTab?.page ?? 1;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
const startRow = (clampedPage - 1) * pageSize + 1;
const endRow = Math.min(clampedPage * pageSize, totalRows);
const handlePrev = () => {
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
};
const handleNext = () => {
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
};
const handleInsertRow = () => {
const newData: Record<string, unknown> = {};
columns.forEach((c) => { newData[c.name] = null; });
addChange({
type: "insert",
schema,
table,
primaryKey: {},
newData,
description: `Insert row into ${table}`,
});
openTab(schema, table);
};
const handleExport = (format: string) => {
exportData(rows, columns, format, table);
setExportOpen(false);
};
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 bg-surface/50 text-xs text-text-muted">
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
type="button"
onClick={handleInsertRow}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Insert row"
>
<Plus size={14} />
</button>
</Tooltip>
{/* Refresh */}
<Tooltip content="Refresh" side="bottom">
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Refresh"
>
<RefreshCw size={14} />
</button>
</Tooltip>
{/* Auto-refresh */}
<div className="relative">
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button
type="button"
onClick={() => setAutoRefreshOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Auto-refresh"
>
<Clock size={14} />
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button>
</Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
{AUTO_REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh === opt.value ? "text-accent" : "text-text"
}`}
>
{autoRefresh === opt.value && <Check size={12} />}
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
</button>
))}
</DropdownMenu>
</div>
<div className="w-px h-4 bg-border mx-1" />
{/* Filter */}
<div className="relative">
<Tooltip content="Column filters" side="bottom">
<button
type="button"
onClick={() => setFilterOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
filterRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Column filters"
>
<Filter size={14} />
{filterRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{filterRules.length}
</span>
)}
</button>
</Tooltip>
<FilterModal
columns={columns}
rules={filterRules}
onChange={onFilterChange}
open={filterOpen}
setOpen={setFilterOpen}
/>
</div>
{/* Sort */}
<div className="relative">
<Tooltip content="Sort rules" side="bottom">
<button
type="button"
onClick={() => setSortOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
sortRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Sort rules"
>
<ArrowUpDown size={14} />
{sortRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{sortRules.length}
</span>
)}
</button>
</Tooltip>
<SortModal
columns={columns}
rules={sortRules}
onChange={onSortChange}
open={sortOpen}
setOpen={setSortOpen}
/>
</div>
{/* Export */}
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
onClick={() => setExportOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Export"
>
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
type="button"
onClick={() => handleExport(fmt.ext)}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{fmt.label}
</button>
))}
</DropdownMenu>
</div>
</div>
{/* ── spacer ──────────────────────────────────── */}
<div className="flex-1" />
{/* ── right side ─────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Action queue button */}
<div className="relative">
<button
type="button"
onClick={() => setQueueOpen((v) => !v)}
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
changesQueue.some((c) => c.status === "pending")
? "text-amber-400 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
aria-label="Action queue"
>
<span className="text-xs font-medium">Queue</span>
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
{changesQueue.filter((c) => c.status === "pending").length}
</span>
)}
</button>
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
</div>
<div className="max-h-64 overflow-y-auto">
{changesQueue.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
)}
{changesQueue.map((item) => (
<div
key={item.id}
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
item.status === "pending" ? "text-text" : "text-text-muted/50"
}`}
>
<span className="truncate flex-1">
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
item.status === "pending" ? "bg-amber-500"
: item.status === "committed" ? "bg-emerald-500"
: "bg-red-500"
}`} />
{item.type.toUpperCase()} {item.table}
{item.description && <span className="ml-1 text-text-muted/50"> {item.description}</span>}
</span>
{item.status === "pending" && (
<button
type="button"
onClick={() => cancelChange(item.id)}
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</DropdownMenu>
</div>
{/* Selected count + bulk actions */}
{selectedCount > 0 && (
<>
<span className="text-accent font-medium tabular-nums">
{selectedCount} selected
</span>
<BulkActionsDropdown
columns={columns}
selectedRows={selectedRows}
schema={schema}
table={table}
onClearSelection={onClearSelection}
/>
<button
type="button"
onClick={onClearSelection}
className="text-text-muted hover:text-text transition-colors cursor-pointer"
aria-label="Clear selection"
>
<X size={14} />
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{/* Columns toggle */}
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
onClick={() => setColumnMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Toggle columns"
>
<Columns size={14} />
</button>
</Tooltip>
<DropdownMenu open={columnMenuOpen} setOpen={setColumnMenuOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">Visible columns</div>
<div className="max-h-64 overflow-y-auto">
{columns.map((col) => (
<button
key={col.name}
type="button"
onClick={() => onToggleColumn(col.name)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
hiddenColumns.has(col.name) ? "border-border bg-transparent" : "border-accent bg-accent"
}`}>
{!hiddenColumns.has(col.name) && <Check size={10} className="text-white" />}
</span>
<span className="truncate">{col.name}</span>
</button>
))}
</div>
</DropdownMenu>
</div>
<div className="w-px h-4 bg-border" />
{/* Row count */}
<span className="tabular-nums">
{startRow}-{endRow} of {totalRows}
</span>
{/* Page size */}
<select
value={pageSize}
onChange={handlePageSizeChange}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{/* Pagination */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handlePrev}
disabled={clampedPage <= 1}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Previous page"
>
<ChevronLeft size={14} />
</button>
<span className="tabular-nums min-w-[3rem] text-center">
{clampedPage}/{totalPages}
</span>
<button
type="button"
onClick={handleNext}
disabled={clampedPage >= totalPages}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Next page"
>
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableOverflowMenu } from "./TableOverflowMenu";
describe("TableOverflowMenu", () => {
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn() },
configurable: true,
writable: true,
});
});
it("renders menu trigger button", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
});
it("shows menu options on click", async () => {
const user = userEvent.setup();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
await user.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
});
it("fires onOpenTab when menu item clicked", async () => {
const user = userEvent.setup();
const onOpenTab = vi.fn().mockReturnValue("tab-1");
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
await user.click(screen.getByLabelText(/table options/i));
await user.click(screen.getByText("Open in new tab"));
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
});
});
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { MoreVertical } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog";
interface TableOverflowMenuProps {
schema: string;
table: string;
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
}
interface MenuItem {
id: string;
label: string;
stub?: boolean;
danger?: boolean;
}
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleAction = (id: string) => {
switch (id) {
case "open":
onOpenTab(schema, table, true);
setOpen(false);
break;
case "copy-schema": {
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
if (navigator.clipboard) {
void navigator.clipboard.writeText(sql);
}
setOpen(false);
break;
}
case "empty":
setConfirmAction("empty");
setOpen(false);
break;
case "delete":
setConfirmAction("delete");
setOpen(false);
break;
default:
break;
}
};
const items: MenuItem[] = [
{ id: "open", label: "Open in new tab" },
{ id: "copy-schema", label: "Copy table schema" },
{ id: "export-csv", label: "Export data (CSV)", stub: true },
{ id: "export-json", label: "Export data (JSON)", stub: true },
{ id: "export-sql", label: "Export data (SQL)", stub: true },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
return (
<div className="relative" ref={menuRef}>
<button
aria-label="Table options"
onClick={() => setOpen((o) => !o)}
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical size={14} />
</button>
{open && (
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleAction(item.id)}
disabled={item.stub}
className={[
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
item.stub ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
>
<span>{item.label}</span>
{item.stub && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
Soon
</span>
)}
</button>
))}
</div>
)}
{confirmAction === "empty" && (
<ConfirmDialog
open
title={`Empty Table: ${table}`}
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
confirmLabel="Empty Table"
onConfirm={() => {
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "delete" && (
<ConfirmDialog
open
title={`Delete Table: ${table}`}
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
confirmLabel="Delete Table"
onConfirm={() => {
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableTree } from "./TableTree";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("TableTree", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders table names from store", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{ name: "users", schema: "public", table_type: "TABLE" },
{ name: "orders", schema: "public", table_type: "TABLE" },
],
});
render(<TableTree />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("orders")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
});
render(<TableTree />);
await user.click(screen.getByText("users"));
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
});
});
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
import { abbreviateType } from "../../lib/utils";
import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) {
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [columnCache, setColumnCache] = useState<Record<string, ColumnInfo[]>>({});
const q = (searchQuery ?? "").toLowerCase().trim();
const filteredTables = (currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables).filter((t) => !q || t.name.toLowerCase().includes(q));
const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (isExpanded) next.delete(key);
else next.add(key);
return next;
});
// Fetch columns if not cached
if (!isExpanded && !columnCache[key] && connectionId) {
try {
const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0);
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
} catch { /* ignore, columns will remain unknowns */ }
}
};
const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => {
openTab(schema, table, forceNew);
return "tab";
};
return (
<div className="py-2">
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">No tables</div>
)}
{filteredTables.map((table) => {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => openTab(table.schema, table.name)}
>
<button
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
toggle(key, table.schema, table.name);
}}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Table2 size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}
table={table.name}
onOpenTab={handleOpenTab}
/>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{cols.length === 0 && (
<div className="text-xs text-text-muted">No columns</div>
)}
{cols.map((col) => (
<div
key={col.name}
className="flex items-center gap-2 text-xs text-text-muted"
title={col.is_fk && col.fk_ref
? `${col.data_type}${col.fk_ref[0]}.${col.fk_ref[1]}`
: col.data_type}
>
{col.is_pk ? (
<Key size={12} className="text-accent shrink-0" />
) : col.is_fk ? (
<Key size={12} className="text-amber-400 shrink-0" />
) : (
<Type size={12} className="shrink-0" />
)}
<span className="truncate">{col.name}</span>
<span className="text-text-subtle truncate" title={col.data_type}>{abbreviateType(col.data_type)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}