v0.2.0 — Object Explorer, Backup/Restore/Sync, Server-Side Filters, ENUM support, and more

* feat(grid): VirtualDataGrid component with TanStack Virtual (Task A1)

* fix(grid): address code review — header bg, row hover/select, table classes, measureElement removal

* feat(grid): column resize, FK popover, JSON popover in VirtualDataGrid (Task A2)

* fix(grid): address code review — accessibility, indeterminate checkbox, safe jp default

* feat(grid): wire VirtualDataGrid into DbViewerScreen, increase page size to 500 (Task A3)

* feat(grid): polish VirtualDataGrid — large datasets, hidden columns, select-all (Tasks A4+A5)

* feat(dnd): moveConnection store action with optimistic update + rollback (Task D1)

* feat(dnd): draggable connection cards + droppable folder tree (Task D2)

* feat(dnd): wire DndContext, onDragEnd, DragOverlay, drop targets in ConnectionGrid (Tasks D3-D5)

* feat(backup): models, v4 migration for backup_history, TypeScript types (Task C1)

* feat(backup): pg_dump, pg_restore, detect_tools, db_sync commands (Task C2)

* feat(backup): frontend wrappers, backup store, dialogs, tool detection, confirmations (Tasks C3-C5)

* feat(objects): add FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo models (Task B1)

* feat(objects): introspection queries + Tauri commands for functions, triggers, sequences, enums, extensions (Task B2)

* feat(objects): ObjectTree component, sidebar navigation, search, source expand (Tasks B3-B5)

* fix: drag handle position, env tag layout, transparent header, virtual row rendering

* fix: remove table toolbar bg, fix sticky header with flex layout, match header/row widths

* fix: vertical borders on all cells, horizontal border scrolls with header, checkbox column borders

* fix: full-height cell borders, centered checkboxes, horizontal scroll borders

* fix: header bg fills full width on scroll, page sizes match settings (50-500)

* fix: add subtle bg to panel resize handle to eliminate visual gap

* fix: FK preview showing 'db error' + make entire tab clickable

- VirtualDataGrid: added schema prop to fix empty schema sent to FK preview,
  which produced invalid SQL (SELECT * FROM ""."table")
- Rust get_fk_preview: use pg_error_message() instead of .to_string() so
  real PostgreSQL error messages surface instead of just 'db error'
- TabBar: moved onClick to the outer tab container so the entire tab area
  is clickable, not just the table name text

* feat: push filters and sorts to server-side SQL queries

- Added FilterRule/SortRule models to Rust backend with serde support
- Built dynamic WHERE (parameterized /?) and ORDER BY clauses
- Both COUNT(*) and data queries now include filter clauses for
  correct pagination with filtered results
- Store setFilterRules/setSortRules now trigger page-1 re-fetch
- Removed client-side applyFilters/applySorts — data arrives
  pre-filtered/sorted from the database
- Added column name validation (is_safe_identifier) to prevent
  SQL injection in filter/sort column names

* chore: center drag handle vertically on connection cards

* feat: convert backup/restore/sync from unreachable modals to in-page views

- Added BackupPage, RestorePage, SyncPage as full-page views inside
  the DB viewer, replacing the old unused modal dialogs
- Added sidebar navigation items: Backup (Download), Restore (Upload),
  DB Sync (ArrowLeftRight)
- Wired routing in DbViewerScreen — currentView switches to the new
  page components, all scoped to the active connection
- Styled with toolbar header, underline inputs, glass cards, and
  destructive confirmation checkboxes matching the app aesthetic
- Auto-detects pg_dump/pg_restore availability with platform-specific
  install instructions when tools are missing

* fix: backup/restore/sync event-driven flow and styling

- Registered tauri-plugin-dialog in Rust lib.rs (was missing — browse
  buttons were silently failing)
- Added #[serde(rename_all = "camelCase")] to BackupOptions,
  RestoreOptions, SyncOptions so frontend camelCase fields map
  correctly to Rust snake_case fields
- Made backup/restore/sync fully event-driven: pages now derive
  running/completed/failed state from the backupStore instead of
  the pgDump/pgRestore/dbSync promise (which returns immediately
  while the actual work runs in the background)
- Added initListener() call in App.tsx so the backupStore actually
  listens to Tauri backup-progress events
- failJob no longer clears activeJobId so the component can still
  find the job to read its error message
- BackupProgress now receives real job status and error_message;
  visible for all states (running/completed/failed) so the error
  bar renders on failure
- Schema field converted from free-text to database-populated
  dropdown (getSchemas) on all three pages
- Removed unused parseError functions and dead code
- Progress bar shows 100% on completed, 50% indeterminate while
  running, red bar + error text on failure

* feat: object explorer pages with split-pane layout and schema switching

Created ObjectExplorerPage — a reusable split-pane component for
Functions, Triggers, Sequences, Enums, and Extensions:

Left panel:
- Database/schema dropdowns for switching context (solves 'no
  functions showing' when they're in a non-default schema)
- Searchable object list with animated search input
- Refresh button to re-query
- Selected item highlighting

Right panel:
- Detail view with icon, name, schema
- Type-specific fields:
  - Functions: return type, language, kind, arguments (with
    mode/type badges), full source code with show/hide
  - Triggers: table (schema-qualified), event, timing,
    orientation, enabled status, trigger definition
  - Sequences: current value, increment, start, min, max,
    cycle flag
  - Enums: labeled pill badges for each enum value
  - Extensions: version, schema, comment
- Empty state showing count of available objects

Bug fix: pg_get_function_result() replaced with format_type()
in Rust introspection — the former returns multiple rows for
SETOF/TABLE functions (generate_series, etc.), causing the
entire query to fail silently. This was the root cause of
'no functions showing even though I know there are'.

* fix: correct PostgreSQL tgtype bitmask in trigger query

The trigger query used wrong bit positions for all events.
PostgreSQL tgtype bits:
  bit 0 (1): ROW vs STATEMENT
  bit 1 (2): BEFORE vs AFTER
  bit 2 (4): INSERT
  bit 3 (8): DELETE
  bit 4 (16): UPDATE
  bit 5 (32): TRUNCATE

The old query had every event shifted one bit down (INSERT=2
instead of 4, DELETE=4 instead of 8, etc.). Timing checked bit 0
instead of bit 1. Orientation label was inverted (showed STATEMENT
when bit 0=1 which actually means ROW).

* fix: object page re-mount on type switch + matching dropdown style

- Added key={type} to each ObjectExplorerPage instance so React
  forces a fresh mount when switching between Functions → Triggers
  → Sequences → Enums → Extensions (was reusing the same component
  instance, showing stale data from the previous type)
