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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">></option>
|
||||
<option value="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" });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user