- Replaced raw <select> elements with SelectDropdown variant="ghost"
  to match the Explorer page toolbar style (ghost text buttons with
  chevron and dropdown menus)

* fix: match ObjectExplorerPage sidebar exactly to Explorer page

- Added draggable resize handle between panels (same style as
  Explorer: w-1 cursor-col-resize, bg-border/20, hover/accent)
- Panel width is now resizable (180–500px, double-click resets to
  280px, same as tablePanelWidth)
- Changed list row gap from gap-2 to gap-1 and py-1.5 to py-1
  so list items match TableTree row sizing exactly
- Removed py-1 from the scrollable list container so the first
  item sits flush against the toolbar border (matching TableTree)
- Fixed dropdown separator: gap-3→gap-2, removed text-sm from |
  (now identical to Explorer toolbar dropdown spacing)

* fix: handle overloaded PostgreSQL functions in object explorer

PostgreSQL supports function overloading — same name, different
argument types. The old code used bare name as React key and for
selection comparison, causing:
- Multiple overloads sharing the same key → rendering glitches
  (looked like 'duplicate instances' or missing items)
- Clicking one overload highlighted ALL overloads with that name

Changes:
- itemKey(): unique key using name(arg_type1,arg_type2) for
  functions, bare name for other object types
- itemLabel(): display name with argument signature for functions
  with args (e.g. 'calculate_tax(numeric, integer)')
- Selection comparison uses itemKey equality instead of name
- Search now matches against the full label (name + args)
- Detail view header shows the full label

* feat: card-based detail views with border separators for all object types

Redesigned detail views to match the Explorer page aesthetic:

Functions:
- Signature card: Returns, Language, Kind, Schema in border-separated rows
- Arguments card: each arg shown as 'name: type' with OUT/VARIADIC mode badges
- Source card: header bar with language label, bordered code block

Triggers:
- Details card: Table (schema-qualified), Event, Timing+Orientation,
  Status (color-coded enabled/disabled), Schema
- Definition card with SQL code block

Sequences:
- Compact card: Current Value (highlighted), Increment, Start,
  Min/Max (combined row), Cycle (amber/neutral)

Enums:
- Details card with schema
- Values card with label count, pill badges in rounded-md style

Extensions:
- Card with Version, Schema, optional Comment

Shared: Source code now has bg-surface-raised, rounded-lg, border,
better padding (p-4), and longer default truncation (800 chars).
Removed unused DetailRow helper.

* feat: syntax-highlighted line-numbered source code + flat border layout

SyntaxCode:
- Tokenizes PL/pgSQL/SQL: keywords (blue), types (emerald),
  strings (amber), comments (subtle italic), numbers (purple),
  operators (muted)
- Handles dollar-quoted strings (26325...26325 / $tag$...$tag$),
  single-quoted strings with escape (''), -- line comments,
  /* block comments */
- Line numbers on the left with border-r separator
- Hover row highlight (bg-surface/30)
- Collapsed by default at 60 lines with 'Show all N lines…'
  toggle; expand/collapse button

Flat border layout (matching Explorer table viewer):
- All cards (rounded-lg border bg-surface overflow-hidden)
  replaced with plain divs using border-b border-border rows
- Section headers use same border-b + tracking-wider style as
  the data grid column headers
- Each field row: px-4 py-2 flex with 24/28px label column
- Source code uses bg-canvas (lets app bg show through)
- No space-y-4 gaps or card wrappers — clean flat aesthetic

* fix: infinite loop in tokenizer + memoize syntax highlighting

- Added catch-all fallback for unrecognized characters (non-ASCII,
  unicode, symbols) that weren't matched by any tokenizer branch,
  causing an infinite loop and app hang
- Wrapped tokenization in useMemo to avoid re-tokenizing on every
  render (source and expanded state as deps)
- Pre-tokenize all visible lines at once instead of per-line in
  the render loop
- Added explicit type annotations to fix TS7022/TS7024 errors

* fix: horizontal scroll for long source lines in SyntaxCode

Changed the <pre> wrapper from block to inline-block min-w-full
so it grows past the viewport width when lines are long, enabling
the outer overflow-x-auto to kick in. Added overscroll-x-contain
for smoother scroll behavior on macOS.

* fix: proper horizontal scroll containment for source code

- Right panel: overflow-x-hidden prevents code content from
  pushing the entire panel past the viewport
- SyntaxCode pre: whitespace-pre on the pre itself (not just
  inner spans) so long lines stay on one line
- Removed inline-block min-w-full hack on the pre — instead
  the outer overflow-x-auto div clips and scrolls
- Removed unused overscroll-none class

* fix: horizontal scroll containment in object explorer detail view

- Removed overflow-x-hidden from right panel (was clipping
  scrollbars and preventing proper scroll context)
- Added max-w-full overflow-x-auto on the detail content wrapper
  so long lines scroll within the content area instead of pushing
  the entire panel
- Added w-max min-w-full on the SyntaxCode pre so it grows to
  fit content while the parent overflow-x-auto clips and scrolls

* fix: use w-0 overflow-x-hidden pattern to constrain right panel width

The Explorer page uses 'flex-1 w-0 min-w-0 overflow-hidden' on the
right panel. Without w-0, the flex item can expand past its
allocated share when content is wider. Adding w-0 forces the
initial width to zero so the panel can only grow via flex
allocation, not from content pressure.

Detail content wrapper uses inline overflowX: auto + width: 100%
to create the scroll context for the SyntaxCode pre.

* fix: add overscroll-x-none to source code scroll container

* style: refine object explorer page layout and detail views

* fix: replace enum pill badges with bordered row list

Enum values now display as numbered rows with border-b border-border,
matching the Arguments section style exactly. Each value shows a #N
index in muted mono + the label in accent mono, consistent with how
argument names and types are displayed.

* docs: update implementation status in AGENTS.md and README.md

Object Explorer (formerly all  stubs):
- Functions:  detail view, syntax highlighting, overload support
- Triggers:  detail view, tgtype bitmask fix, definition display
- Sequences:  detail view, schema-filtered
- Enums:  bordered row list matching Arguments style
- Extensions:  detail view, DB-scoped query

Database Viewer:
- Column filtering/sorting: now server-side (was client-side)

Backup & Restore (formerly all ):
- pg_dump/pg_restore wrappers:  with progress events
- Backup/Restore/Sync UI:  in-page views with event-driven status

README roadmap: Phases 3 & 5 marked complete

* fix: SQLite data rows showing null for non-text columns

The SQLite branch of get_table_data always read row values as
Option<String>, which silently failed for INTEGER, REAL, and other
non-text types — returning Null for every non-text column.

Added sqlite_value_to_json() that uses rusqlite's ValueRef enum
(Null/Integer/Real/Text/Blob) to properly convert each column to
the correct serde_json::Value type. Applied to all three query
branches (unfiltered, filtered, and FK preview).

* fix: handle PostgreSQL ENUM and USER-DEFINED types in table viewer

- pg_value_to_json: added robust String fallback that catches enum
  values and any other custom/user-defined types not matched by
  the specific type checks (i32, i64, f64, bool, uuid, etc.)
- pg_columns_query: replaced bare c.data_type with CASE WHEN
  c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type
  so enum columns show their actual type name (e.g. badge_category)
  instead of 'USER-DEFINED'
- The get_table_data inline column query already had this fix;
  now pg_columns_query in introspection.rs matches

* fix: cast PostgreSQL custom/enum columns to ::text in data query

tokio-postgres FromSql<String> rejects custom type OIDs (enums,
composite types, domains) even under simple query protocol where
all values arrive as text. This caused all enum column values to
display as NULL.

Fix: dynamically build the SELECT column list by inspecting the
column metadata. For standard PostgreSQL types (int4, text, bool,
jsonb, etc.) use the bare column name. For anything else (custom
enums, domains), append ::text so the value reaches the client as
a regular text column that FromSql<String> accepts.

* chore: replace app icons with 1024x1024 Default variant (proper padding)

* chore: fix Rust warnings — unused import and unnecessary mut

- Removed unused pub use backup::* from models/mod.rs (backup
  types are imported directly via crate::models::backup::*)
- Removed unnecessary mut on filter_params in db_viewer.rs

* chore: bump v0.2.0 + update docs status

AGENTS.md:
- Virtualized data grid:  (row-level virtualization
  via @tanstack/react-virtual)
- Column metadata: noted ENUM/custom type resolution and ::text
  casting for data retrieval

Version bump: 0.1.0 → 0.2.0 across package.json, tauri.conf.json,
and Cargo.toml

* docs: update README features to reflect current implementation

Rewrote aspirational sections with accurate current-state descriptions:

Object Explorer:
- Removed Indexes & Constraints (not yet implemented)
- Added syntax highlighting, line numbers, overload support for Functions
- Clarified Extensions are view-only (no enable/disable toggling yet)

SQL Editor & Query Workbench:
- Removed Monaco, autocomplete, formatter, history (not yet built)
- Kept what's real: multi-tab workspace, changes queue, smart sort
- Added 'coming soon' note

Data Grid:
- Removed inline editing, visual filter builder, import (not yet built)
- Added what's real: server-side filtering, FK preview, JSON viewer

Admin Tools:
- Updated backup/restore/sync descriptions to match actual UI
- Removed aspirational 'drag-and-drop', 'dry-run', 'diff viewer'

Tech Stack:
- Monaco marked as planned; Glide removed (we use TanStack Virtual)
This commit is contained in:
2026-07-29 03:23:52 +08:00
committed by GitHub
parent d872e429e4
commit 3dab09f25d
86 changed files with 7125 additions and 1320 deletions
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import type { Connection, Tag } from "../../lib/types";
import { useUiStore } from "../../stores/uiStore";
@@ -15,42 +16,50 @@ const conn: Connection = {
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
};
function Wrapper({ children }: { children: React.ReactNode }) {
return <DndContext>{children}</DndContext>;
}
describe("ConnectionCard", () => {
beforeEach(() => {
useUiStore.setState({ selectedItemIds: [] });
});
it("renders name and host", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
});
it("renders db type label", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
});
it("renders tag badges", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("primary")).toBeInTheDocument();
});
it("renders drag handle", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
});
it("omits port for sqlite", () => {
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
render(<ConnectionCard connection={sqlite} tags={tags} />);
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("/data/x.db")).toBeInTheDocument();
expect(screen.queryByText(/:5432/)).not.toBeInTheDocument();
});
it("fires onTagToggle when a tag badge is clicked", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("production"));
expect(fn).toHaveBeenCalledWith("t1");
});
it("opens DbViewer on single click when nothing is selected", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
expect(fn).toHaveBeenCalledWith(conn.id);
});
@@ -59,7 +68,7 @@ describe("ConnectionCard", () => {
const user = userEvent.setup();
useUiStore.setState({ selectedItemIds: ["other-id"] });
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
// Should NOT open — should toggle selection instead
expect(fn).not.toHaveBeenCalled();
+38 -11
View File
@@ -3,8 +3,10 @@ import type { Connection, Tag } from "../../lib/types";
import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons";
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
import { TagBadge } from "../tags/TagBadge";
import { Check } from "lucide-react";
import { Check, GripVertical } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
interface ConnectionCardProps {
connection: Connection;
@@ -21,6 +23,18 @@ function ConnectionCardBase({
}: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: connection.id,
data: { type: "connection", connection },
});
const style: React.CSSProperties = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? "grabbing" : "default",
};
const tagMap = new Map(tags.map((t) => [t.id, t]));
const cardTags = connection.tag_ids
.map((id) => tagMap.get(id))
@@ -42,6 +56,8 @@ function ConnectionCardBase({
return (
<div
ref={setNodeRef}
style={style}
onClick={handleClick}
className={`relative group rounded-xl border transition-colors cursor-pointer ${
isSelected
@@ -49,6 +65,14 @@ function ConnectionCardBase({
: "bg-surface border-border hover:border-border-hover"
}`}
>
<div
{...listeners}
{...attributes}
className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
aria-label="Drag to move connection"
>
<GripVertical size={14} className="text-text-muted" />
</div>
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
@@ -58,18 +82,21 @@ function ConnectionCardBase({
<div className="font-semibold truncate text-text">
{connection.name}
</div>
<div className="text-xs text-text-muted">
{DB_LABELS[connection.db_type] ??
connection.db_type}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{DB_LABELS[connection.db_type] ??
connection.db_type}
</span>
{connection.environment && (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
>
{ENV_LABELS[connection.environment] ??
connection.environment}
</span>
)}
</div>
</div>
{connection.environment && (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
>
{ENV_LABELS[connection.environment] ?? connection.environment}
</span>
)}
</div>
<div className="text-xs text-text-muted mb-2 font-mono truncate">
{hostLabel}
+101 -64
View File
@@ -1,11 +1,102 @@
import type { Connection, Folder, Tag } from "../../lib/types";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
import { getChildFolders } from "../../lib/utils";
import { useUiStore } from "../../stores/uiStore";
import { TagBadge } from "../tags/TagBadge";
interface DroppableFolderCardProps {
folder: Folder;
isSelected: boolean;
count: number;
subfolderCount: number;
folderTags: Tag[];
onFolderClick: (id: string) => void;
onToggleSelection: (id: string) => void;
}
function DroppableFolderCard({
folder,
isSelected,
count,
subfolderCount,
folderTags,
onFolderClick,
onToggleSelection,
}: DroppableFolderCardProps) {
const { setNodeRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div
ref={setNodeRef}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<button
onClick={() => onFolderClick(folder.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{folder.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 && subfolderCount > 0 && " · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelection(folder.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
);
}
interface ConnectionGridProps {
connections: Connection[];
tags: Tag[];
@@ -118,72 +209,18 @@ export function ConnectionGrid({
const tagMap = new Map(tags.map((t) => [t.id, t]));
const folderTags = f.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as import("../../lib/types").Tag[];
.filter(Boolean) as Tag[];
return (
<div
<DroppableFolderCard
key={f.id}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
}`}
>
<button
onClick={() => handleFolderClick(f.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{f.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 &&
subfolderCount > 0 &&
" · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleItemSelection(f.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
folder={f}
isSelected={isSelected}
count={count}
subfolderCount={subfolderCount}
folderTags={folderTags}
onFolderClick={handleFolderClick}
onToggleSelection={toggleItemSelection}
/>
);
})}
{directConnections.map((c) => (
+222
View File
@@ -0,0 +1,222 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const FORMAT_OPTIONS = [
{ value: "plain", label: "Plain SQL" },
{ value: "custom", label: "Custom Archive" },
{ value: "tar", label: "Tarball" },
{ value: "directory", label: "Directory" },
];
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${format === "custom" ? "dump" : format === "plain" ? "sql" : "tar"}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
try {
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
notify("Backup completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Backup failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
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">Backup Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_dump not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Format selector */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<Select
value={format}
onChange={(v) => setFormat(v as BackupFormat)}
options={FORMAT_OPTIONS}
/>
</div>
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Output File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* No owner toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={noOwner}
onChange={(e) => setNoOwner(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
No Owner (--no-owner flag)
</label>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="dump"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartBackup} disabled={running || !filePath}>
{running ? "Backing up..." : "Start Backup"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:")) {
const [, ...rest] = msg.split("pg_dump:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions for the output path.`;
}
return msg;
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Download, FolderOpen, HardDrive } from "lucide-react";
import { save } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupPageProps {
connectionId: string;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupPage({ connectionId }: BackupPageProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
// Track our job ID so we only react to jobs we started
const pendingJobRef = useRef<string | null>(null);
// React to job completion/failure via store events
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Backup completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Backup failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
const handlePickFile = useCallback(async () => {
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${
format === "custom"
? "dump"
: format === "plain"
? "sql"
: "tar"
}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
pendingJobRef.current = jobId;
try {
// pgDump returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
} catch (e) {
// If the command itself fails (e.g. connection not found),
// the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted">
Create a database backup via pg_dump
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_dump not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{/* Output file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Output File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FolderOpen size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* No-owner toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noOwner}
onChange={(e) =>
setNoOwner(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartBackup}
disabled={isRunning || !filePath}
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { Button } from "../ui/Button";
interface BackupProgressProps {
progress: number;
jobType: string;
status: "running" | "completed" | "failed" | "cancelled";
errorMessage?: string | null;
onCancel?: () => void;
}
export function BackupProgress({
progress,
jobType,
status,
errorMessage,
onCancel,
}: BackupProgressProps) {
const isRunning = status === "running";
return (
<div data-testid="backup-progress" className="w-full space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text capitalize">
{jobType}
</span>
<span className="text-xs text-text-muted">
{status === "running" && `In progress...`}
{status === "completed" && "Completed"}
{status === "failed" && "Failed"}
{status === "cancelled" && "Cancelled"}
</span>
</div>
{isRunning && onCancel && (
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
</div>
{/* Progress bar */}
<div className="relative w-full h-2 bg-surface-raised rounded-full overflow-hidden">
<div
data-testid="progress-bar-fill"
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-300 ${
status === "failed" ? "bg-red-500" : "bg-accent"
}`}
style={{
width: `${Math.min(100, Math.max(0, progress))}%`,
}}
/>
</div>
{/* Error message */}
{errorMessage && status === "failed" && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
</div>
);
}
@@ -1,79 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataGrid } from "./DataGrid";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { QueryResult } from "../../lib/types";
const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
rows: [
[1, "Alice", "alice@example.com"],
[2, "Bob", null],
],
total_rows: 2, page: 1, page_size: 50,
};
describe("DataGrid", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no active tab", () => {
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument();
});
it("shows loading state", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabLoading(tabId, true);
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("shows error message in red", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabError(tabId, "Connection failed");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
const error = screen.getByText(/Connection failed/i);
expect(error).toBeInTheDocument();
expect(error).toHaveClass("text-red-500");
});
it("shows loading state when first opening a tab", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders column headers and row data when loaded", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("NULL")).toBeInTheDocument();
});
it("renders NULL values as italic muted text", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
const nullCell = screen.getByText("NULL");
expect(nullCell).toHaveClass("italic");
expect(nullCell).toHaveClass("text-text-muted");
});
});
-339
View File
@@ -1,339 +0,0 @@
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>
);
}
@@ -1,8 +1,16 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => {},
}),
}));
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.setState({
+564 -376
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
import { DataGrid } from "./DataGrid";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
@@ -14,395 +15,582 @@ import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
export interface DbViewerScreenProps {
connectionId: string;
onHome: () => void;
onSettings: () => void;
connectionId: string;
onHome: () => void;
onSettings: () => void;
}
// ─── client-side filter/sort helpers ─────────────────────
export function DbViewerScreen({
connectionId,
onHome,
onSettings,
}: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<string>("db-viewer");
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState("");
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);
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
const setSortRules = useDbViewerStore((s) => s.setSortRules);
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
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;
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
setDefaultPageSize(settings.table_page_size);
}
}
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;
};
}, [settings?.table_page_size, setDefaultPageSize]);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
);
// 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 }];
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
});
}, [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]);
// Derive per-tab toolbar state from active tab
const filterRules = activeTab?.filterRules ?? [];
const sortRules = activeTab?.sortRules ?? [];
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
// 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 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 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 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,
tab.filterRules,
tab.sortRules,
);
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],
);
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]);
// 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]);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
else if (view === "settings") onSettings();
},
[onHome, onSettings],
);
// 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 (activeTab.smartSortApplied) return;
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
const cols = activeTab.data.columns;
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())}
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) {
setSmartSortApplied(activeTab.id);
setSortRules(activeTab.id, [
{ 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;
const currentRules = activeTab.filterRules ?? [];
const exists = currentRules.some(
(r) => r.column === column && r.value === value,
);
if (exists) return;
setFilterRules(activeTab.id, [
...currentRules,
{
id: crypto.randomUUID(),
column,
operator: "contains" as const,
value,
},
]);
}, [activeTab?.columnFilter, activeTab?.id, activeTab?.filterRules, setFilterRules]);
// 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 ?? [];
// Data is already filtered and sorted server-side; no client-side transform needed.
const processedRows = rawRows;
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();
else setCurrentView(view);
},
[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={currentView}
onNavigate={handleNavigate}
/>
)}
<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 className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
{currentView === "db-viewer" ? (
<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-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
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) =>
toggleHiddenColumn(activeTab!.id, col)
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={(rules) =>
setFilterRules(activeTab!.id, rules)
}
sortRules={sortRules}
onSortChange={(rules) =>
setSortRules(activeTab!.id, rules)
}
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">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(rowIndex))
next.delete(rowIndex);
else next.add(rowIndex);
return next;
});
}}
onToggleAll={() => {
setSelectedRows((prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length > 0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) => i,
),
);
});
}}
/>
</div>
</div>
</div>
) : currentView === "functions" ? (
<ObjectExplorerPage
key="functions"
type="functions"
connectionId={connectionId}
/>
) : currentView === "triggers" ? (
<ObjectExplorerPage
key="triggers"
type="triggers"
connectionId={connectionId}
/>
) : currentView === "sequences" ? (
<ObjectExplorerPage
key="sequences"
type="sequences"
connectionId={connectionId}
/>
) : currentView === "enums" ? (
<ObjectExplorerPage key="enums" type="enums" connectionId={connectionId} />
) : currentView === "extensions" ? (
<ObjectExplorerPage
key="extensions"
type="extensions"
connectionId={connectionId}
/>
) : currentView === "backup" ? (
<BackupPage connectionId={connectionId} />
) : currentView === "restore" ? (
<RestorePage connectionId={connectionId} />
) : currentView === "sync" ? (
<SyncPage />
) : null}
{currentView === "db-viewer" && <ChangesQueuePanel />}
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</div>
<ChangesQueuePanel />
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</TooltipProvider>
);
}
</TooltipProvider>
);
}
+89 -44
View File
@@ -1,57 +1,102 @@
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
import {
ArrowLeftRight,
Database,
Download,
FunctionSquare,
GitBranch,
Grid2x2,
Home,
ListOrdered,
Puzzle,
Settings,
Tag,
Upload,
} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
currentView: string;
onNavigate: (view: string) => void;
}
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
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 },
];
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",
icon: <FunctionSquare size={20} />,
},
{ id: "triggers", label: "Triggers", icon: <GitBranch size={20} /> },
{
id: "sequences",
label: "Sequences",
icon: <ListOrdered size={20} />,
},
{ id: "enums", label: "Enums", icon: <Tag size={20} /> },
{ id: "extensions", label: "Extensions", icon: <Puzzle size={20} /> },
{ id: "backup", label: "Backup", icon: <Download size={20} /> },
{ id: "restore", label: "Restore", icon: <Upload size={20} /> },
{
id: "sync",
label: "DB Sync",
icon: <ArrowLeftRight size={20} />,
},
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
];
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";
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 (
<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>
<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>
);
}
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>
);
}
}
+209 -178
View File
@@ -1,4 +1,12 @@
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
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";
@@ -6,190 +14,213 @@ 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,
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;
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);
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]);
// 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]);
// 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]);
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); };
}, []);
// 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]);
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"
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"}`}
>
{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 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>
</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>
);
}
);
}
File diff suppressed because it is too large Load Diff
+329
View File
@@ -0,0 +1,329 @@
import { useEffect, useState, useMemo } from "react";
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
interface ObjectTreeProps {
type: ObjectType;
connectionId: string;
}
const TYPE_LABELS: Record<ObjectType, string> = {
functions: "functions",
triggers: "triggers",
sequences: "sequences",
enums: "enums",
extensions: "extensions",
};
const ICONS: Record<ObjectType, React.ReactNode> = {
functions: <FunctionSquare size={14} className="text-text-muted shrink-0" />,
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
};
function SourceCode({ source }: { source: string }) {
const [expanded, setExpanded] = useState(false);
const maxLen = 500;
const truncated = source.length > maxLen && !expanded;
const display = truncated ? source.slice(0, maxLen) : source;
return (
<div className="mt-1">
<pre className="text-xs text-text-muted bg-surface-raised rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
{display}
{truncated && <span className="text-text-subtle">...</span>}
</pre>
{source.length > maxLen && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="text-xs text-accent hover:underline mt-1"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const functions = useDbViewerStore((s) => s.functions);
const triggers = useDbViewerStore((s) => s.triggers);
const sequences = useDbViewerStore((s) => s.sequences);
const enums = useDbViewerStore((s) => s.enums);
const extensions = useDbViewerStore((s) => s.extensions);
const setFunctions = useDbViewerStore((s) => s.setFunctions);
const setTriggers = useDbViewerStore((s) => s.setTriggers);
const setSequences = useDbViewerStore((s) => s.setSequences);
const setEnums = useDbViewerStore((s) => s.setEnums);
const setExtensions = useDbViewerStore((s) => s.setExtensions);
const [loading, setLoading] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
// Determine which store accessors to use
const data = useMemo(() => {
switch (type) {
case "functions": return functions;
case "triggers": return triggers;
case "sequences": return sequences;
case "enums": return enums;
case "extensions": return extensions;
}
}, [type, functions, triggers, sequences, enums, extensions]);
const setter = useMemo(() => {
switch (type) {
case "functions": return setFunctions;
case "triggers": return setTriggers;
case "sequences": return setSequences;
case "enums": return setEnums;
case "extensions": return setExtensions;
}
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
// Fetch on mount if not in store
useEffect(() => {
if (data !== null) return;
let cancelled = false;
setLoading(true);
const fetchData = async () => {
try {
if (type === "extensions") {
const result = await cmd.getExtensions(connectionId);
if (!cancelled) (setExtensions as (v: ExtensionInfo[]) => void)(result);
} else if (type === "functions") {
const result = await cmd.getFunctions(connectionId, currentSchema ?? undefined);
if (!cancelled) (setFunctions as (v: FunctionInfo[]) => void)(result);
} else if (type === "triggers") {
const result = await cmd.getTriggers(connectionId, currentSchema ?? undefined);
if (!cancelled) (setTriggers as (v: TriggerInfo[]) => void)(result);
} else if (type === "sequences") {
const result = await cmd.getSequences(connectionId, currentSchema ?? undefined);
if (!cancelled) (setSequences as (v: SequenceInfo[]) => void)(result);
} else if (type === "enums") {
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
}
} catch {
// Silently fail — store remains null, we show the empty state
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
const q = search.toLowerCase().trim();
const list = useMemo(() => {
if (!data) return [];
if (!q) return data;
return data.filter((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
return name.toLowerCase().includes(q);
});
}, [data, q]);
const toggle = (key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const icon = ICONS[type];
const pluralLabel = TYPE_LABELS[type];
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="px-3 py-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder={`Search ${pluralLabel}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2 py-1 text-xs bg-surface-raised border border-border rounded text-text placeholder:text-text-subtle focus:outline-none focus:border-accent/50"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto py-2" style={{ overscrollBehavior: "none" }}>
{loading && (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<div className="w-4 h-4 border-2 border-text-muted border-t-accent rounded-full animate-spin mr-2" />
Loading {pluralLabel}...
</div>
)}
{!loading && list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`}
</div>
)}
{!loading && list.map((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
const key = name;
const isExpanded = expandedKeys.has(key);
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => toggle(key)}
>
<button
type="button"
aria-label={isExpanded ? "Collapse" : "Expand"}
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>
{icon}
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{name}
</span>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{type === "functions" && (() => {
const f = item as FunctionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Returns:</span> {f.return_type}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Language:</span> {f.language}
</div>
{f.argument_names.length > 0 && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Args:</span>{" "}
{f.argument_names.map((a, i) => (
<span key={i}>
{f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && (
<span className="text-amber-400">{f.argument_modes[i]} </span>
)}
{a} <span className="text-text-subtle">({f.argument_types?.[i] || "unknown"})</span>
{i < f.argument_names.length - 1 && ", "}
</span>
))}
</div>
)}
{f.source && <SourceCode source={f.source} />}
</>
);
})()}
{type === "triggers" && (() => {
const t = item as TriggerInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Table:</span> {t.table_schema}.{t.table_name}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Event:</span> {t.event_manipulation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Timing:</span> {t.action_timing}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Orientation:</span> {t.action_orientation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Enabled:</span> {t.enabled}
</div>
{t.action_statement && <SourceCode source={t.action_statement} />}
</>
);
})()}
{type === "sequences" && (() => {
const s = item as SequenceInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Current:</span> {s.current_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Increment:</span> {s.increment}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Min:</span> {s.min_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Max:</span> {s.max_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Start:</span> {s.start_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Cycle:</span> {s.cycle ? "Yes" : "No"}
</div>
</>
);
})()}
{type === "enums" && (() => {
const e = item as EnumInfo;
return (
<div className="flex flex-wrap gap-1 mt-1">
{e.labels.map((label) => (
<span
key={label}
className="inline-block px-1.5 py-0.5 text-[10px] rounded bg-surface-raised text-text-muted border border-border"
>
{label}
</span>
))}
</div>
);
})()}
{type === "extensions" && (() => {
const e = item as ExtensionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Version:</span> {e.version}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Schema:</span> {e.schema}
</div>
{e.comment && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Comment:</span> {e.comment}
</div>
)}
</>
);
})()}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface RestoreDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProps) {
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const picked = await openDialog({
multiple: false,
filters: [{ name: "Backup Files", extensions: ["dump", "sql", "tar", "custom", "gz"] }],
});
if (picked && typeof picked === "string") setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, []);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
notify("Restore completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Restore failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, clean, schema, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !running;
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">Restore Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_restore not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Backup File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Format */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">Directory</option>
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={clean}
onChange={(e) => setClean(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
Clean (DROP before CREATE)
</label>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="restore"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartRestore} disabled={!canStart}>
{running ? "Restoring..." : "Start Restore"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_restore:")) {
const [, ...rest] = msg.split("pg_restore:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
+311
View File
@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function RestorePage({ connectionId }: RestorePageProps) {
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Restore completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Restore failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [connectionId]);
const handlePickFile = useCallback(async () => {
const picked = await open({
multiple: false,
filters: [
{
name: "Backup Files",
extensions: ["dump", "sql", "tar", "custom", "gz"],
},
],
});
if (picked && typeof picked === "string") setFilePath(picked);
}, []);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
pendingJobRef.current = jobId;
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !isRunning;
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted">
Restore a database from a backup file
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_restore...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_restore not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The PostgreSQL client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
{/* Backup file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FileSearch size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
</label>
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface SyncDialogProps {
open: boolean;
onClose: () => void;
}
export function SyncDialog({ open, onClose }: SyncDialogProps) {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
setRunning(true);
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
notify("Sync completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Sync failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify, onClose]);
const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const canStart = sourceConnectionId && targetConnectionId && confirmed && !running;
const postgresqlConnections = connections.filter((c) => c.db_type === "postgresql");
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">Sync Databases</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump/pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">PostgreSQL tools not found</p>
<p className="text-amber-200/80 text-xs">
Both pg_dump and pg_restore are required for database sync.
</p>
{!toolStatus?.pg_dump_found && (
<p className="text-amber-200/80 text-xs">pg_dump is missing.</p>
)}
{!toolStatus?.pg_restore_found && (
<p className="text-amber-200/80 text-xs">pg_restore is missing.</p>
)}
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Source connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Source Connection</label>
<select
value={sourceConnectionId}
onChange={(e) => setSourceConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select source...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === targetConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Target connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Target Connection</label>
<select
value={targetConnectionId}
onChange={(e) => setTargetConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select target...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === sourceConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="sync"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartSync} disabled={!canStart}>
{running ? "Syncing..." : "Start Sync"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:") || msg.includes("pg_restore:")) {
const parts = msg.split(/pg_(dump|restore):/);
return parts[parts.length - 1]?.trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
+327
View File
@@ -0,0 +1,327 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowLeftRight, Database } from "lucide-react";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Sync completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Sync failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() =>
setToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
}),
)
.finally(() => setCheckingTools(false));
}, []);
// Fetch schemas from the source connection when it changes
useEffect(() => {
if (!sourceConnectionId) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [sourceConnectionId]);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
pendingJobRef.current = jobId;
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const postgresqlConnections = connections.filter(
(c) => c.db_type === "postgresql",
);
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted">
Transfer data between PostgreSQL databases via pipe
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
Checking for pg_dump / pg_restore...
</p>
</div>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Both pg_dump and pg_restore are required for
database sync.
</p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{!toolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li>
)}
{!toolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li>
)}
</ul>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Source & Target connection pickers */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Source
</label>
<select
value={sourceConnectionId}
onChange={(e) =>
setSourceConnectionId(
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select source...
</option>
{postgresqlConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
targetConnectionId
}
>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Target
</label>
<select
value={targetConnectionId}
onChange={(e) =>
setTargetConnectionId(
e.target.value,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select target...
</option>
{postgresqlConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id ===
sourceConnectionId
}
>
{c.name}
</option>
))}
</select>
</div>
</div>
{/* Schema (optional) */}
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text">
{postgresqlConnections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
</span>
<ArrowLeftRight
size={12}
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{postgresqlConnections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
</span>
</div>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartSync}
disabled={!canStart}
>
<ArrowLeftRight
size={14}
className="mr-1.5"
/>
{isRunning ? "Syncing..." : "Start Sync"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
+4 -7
View File
@@ -27,20 +27,17 @@ export function TabBar() {
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
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"
>
<span className="flex-1 text-left select-none">
{tab.table}
</button>
</span>
<button
type="button"
onClick={(e) => {
+2 -2
View File
@@ -17,7 +17,7 @@ const AUTO_REFRESH_OPTIONS = [
{ label: "5m", value: 300_000 },
] as const;
const PAGE_SIZES = [50, 100, 200] as const;
const PAGE_SIZES = [50, 100, 200, 500] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
@@ -560,7 +560,7 @@ export function TableControls({
};
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">
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{/* Insert Row */}
+141 -99
View File
@@ -8,107 +8,149 @@ 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 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 q = (searchQuery ?? "").toLowerCase().trim();
const filteredTables = (currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables).filter((t) => !q || t.name.toLowerCase().includes(q));
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 toggle = async (key: string, schema: string, tableName: string) => {
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>
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>
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
No tables
</div>
)}
</div>
);
})}
</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>
);
}
+8 -3
View File
@@ -1,9 +1,14 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { FolderTree } from "./FolderTree";
import type { Folder } from "../../lib/types";
function Wrapper({ children }: { children: React.ReactNode }) {
return <DndContext>{children}</DndContext>;
}
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "ClientA", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
@@ -11,7 +16,7 @@ const folders: Folder[] = [
describe("FolderTree", () => {
it("renders all folders", () => {
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />);
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />, { wrapper: Wrapper });
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("ClientA")).toBeInTheDocument();
});
@@ -19,7 +24,7 @@ describe("FolderTree", () => {
it("renders All Connections option that clears filter", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<FolderTree folders={folders} activeFolderId="f1" onSelect={fn} />);
render(<FolderTree folders={folders} activeFolderId="f1" onSelect={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
@@ -27,7 +32,7 @@ describe("FolderTree", () => {
it("selecting a folder calls onSelect with id", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<FolderTree folders={folders} activeFolderId={null} onSelect={fn} />);
render(<FolderTree folders={folders} activeFolderId={null} onSelect={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
+23 -5
View File
@@ -1,5 +1,6 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Folder as FolderIcon } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
interface FolderTreeProps {
folders: Folder[];
@@ -10,19 +11,31 @@ interface FolderTreeProps {
export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProps) {
const roots = folders.filter((f) => f.parent_id === null);
const childrenOf = (id: string) => folders.filter((f) => f.parent_id === id);
const { setNodeRef: setRootRef, isOver: isRootOver } = useDroppable({
id: "root",
});
const renderFolder = (folder: Folder, depth: number) => {
const FolderItem = ({ folder, depth }: { folder: Folder; depth: number }) => {
const isActive = activeFolderId === folder.id;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div key={folder.id}>
<button
ref={setDropRef}
onClick={() => onSelect(folder.id)}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FolderIcon size={14} /> {folder.name}
</button>
{childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))}
{childrenOf(folder.id).map((c) => (
<FolderItem key={c.id} folder={c} depth={depth + 1} />
))}
</div>
);
};
@@ -30,12 +43,17 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp
return (
<div className="space-y-0.5">
<button
ref={setRootRef}
onClick={() => onSelect(null)}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
} ${isRootOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<ChevronRight size={14} /> All Connections
</button>
{roots.map((r) => renderFolder(r, 0))}
{roots.map((r) => (
<FolderItem key={r.id} folder={r} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,245 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VirtualDataGrid } from "./VirtualDataGrid";
import type { ColumnInfo } from "../../lib/types";
const mockColumns: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
];
const mockRows: unknown[][] = [
[1, "Alice"],
[2, "Bob"],
];
/**
* Override `useVirtualizer` so virtual items are always rendered
* regardless of container dimensions in jsdom.
*/
const { mockGetVirtualItems, mockGetTotalSize, mockMeasureElement } = vi.hoisted(() => ({
mockGetVirtualItems: vi.fn(),
mockGetTotalSize: vi.fn(),
mockMeasureElement: vi.fn(),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: mockGetVirtualItems,
getTotalSize: mockGetTotalSize,
measureElement: mockMeasureElement,
}),
}));
describe("VirtualDataGrid", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders all rows when row count is small", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("renders column headers with type badges", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("name")).toBeInTheDocument();
expect(screen.getByText("int")).toBeInTheDocument();
});
it("renders NULL values in italic", () => {
const rows: unknown[][] = [[null, "HasNull"]];
mockGetTotalSize.mockReturnValue(rows.length * 36);
mockGetVirtualItems.mockReturnValue(
rows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={rows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText("NULL")).toBeInTheDocument();
expect(screen.getByText("NULL").className).toContain("italic");
});
it("renders empty state when no rows", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
/>,
);
expect(screen.getByText(/no rows/i)).toBeInTheDocument();
});
it("calls onToggleRow when checkbox clicked", () => {
let toggled = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} />);
const checkboxes = screen.getAllByRole("checkbox");
fireEvent.click(checkboxes[1]); // first row checkbox
expect(toggled).toBe(0);
});
it("renders FK cells with clickable underline styling", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
const fkCell = screen.getByText("42");
expect(fkCell.className).toContain("cursor-pointer");
expect(fkCell.className).toContain("underline");
});
it("renders JSON cells with preview label", () => {
const jsonCols: ColumnInfo[] = [
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
});
it("has resize handles on column headers", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[]} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />);
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
expect(handles.length).toBe(2); // one per visible column
});
it("renders 10000 rows without crashing (virtualization)", () => {
const bigRows: unknown[][] = Array.from({ length: 10000 }, (_, i) => [i, `Name${i}`]);
mockGetTotalSize.mockReturnValue(10000 * 36);
mockGetVirtualItems.mockReturnValue(
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={bigRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
});
it("shows select-all as checked when all rows selected", () => {
const allSelected = new Set([0, 1]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={allSelected}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
expect(selectAll.checked).toBe(true);
});
it("hides columns in hiddenColumns set", () => {
const hidden = new Set(["name"]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
hiddenColumns={hidden} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}} />,
);
expect(screen.queryByText("name")).not.toBeInTheDocument();
expect(screen.getByText("id")).toBeInTheDocument();
});
});
+319
View File
@@ -0,0 +1,319 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Key, Braces } from "lucide-react";
import type { ColumnInfo } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
interface VirtualDataGridProps {
connectionId: string;
schema: string;
rows: unknown[][];
columns: ColumnInfo[];
hiddenColumns: Set<string>;
selectedRows: Set<number>;
onToggleRow: (rowIndex: number) => void;
onToggleAll: () => void;
}
const ROW_HEIGHT = 36;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
export function VirtualDataGrid({
connectionId,
schema,
rows,
columns,
hiddenColumns,
selectedRows,
onToggleRow,
onToggleAll,
}: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
}
}, [selectedRows.size, rows.length]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
});
// ── column widths ─────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const getWidth = useCallback(
(colName: string) => colWidths[colName] ?? DEFAULT_COL_WIDTH,
[colWidths],
);
// Total width for horizontal scroll support
const totalWidth = 40 + visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
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) => {
const current = resizeRef.current;
if (!current) return;
const delta = ev.clientX - current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, current.startWidth + delta));
setColWidths((prev) => ({ ...prev, [current.col]: next }));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[getWidth],
);
const resetWidth = useCallback((colName: string) => {
setColWidths((prev) => {
const next = { ...prev };
delete next[colName];
return next;
});
}, []);
// ── FK preview popover state ──────────────────────────
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
const handleFkClick = useCallback(
(col: ColumnInfo, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[connectionId, schema],
);
// ── JSON popover state ────────────────────────────────
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── cell renderer (shared between header sizing and body) ──
const renderCell = useCallback(
(col: ColumnInfo, row: unknown[], _rowIndex: number) => {
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 (
<div
key={col.name}
className={`px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
onKeyDown={
isFk || isJson
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isFk) handleFkClick(col, cell, e as any);
else if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
}
}
: undefined
}
style={{ width: getWidth(col.name), flexShrink: 0 }}
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
}
>
{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>
);
},
[columns, getWidth, handleFkClick],
);
return (
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
{/* ── sticky header ── */}
<div className="sticky top-0 z-10">
<div className="flex items-center border-b border-border bg-canvas" style={{ minWidth: totalWidth }}>
<div style={{ width: 40, minWidth: 40 }} className="px-2 py-2 flex items-center justify-center border-r border-border self-stretch">
<input
ref={selectAllRef}
type="checkbox"
checked={allSelected}
onChange={onToggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
{visibleColumns.map((col) => (
<div
key={col.name}
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border last:border-r-0 self-stretch"
style={{ width: getWidth(col.name), flexShrink: 0 }}
>
<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="text-[10px] text-text-muted/50 shrink-0" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
<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={() => resetWidth(col.name)}
/>
</div>
))}
</div>
</div>
{/* ── virtual body ── */}
{rows.length === 0 ? (
<div className="py-12 text-center text-sm text-text-muted">
No rows in result set
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
const isSelected = selectedRows.has(virtualRow.index);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : ""
} hover:bg-surface/50`}
style={{
position: "absolute",
top: 0,
left: 0,
minWidth: totalWidth,
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(virtualRow.index)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
</div>
);
})}
</div>
)}
{/* 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>
);
}
+62 -15
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
import { useFilteredConnections } from "../../hooks/useConnections";
@@ -7,6 +8,7 @@ import { SearchBar } from "../search/SearchBar";
import type { SearchBarHandle } from "../search/SearchBar";
import { ActionRow } from "./ActionRow";
import { ConnectionGrid } from "../connections/ConnectionGrid";
import { ConnectionCard } from "../connections/ConnectionCard";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog";
@@ -36,6 +38,7 @@ export function HomeScreen() {
type: "folder" | "selected";
folder?: Folder;
} | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const searchRef = useRef<SearchBarHandle>(null);
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const setPrefilledConnectionString = useUiStore(
@@ -55,6 +58,29 @@ export function HomeScreen() {
setActiveView("new-connection");
};
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const connectionId = active.id as string;
let folderId: string | null = null;
if (over.id === "root") {
folderId = null;
} else if (typeof over.id === "string" && over.id.startsWith("folder-")) {
const folderData = (over.data.current as any)?.folder;
folderId = folderData?.id ?? null;
} else {
return; // dropped on something unexpected
}
try {
await useConnectionStore.getState().moveConnection(connectionId, folderId);
} catch {
// Error handling in store; no additional action needed here
}
}, []);
// Cmd+K to focus search (configurable in Settings → Shortcuts)
useShortcut("command_palette", () => {
searchRef.current?.focus();
@@ -140,20 +166,41 @@ export function HomeScreen() {
visibleItemIds={visibleItemIds}
/>
</div>
<ConnectionGrid
connections={connections}
tags={tags}
folders={folders}
activeFolderId={activeFolderId}
onFolderSelect={setActiveFolderId}
hasSearch={searchQuery.length > 0}
onTagToggle={toggleTag}
onOpenDbViewer={handleOpenDbViewer}
onEditFolder={(f) => setEditFolder(f)}
onDeleteFolder={(f) =>
setConfirmDelete({ type: "folder", folder: f })
}
/>
<DndContext
onDragStart={(event) => setActiveDragId(event.active.id as string)}
onDragEnd={async (event) => {
setActiveDragId(null);
await handleDragEnd(event);
}}
collisionDetection={closestCenter}
>
<ConnectionGrid
connections={connections}
tags={tags}
folders={folders}
activeFolderId={activeFolderId}
onFolderSelect={setActiveFolderId}
hasSearch={searchQuery.length > 0}
onTagToggle={toggleTag}
onOpenDbViewer={handleOpenDbViewer}
onEditFolder={(f) => setEditFolder(f)}
onDeleteFolder={(f) =>
setConfirmDelete({ type: "folder", folder: f })
}
/>
<DragOverlay dropAnimation={null}>
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
<div className="opacity-80">
<ConnectionCard
connection={connections.find((c) => c.id === activeDragId)!}
tags={tags}
onTagToggle={() => {}}
onOpenDbViewer={() => {}}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
<CreateFolderDialog
open={folderDialogOpen}
parentOptions={folders}