feat: initial Gridline implementation (#1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* deps: install motion for animations

* feat(types): add system theme option

* feat(settings): default stored theme to system

* feat(ui): add Toggle primitive

* feat(ui): add Select primitive

* feat(ui): add SettingsRow and SettingsSection primitives

* fix(ui): improve SettingsRow and SettingsSection quality

* feat(ui): add ThemePicker primitive

* feat(ui): add AnimatedModal primitive with motion

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

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

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

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

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

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

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

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

* fix(ui): disable overscroll on both axes

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

* chore: ignore .worktrees directory

* refactor: centralize db type icons and labels

* feat: add connection string parser and detection

* test: add edge cases for connection string parser

* fix: preserve absolute paths in SQLite connection strings

* types: add new connection form fields

* feat: add folder path label helper

* test: cover missing folder in path label helper

* feat: add stub connection string parse and test commands

* feat: add stub test connection command

* feat: add new connection form primitives

* feat: add connection form shell

* feat: add shared form type and simple connection form

* fix: keep SimpleConnectionForm fully controlled

* style: format and tighten types for simple connection form

* feat: add detailed connection form

* fix: export DetailedConnectionFormProps and assert port update

* feat: add new connection screen container

* fix: clean up useEffect dependencies in new connection screen

* fix: address quality feedback on new connection screen

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

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

* test: add home search URL shortcut integration test

* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown

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

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

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

* feat: home screen redesign and connection form updates

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: tooltip UI primitive (Task 4a)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: rename sidebar label from DB Viewer to Explorer

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

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

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

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

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

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

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

* style: monospace font for table data cells

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

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

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

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

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

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

* feat: selected row count in toolbar with clear button

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add comprehensive implementation status to AGENTS.md

- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with /🟡/ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
2026-07-28 19:01:00 +08:00
committed by GitHub
parent 346372c552
commit d872e429e4
186 changed files with 23311 additions and 194 deletions
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionCard } from "./ConnectionCard";
import type { Connection, Tag } from "../../lib/types";
import { useUiStore } from "../../stores/uiStore";
const tags: Tag[] = [
{ id: "t1", name: "production", color: "#ef4444", created_at: "" },
{ id: "t2", name: "primary", color: "#3b82f6", created_at: "" },
];
const conn: Connection = {
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
port: 5432, username: null, folder_id: null, keychain_ref: null,
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
};
describe("ConnectionCard", () => {
beforeEach(() => {
useUiStore.setState({ selectedItemIds: [] });
});
it("renders name and host", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
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} />);
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
});
it("renders tag badges", () => {
render(<ConnectionCard connection={conn} tags={tags} />);
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("primary")).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} />);
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} />);
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} />);
await user.click(screen.getByText("Prod DB"));
expect(fn).toHaveBeenCalledWith(conn.id);
});
it("toggles selection on single click when something is already selected", async () => {
const user = userEvent.setup();
useUiStore.setState({ selectedItemIds: ["other-id"] });
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
await user.click(screen.getByText("Prod DB"));
// Should NOT open — should toggle selection instead
expect(fn).not.toHaveBeenCalled();
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
});
});
@@ -0,0 +1,100 @@
import { memo } from "react";
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 { useUiStore } from "../../stores/uiStore";
interface ConnectionCardProps {
connection: Connection;
tags: Tag[];
onTagToggle?: (id: string) => void;
onOpenDbViewer?: (connectionId: string) => void;
}
function ConnectionCardBase({
connection,
tags,
onTagToggle,
onOpenDbViewer,
}: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const tagMap = new Map(tags.map((t) => [t.id, t]));
const cardTags = connection.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as Tag[];
const hostLabel = connection.port
? `${connection.host}:${connection.port}`
: connection.host;
const isSelected = selectedItemIds.includes(connection.id);
const handleClick = () => {
if (selectedItemIds.length > 0) {
// Something already selected — toggle this item in the selection
toggleItemSelection(connection.id);
} else {
// Nothing selected — open the connection
onOpenDbViewer?.(connection.id);
}
};
return (
<div
onClick={handleClick}
className={`relative group rounded-xl border transition-colors cursor-pointer ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
}`}
>
<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">
{DB_ICONS[connection.db_type] ?? "❓"}
</div>
<div className="flex-1 min-w-0">
<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>
</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}
</div>
<div className="flex gap-1 flex-wrap">
{cardTags.map((t) => (
<TagBadge key={t.id} tag={t} onToggle={onTagToggle} />
))}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
toggleItemSelection(connection.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>
);
}
export const ConnectionCard = memo(ConnectionCardBase);
@@ -0,0 +1,70 @@
import { ChevronLeft } from "lucide-react";
import { Button } from "../ui/Button";
import type { NewConnectionMode } from "../../lib/types";
import type { ReactNode } from "react";
interface ConnectionFormShellProps {
mode: NewConnectionMode;
onBack: () => void;
onTest: () => void;
onSave: () => void;
onToggleMode: () => void;
testLoading?: boolean;
saveLoading?: boolean;
children: ReactNode;
}
export function ConnectionFormShell({
mode,
onBack,
onTest,
onSave,
onToggleMode,
testLoading,
saveLoading,
children,
}: ConnectionFormShellProps) {
return (
<div className="min-h-screen bg-canvas">
<div className="max-w-lg mx-auto p-8">
<Button
variant="ghost"
onClick={onBack}
className="mb-4 -ml-3 justify-start gap-1 px-3"
>
<ChevronLeft size={16} /> Back
</Button>
<div className="space-y-4">{children}</div>
<div className="flex gap-3 mt-8">
<Button
variant="secondary"
onClick={onTest}
disabled={testLoading}
className="flex-1"
>
{testLoading ? "Testing..." : "Test Connection"}
</Button>
<Button
onClick={onSave}
disabled={saveLoading}
className="flex-1"
>
{saveLoading ? "Saving..." : "Save Connection"}
</Button>
</div>
<button
type="button"
onClick={onToggleMode}
className="w-full mt-4 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
>
{mode === "simple"
? "Configure manually instead →"
: "← Back to connection string"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionGrid } from "./ConnectionGrid";
import type { Connection, Folder } from "../../lib/types";
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id, keychain_ref: null, tag_ids: [],
created_at: "", updated_at: "",
});
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f3", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
];
describe("ConnectionGrid", () => {
it("renders empty state when no connections and no folders", () => {
render(<ConnectionGrid connections={[]} tags={[]} />);
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
});
it("renders cards for each connection", () => {
const conns = [makeConn("1"), makeConn("2")];
render(<ConnectionGrid connections={conns} tags={[]} />);
expect(screen.getByText("Conn 1")).toBeInTheDocument();
expect(screen.getByText("Conn 2")).toBeInTheDocument();
});
it("renders no-results state when filtered empty", () => {
render(<ConnectionGrid connections={[]} tags={[]} hasSearch />);
expect(screen.getByText(/no connections match/i)).toBeInTheDocument();
});
it("renders only top-level folders at root", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
expect(screen.queryByText("Client A")).not.toBeInTheDocument();
});
it("renders only children of active folder", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" />);
expect(screen.getByText("Client A")).toBeInTheDocument();
expect(screen.queryByText("Personal")).not.toBeInTheDocument();
});
it("calls onFolderSelect with folder id on click", async () => {
const fn = vi.fn();
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} onFolderSelect={fn} />);
await userEvent.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
it("breadcrumb navigates to root", async () => {
const fn = vi.fn();
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" onFolderSelect={fn} />);
await userEvent.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
it("shows folder cards", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
});
@@ -0,0 +1,202 @@
import type { Connection, Folder, Tag } from "../../lib/types";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
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 ConnectionGridProps {
connections: Connection[];
tags: Tag[];
folders?: Folder[];
activeFolderId?: string | null;
onFolderSelect?: (id: string | null) => void;
hasSearch?: boolean;
onTagToggle?: (id: string) => void;
onEditFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onOpenDbViewer?: (connectionId: string) => void;
}
export function ConnectionGrid({
connections,
tags,
folders = [],
activeFolderId = null,
onFolderSelect,
hasSearch = false,
onTagToggle,
onEditFolder,
onDeleteFolder,
onOpenDbViewer,
}: ConnectionGridProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const clearSelection = useUiStore((s) => s.clearSelection);
const currentFolderId =
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
? activeFolderId
: null;
const visibleFolders = hasSearch
? []
: getChildFolders(folders, currentFolderId);
const directConnections = connections.filter(
(c) => c.folder_id === currentFolderId,
);
const hasItems = visibleFolders.length > 0 || directConnections.length > 0;
const isSelecting = selectedItemIds.length > 0;
const activeFolder = currentFolderId
? (folders.find((f) => f.id === currentFolderId) ?? null)
: null;
const handleFolderClick = (folderId: string) => {
if (isSelecting) {
toggleItemSelection(folderId);
} else {
onFolderSelect?.(folderId);
}
};
const handleBreadcrumbNavigate = (folderId: string | null) => {
clearSelection();
onFolderSelect?.(folderId);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FolderBreadcrumb
folders={folders}
activeFolderId={currentFolderId}
onNavigate={handleBreadcrumbNavigate}
/>
{activeFolder && (
<div className="flex items-center gap-1">
<button
onClick={() => onEditFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Pencil size={12} /> Edit
</button>
<button
onClick={() => onDeleteFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs !text-red-400 hover:!text-red-300 transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Trash2 size={12} /> Delete
</button>
</div>
)}
</div>
{!hasItems ? (
<div className="text-center w-full py-16 text-text-muted">
{hasSearch
? "No connections match your search."
: activeFolderId
? "This folder is empty. Add a connection or subfolder."
: "No connections yet. Create one to get started."}
</div>
) : (
<div
className="grid gap-3"
style={{
gridTemplateColumns:
"repeat(auto-fill, minmax(260px, 1fr))",
}}
>
{visibleFolders.map((f) => {
const isSelected = selectedItemIds.includes(f.id);
const count = directConnections.filter(
(c) => c.folder_id === f.id,
).length;
const subfolderCount = getChildFolders(
folders,
f.id,
).length;
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[];
return (
<div
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>
);
})}
{directConnections.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
tags={tags}
onTagToggle={onTagToggle}
onOpenDbViewer={onOpenDbViewer}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,24 @@
import { forwardRef } from "react";
import type { KeyboardEvent } from "react";
interface ConnectionStringInputProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export const ConnectionStringInput = forwardRef<HTMLInputElement, ConnectionStringInputProps>(
function ConnectionStringInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
return (
<input
ref={ref}
className={`w-full rounded-lg bg-surface border border-border px-4 py-3 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer font-mono ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
/>
);
}
);
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import type { ConnectionFormData } from "./connectionFormData";
import type { DetailedConnectionFormProps } from "./DetailedConnectionForm";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: false,
};
function StatefulForm(
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
return (
<DetailedConnectionForm
{...props}
form={form}
onChange={(updates) => {
setForm((prev) => ({ ...prev, ...updates }));
props.onChange?.(updates);
}}
/>
);
}
describe("DetailedConnectionForm", () => {
it("updates host and port", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm onChange={onChange} />);
await user.type(screen.getByLabelText(/host/i), "localhost");
await user.clear(screen.getByLabelText(/port/i));
await user.type(screen.getByLabelText(/port/i), "5432");
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
});
});
@@ -0,0 +1,56 @@
import { useState } from "react";
import { GeneralTab } from "./GeneralTab";
import { SshSslTab } from "./SshSslTab";
import { TagsEnvTab } from "./TagsEnvTab";
import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general");
return (
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button
type="button"
onClick={() => setActiveTab("general")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
General
</button>
<button
type="button"
onClick={() => setActiveTab("ssh")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSH / SSL
</button>
<button
type="button"
onClick={() => setActiveTab("tags")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "tags" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
Tags & Env
</button>
</div>
{activeTab === "general" ? (
<GeneralTab form={form} onChange={onChange} />
) : activeTab === "ssh" ? (
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (updates: Record<string, unknown>) => void} />
) : (
<TagsEnvTab form={form} onChange={onChange} />
)}
</div>
);
}
@@ -0,0 +1,31 @@
import { SelectDropdown } from "../ui/SelectDropdown";
export type Environment = "production" | "staging" | "development" | null;
interface EnvironmentSelectProps {
value: Environment;
onChange: (value: Environment) => void;
}
const OPTIONS: { value: Environment; label: string }[] = [
{ value: null, label: "None" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
];
export function EnvironmentSelect({ value, onChange }: EnvironmentSelectProps) {
return (
<SelectDropdown
value={value ?? ""}
onChange={(next) =>
onChange(next === "" ? null : (next as Environment))
}
options={OPTIONS.map((opt) => ({
value: opt.value ?? "",
label: opt.label,
}))}
placeholder="None"
/>
);
}
@@ -0,0 +1,28 @@
import { SelectDropdown } from "../ui/SelectDropdown";
import type { Folder } from "../../lib/types";
import { getFolderPathLabel } from "../../lib/utils";
interface FolderSelectProps {
folders: Folder[];
value: string | null;
onChange: (value: string | null) => void;
}
export function FolderSelect({ folders, value, onChange }: FolderSelectProps) {
const options = [
{ value: "", label: "None" },
...folders.map((folder) => ({
value: folder.id,
label: getFolderPathLabel(folders, folder.id),
})),
];
return (
<SelectDropdown
value={value ?? ""}
onChange={(next) => onChange(next === "" ? null : next)}
options={options}
placeholder="None"
/>
);
}
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { GeneralTab } from "./GeneralTab";
import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "postgres",
password: "secret",
database: "mydb",
use_keychain: true,
};
describe("GeneralTab", () => {
it("renders host, port, user, password, and database fields", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByLabelText("Host")).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toBeInTheDocument();
expect(screen.getByLabelText("User")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
});
it("hides host and port for sqlite but shows database", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite" }} onChange={() => {}} />);
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
}
const AUTH_OPTIONS = ["User & Password"];
export function GeneralTab({ form, onChange }: GeneralTabProps) {
const isSqlite = form.db_type === "sqlite";
return (
<div className="space-y-4">
{!isSqlite && (
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm text-text mb-1.5">Host</label>
<Input
value={form.host}
onChange={(value) => onChange({ host: value })}
placeholder="localhost"
aria-label="Host"
/>
</div>
<div className="w-28">
<label className="block text-sm text-text mb-1.5">Port</label>
<Input
type="number"
value={form.port?.toString() ?? ""}
onChange={(value) => onChange({ port: value === "" ? null : Number(value) })}
placeholder="5432"
aria-label="Port"
/>
</div>
</div>
)}
<div>
<label className="block text-sm text-text mb-1.5">Authentication</label>
<select
value={AUTH_OPTIONS[0]}
disabled
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed"
>
{AUTH_OPTIONS.map((opt) => (
<option key={opt}>{opt}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-text mb-1.5">User</label>
<Input
value={form.username ?? ""}
onChange={(value) => onChange({ username: value || null })}
placeholder="postgres"
aria-label="User"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Password</label>
<PasswordInput
value={form.password ?? ""}
onChange={(value) => onChange({ password: value || null })}
placeholder="••••••••"
aria-label="Password"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
<Input
value={form.database ?? ""}
onChange={(value) => onChange({ database: value || null })}
placeholder="database"
aria-label="Database"
/>
</div>
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={form.use_keychain}
onChange={(e) => onChange({ use_keychain: e.target.checked })}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
Enable keychain
</label>
</div>
);
}
@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NewConnectionScreen } from "./NewConnectionScreen";
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
createConnection: vi.fn().mockResolvedValue({}),
notify: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../../stores/connectionStore", () => ({
createConnection,
useConnectionStore: (selector: (s: { createConnection: typeof createConnection }) => unknown) =>
selector({ createConnection }),
}));
vi.mock("../../stores/notificationStore", () => ({
notify,
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("../../lib/commands", () => ({
testConnection,
}));
describe("NewConnectionScreen", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("switches to detailed mode and back", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByText(/general/i)).toBeInTheDocument();
await user.click(screen.getByText(/back to connection string/i));
expect(screen.getByLabelText(/connection string/i)).toBeInTheDocument();
});
it("parses prefilled connection string and populates fields", async () => {
const user = userEvent.setup();
render(
<NewConnectionScreen
prefilledConnectionString="postgresql://u:p@localhost:5432/db"
folders={[]}
tags={[]}
/>,
);
expect(screen.getByLabelText(/connection string/i)).toHaveValue(
"postgresql://u:p@localhost:5432/db",
);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("u");
expect(screen.getByLabelText("Database")).toHaveValue("db");
});
it("shows validation error and does not call createConnection when saving empty form", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText("Save Connection"));
expect(notify).toHaveBeenCalledWith("name is required", "error");
expect(createConnection).not.toHaveBeenCalled();
});
it("saves a connection and invokes onSaved when required fields are filled", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onSaved={onSaved} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
connection_string: "postgresql://u:p@localhost:5432/db",
folder_id: null,
tag_ids: [],
environment: null,
use_keychain: false,
}),
);
expect(notify).toHaveBeenCalledWith("Connection saved", "success");
expect(onSaved).toHaveBeenCalled();
});
it("calls testConnection when Test Connection is clicked", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
}),
);
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
});
it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onCancel={onCancel} />);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(onCancel).toHaveBeenCalled();
});
});
@@ -0,0 +1,193 @@
import { useState, useEffect, useCallback } from "react";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { ConnectionFormShell } from "./ConnectionFormShell";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import { parseConnectionString } from "../../lib/connectionString";
import { validateConnectionInput } from "../../lib/utils";
import { testConnection } from "../../lib/commands";
import type {
Folder,
Tag,
NewConnectionMode,
ConnectionInput,
} from "../../lib/types";
import type { ConnectionFormData } from "./connectionFormData";
interface NewConnectionScreenProps {
defaultFolderId?: string | null;
prefilledConnectionString?: string;
folders: Folder[];
tags: Tag[];
onSaved?: () => void;
onCancel?: () => void;
}
function createEmptyForm(
defaultFolderId: string | null = null,
): ConnectionFormData {
return {
name: "",
environment: null,
folder_id: defaultFolderId,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: false,
};
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
folders,
tags,
onSaved,
onCancel,
}: NewConnectionScreenProps) {
const [mode, setMode] = useState<NewConnectionMode>("simple");
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(defaultFolderId),
);
const [testLoading, setTestLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
const handleConnectionStringChange = useCallback((value: string) => {
setForm((prev) => {
const parsed = parseConnectionString(value);
if (!parsed) return { ...prev, connection_string: value };
return {
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
});
}, []);
useEffect(() => {
if (prefilledConnectionString) {
handleConnectionStringChange(prefilledConnectionString);
}
}, [prefilledConnectionString, handleConnectionStringChange]);
const updateForm = useCallback((updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
}, []);
const buildPayload = useCallback((): ConnectionInput => {
return {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
folder_id: form.folder_id,
tag_ids: form.tag_ids,
connection_string: form.connection_string,
environment: form.environment,
password: form.password,
database: form.database,
use_keychain: form.use_keychain,
};
}, [form]);
const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload());
return result.ok ? null : result.error;
}, [buildPayload]);
const handleSave = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setSaveLoading(true);
try {
await createConnection(buildPayload());
notify("Connection saved", "success");
onSaved?.();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Failed to save connection: ${message}`, "error");
} finally {
setSaveLoading(false);
}
}, [validate, notify, createConnection, buildPayload, onSaved]);
const handleTest = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setTestLoading(true);
try {
const result = await testConnection(buildPayload());
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Connection test failed: ${message}`, "error");
} finally {
setTestLoading(false);
}
}, [validate, notify, testConnection, buildPayload]);
const onSimpleChange = useCallback(
(updates: Partial<ConnectionFormData>) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
handleConnectionStringChange(updates.connection_string);
} else {
updateForm(updates);
}
},
[handleConnectionStringChange, updateForm],
);
const onToggleMode = useCallback(() => {
setMode((m) => (m === "simple" ? "detailed" : "simple"));
}, []);
return (
<ConnectionFormShell
mode={mode}
onBack={() => onCancel?.()}
onTest={handleTest}
onSave={handleSave}
onToggleMode={onToggleMode}
testLoading={testLoading}
saveLoading={saveLoading}
>
{mode === "simple" ? (
<SimpleConnectionForm
form={form}
folders={folders}
tags={tags}
onChange={onSimpleChange}
/>
) : (
<DetailedConnectionForm form={form} onChange={updateForm} />
)}
</ConnectionFormShell>
);
}
@@ -0,0 +1,37 @@
import { useState, forwardRef } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { KeyboardEvent } from "react";
interface PasswordInputProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
ref={ref}
type={visible ? "text" : "password"}
className={`w-full rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text transition-colors cursor-pointer"
aria-label={visible ? "Hide password" : "Show password"}
>
{visible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
);
}
);
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import type { ConnectionFormData } from "./connectionFormData";
import type { SimpleConnectionFormProps } from "./SimpleConnectionForm";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: false,
};
function StatefulForm(
props: Omit<SimpleConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
return (
<SimpleConnectionForm
{...props}
form={form}
onChange={(updates) => {
setForm((prev) => ({ ...prev, ...updates }));
props.onChange?.(updates);
}}
/>
);
}
describe("SimpleConnectionForm", () => {
it("updates the connection string", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
const input = screen.getByLabelText(/connection string/i);
await user.type(input, "postgresql://a@b/c");
expect(onChange).toHaveBeenLastCalledWith({
connection_string: "postgresql://a@b/c",
});
expect(input).toHaveValue("postgresql://a@b/c");
});
it("toggles a tag via the SearchableTagPicker", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const tags = [
{
id: "tag-1",
name: "Work",
color: "#ff0000",
created_at: "2024-01-01T00:00:00Z",
},
{
id: "tag-2",
name: "Personal",
color: "#00ff00",
created_at: "2024-01-01T00:00:00Z",
},
];
render(<StatefulForm folders={[]} tags={tags} onChange={onChange} />);
const workTag = screen.getByText("Work");
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: ["tag-1"] });
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: [] });
});
});
@@ -0,0 +1,84 @@
import { Input } from "../ui/Input";
import { EnvironmentSelect } from "./EnvironmentSelect";
import { FolderSelect } from "./FolderSelect";
import { ConnectionStringInput } from "./ConnectionStringInput";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
export interface SimpleConnectionFormProps {
form: ConnectionFormData;
folders: Folder[];
tags: Tag[];
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function SimpleConnectionForm({
form,
folders,
tags,
onChange,
}: SimpleConnectionFormProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
<p className="text-xs text-text-muted mt-1.5">
A friendly name to identify this connection.
</p>
</div>
<div>
<label className="block text-sm text-text mb-1.5">
Environment
</label>
<EnvironmentSelect
value={form.environment}
onChange={(value) => onChange({ environment: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect
folders={folders}
value={form.folder_id ?? null}
onChange={(value) => onChange({ folder_id: value })}
/>
</div>
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onChange({ tag_ids: next });
}}
/>
<div>
<label className="block text-sm text-text mb-1.5">
Connection String
</label>
<ConnectionStringInput
value={form.connection_string}
onChange={(value) => onChange({ connection_string: value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection String"
/>
<p className="text-xs text-text-muted mt-1.5">
Paste your connection string to auto-detect database type.
</p>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SshFields } from "./SshFields";
const notify = vi.fn();
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SshFields", () => {
it("renders SSH host, port, and user fields", () => {
render(<SshFields values={{}} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.getByLabelText("SSH Port")).toBeInTheDocument();
expect(screen.getByLabelText("SSH User")).toBeInTheDocument();
});
it("renders auth method dropdown", () => {
render(<SshFields values={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Auth Method" })).toBeInTheDocument();
});
it("shows private key and passphrase fields when auth method is key", () => {
render(<SshFields values={{ ssh_auth_method: "key" }} onChange={() => {}} />);
expect(screen.getByLabelText("Private Key")).toBeInTheDocument();
expect(screen.getByLabelText("Passphrase")).toBeInTheDocument();
expect(screen.queryByLabelText("SSH Password")).not.toBeInTheDocument();
});
it("shows password field when auth method is password", () => {
render(<SshFields values={{ ssh_auth_method: "password" }} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Password")).toBeInTheDocument();
expect(screen.queryByLabelText("Private Key")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Passphrase")).not.toBeInTheDocument();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { open } from "@tauri-apps/plugin-dialog";
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import { SelectDropdown } from "../ui/SelectDropdown";
import { useNotificationStore } from "../../stores/notificationStore";
export interface SshFieldsProps {
values: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
const AUTH_METHOD_OPTIONS = [
{ value: "password", label: "Password" },
{ value: "key", label: "Private Key" },
];
export function SshFields({ values, onChange }: SshFieldsProps) {
const notify = useNotificationStore((s) => s.notify);
const authMethod = (values.ssh_auth_method as string) ?? "password";
const handlePickFile = async (field: string) => {
try {
const path = await open({ multiple: false, directory: false });
if (path) {
onChange({ [field]: path });
}
} catch {
notify("File picker not available", "error");
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">SSH Host</label>
<Input
value={(values.ssh_host as string) ?? ""}
onChange={(value) => onChange({ ssh_host: value })}
placeholder="bastion.example.com"
aria-label="SSH Host"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">SSH Port</label>
<Input
type="number"
value={(values.ssh_port as number)?.toString() ?? "22"}
onChange={(value) => onChange({ ssh_port: value === "" ? null : Number(value) })}
placeholder="22"
aria-label="SSH Port"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">SSH User</label>
<Input
value={(values.ssh_user as string) ?? ""}
onChange={(value) => onChange({ ssh_user: value })}
placeholder="ssh-user"
aria-label="SSH User"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Auth Method</label>
<SelectDropdown
value={authMethod}
onChange={(value) => onChange({ ssh_auth_method: value })}
options={AUTH_METHOD_OPTIONS}
aria-label="Auth Method"
/>
</div>
{authMethod === "key" ? (
<>
<div>
<label className="block text-sm text-text mb-1.5">Private Key</label>
<div className="flex gap-2">
<Input
value={(values.ssh_private_key as string) ?? ""}
onChange={(value) => onChange({ ssh_private_key: value })}
placeholder="/path/to/key"
aria-label="Private Key"
/>
<button
type="button"
onClick={() => handlePickFile("ssh_private_key")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Passphrase</label>
<PasswordInput
value={(values.ssh_passphrase as string) ?? ""}
onChange={(value) => onChange({ ssh_passphrase: value })}
placeholder="••••••••"
aria-label="Passphrase"
/>
</div>
</>
) : (
<div>
<label className="block text-sm text-text mb-1.5">SSH Password</label>
<PasswordInput
value={(values.ssh_password as string) ?? ""}
onChange={(value) => onChange({ ssh_password: value })}
placeholder="••••••••"
aria-label="SSH Password"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SshSslTab } from "./SshSslTab";
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: () => ({
notify: vi.fn(),
}),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SshSslTab", () => {
it("renders SSH and SSL sub-tab buttons", () => {
render(<SshSslTab form={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "SSH" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "SSL" })).toBeInTheDocument();
});
it("toggles between SSH and SSL content", async () => {
const user = userEvent.setup();
render(<SshSslTab form={{}} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "SSL" }));
expect(screen.queryByLabelText("SSH Host")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "SSH" }));
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { SshFields } from "./SshFields";
import { SslFields } from "./SslFields";
export interface SshSslTabProps {
form: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
export function SshSslTab({ form, onChange }: SshSslTabProps) {
const [activeSubTab, setActiveSubTab] = useState<"ssh" | "ssl">("ssh");
return (
<div>
<div className="flex gap-4 border-b border-border mb-4">
<button
type="button"
onClick={() => setActiveSubTab("ssh")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeSubTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSH
</button>
<button
type="button"
onClick={() => setActiveSubTab("ssl")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeSubTab === "ssl" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSL
</button>
</div>
{activeSubTab === "ssh" ? <SshFields values={form} onChange={onChange} /> : <SslFields values={form} onChange={onChange} />}
</div>
);
}
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SslFields } from "./SslFields";
const notify = vi.fn();
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SslFields", () => {
it("renders SSL mode dropdown", () => {
render(<SslFields values={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
});
it("shows file pickers for verify-full mode", () => {
render(<SslFields values={{ ssl_mode: "verify-full" }} onChange={() => {}} />);
expect(screen.getByLabelText("CA Certificate")).toBeInTheDocument();
expect(screen.getByLabelText("Client Certificate")).toBeInTheDocument();
expect(screen.getByLabelText("Client Key")).toBeInTheDocument();
});
it("hides file pickers for disable mode", () => {
render(<SslFields values={{ ssl_mode: "disable" }} onChange={() => {}} />);
expect(screen.queryByLabelText("CA Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { open } from "@tauri-apps/plugin-dialog";
import { Input } from "../ui/Input";
import { SelectDropdown } from "../ui/SelectDropdown";
import { useNotificationStore } from "../../stores/notificationStore";
export interface SslFieldsProps {
values: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
const SSL_MODE_OPTIONS = [
{ value: "disable", label: "Disable" },
{ value: "require", label: "Require" },
{ value: "verify-ca", label: "Verify CA" },
{ value: "verify-full", label: "Verify Full" },
];
export function SslFields({ values, onChange }: SslFieldsProps) {
const notify = useNotificationStore((s) => s.notify);
const mode = (values.ssl_mode as string) ?? "disable";
const showCertFields = mode === "verify-ca" || mode === "verify-full";
const handlePickFile = async (field: string) => {
try {
const path = await open({ multiple: false, directory: false });
if (path) {
onChange({ [field]: path });
}
} catch {
notify("File picker not available", "error");
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">SSL Mode</label>
<SelectDropdown
value={mode}
onChange={(value) => onChange({ ssl_mode: value })}
options={SSL_MODE_OPTIONS}
aria-label="SSL Mode"
/>
</div>
{mode === "require" && (
<p className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
Require mode is vulnerable to man-in-the-middle attacks because it does not verify the server certificate.
</p>
)}
{showCertFields && (
<>
<div>
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_ca_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_cert: value })}
placeholder="/path/to/ca-cert.pem"
aria-label="CA Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_ca_cert")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_client_cert: value })}
placeholder="/path/to/client-cert.pem"
aria-label="Client Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_cert")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Client Key</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_key as string) ?? ""}
onChange={(value) => onChange({ ssl_client_key: value })}
placeholder="/path/to/client-key.pem"
aria-label="Client Key"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_key")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
</>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { EnvironmentSelect } from "../connections/EnvironmentSelect";
import { FolderSelect } from "../connections/FolderSelect";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import { useConnectionStore } from "../../stores/connectionStore";
import type { ConnectionFormData } from "../connections/connectionFormData";
interface TagsEnvTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function TagsEnvTab({ form, onChange }: TagsEnvTabProps) {
const folders = useConnectionStore((s) => s.folders);
const tags = useConnectionStore((s) => s.tags);
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Environment</label>
<EnvironmentSelect
value={form.environment}
onChange={(value) => onChange({ environment: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect
folders={folders}
value={form.folder_id ?? null}
onChange={(value) => onChange({ folder_id: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Tags</label>
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onChange({ tag_ids: next });
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import type { DbType } from "../../lib/types";
import type { Environment } from "./EnvironmentSelect";
export interface ConnectionFormData {
name: string;
environment: Environment;
folder_id: string | null;
tag_ids: string[];
connection_string: string;
db_type: DbType;
host: string;
port: number | null;
username: string | null;
password: string | null;
database: string | null;
use_keychain: boolean;
}
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("ChangesQueuePanel", () => {
beforeEach(() => {
useDbViewerStore.setState({ changesQueue: [] });
});
it("shows nothing when queue is empty", () => {
const { container } = render(<ChangesQueuePanel />);
expect(container.textContent).toBe("");
});
it("shows pending changes", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
expect(screen.getByText(/users/i)).toBeInTheDocument();
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelBtn);
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,199 @@
import { useState, useCallback } from "react";
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as cmd from "../../lib/commands";
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
import type { ChangeItem } from "../../lib/types";
const statusBg: Record<QueueStatus, string> = {
pending: "bg-accent/5",
committed: "bg-green-500/5",
failed: "bg-red-500/5",
cancelled: "bg-surface-raised/50",
};
function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
function StatusIndicator({ status }: { status: QueueStatus }) {
switch (status) {
case "pending":
return (
<div className="flex items-center gap-1.5 text-amber-400">
<span className="h-2 w-2 rounded-full bg-amber-400" />
<span>Pending</span>
</div>
);
case "committed":
return (
<div className="flex items-center gap-1.5 text-green-500">
<Check className="h-4 w-4" />
<span>Committed</span>
</div>
);
case "failed":
return (
<div className="flex items-center gap-1.5 text-red-500">
<X className="h-4 w-4" />
<span>Failed</span>
</div>
);
case "cancelled":
return (
<div className="flex items-center gap-1.5 text-text-muted">
<span>Cancelled</span>
</div>
);
default:
return null;
}
}
export function ChangesQueuePanel() {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const cancelChange = useDbViewerStore((state) => state.cancelChange);
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const [expanded, setExpanded] = useState(true);
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
if (!connectionId) {
notify("No active connection", "error");
return;
}
const pending = useDbViewerStore.getState().changesQueue.filter(
(c) => c.status === "pending",
);
if (pending.length === 0) return;
let committedCount = 0;
for (const change of pending) {
try {
const payload = {
id: change.id,
type: change.type,
sql: change.sql,
status: "pending" as const,
description: change.description ?? null,
} satisfies ChangeItem;
await cmd.executeChange(connectionId, payload);
markChangeCommitted(change.id);
committedCount++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
markChangeFailed(change.id, msg);
notify(`Change failed: ${msg}`, "error");
break;
}
}
if (committedCount > 0) {
notify(`${committedCount} change(s) committed`, "success");
}
}, [markChangeCommitted, markChangeFailed, notify]);
if (changesQueue.length === 0) {
return null;
}
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
const processedCount = changesQueue.filter(
(c) => c.status === "committed" || c.status === "failed",
).length;
const changeWord = pendingCount === 1 ? "change" : "changes";
return (
<div className="border-t border-border bg-surface">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronUp className="h-4 w-4 text-text-muted" />
)}
<span className="font-medium">
Changes Queue ({pendingCount} pending {changeWord}, {processedCount}{" "}
processed)
</span>
{pendingCount > 0 && (
<span className="rounded-full bg-accent/20 px-2 py-0.5 text-xs text-accent-muted">
{pendingCount}
</span>
)}
</div>
<button
type="button"
disabled={pendingCount === 0}
onClick={(e) => {
e.stopPropagation();
handleCommitAll();
}}
className="rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Commit All
</button>
</button>
{expanded && (
<div className="max-h-48 overflow-y-auto">
{changesQueue.map((change) => (
<ChangeRow
key={change.id}
change={change}
onCancel={() => cancelChange(change.id)}
/>
))}
</div>
)}
</div>
);
}
function ChangeRow({
change,
onCancel,
}: {
change: QueueItem;
onCancel: () => void;
}) {
return (
<div
className={`flex items-center justify-between px-4 py-2 text-sm ${statusBg[change.status]}`}
>
<div className="flex items-center gap-3">
<span className="rounded-md bg-surface-raised px-2 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
</span>
<span className="text-text">
{change.table ? change.table : "-"}
</span>
</div>
<div className="flex items-center gap-3">
<StatusIndicator status={change.status} />
{change.status === "pending" && (
<button
type="button"
aria-label="Cancel"
onClick={onCancel}
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
describe("ConnectionDropBanner", () => {
it("shows error message", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByText("Connection lost")).toBeInTheDocument();
});
it("shows reconnect button", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument();
});
it("calls onRetry when reconnect clicked", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={onRetry}
onDismiss={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /reconnect/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("calls onDismiss when close button clicked", async () => {
const onDismiss = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,35 @@
import { AlertTriangle, X } from "lucide-react";
interface ConnectionDropBannerProps {
error: string;
onRetry: () => void;
onDismiss?: () => void;
}
export function ConnectionDropBanner({ error, onRetry, onDismiss }: ConnectionDropBannerProps) {
return (
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/20 rounded-md px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<AlertTriangle size={18} className="text-red-400 shrink-0" />
<span className="text-red-300 text-sm truncate">{error}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={onRetry}
className="text-sm px-3 py-1.5 rounded-md bg-red-500/20 text-red-200 hover:bg-red-500/30 transition-colors cursor-pointer"
>
Reconnect
</button>
<button
type="button"
aria-label="Dismiss error"
onClick={onDismiss}
className="p-1.5 rounded-md text-red-300 hover:bg-red-500/20 transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataGrid } from "./DataGrid";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { QueryResult } from "../../lib/types";
const mockData: QueryResult = {
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
rows: [
[1, "Alice", "alice@example.com"],
[2, "Bob", null],
],
total_rows: 2, page: 1, page_size: 50,
};
describe("DataGrid", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no active tab", () => {
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument();
});
it("shows loading state", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabLoading(tabId, true);
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("shows error message in red", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabError(tabId, "Connection failed");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
const error = screen.getByText(/Connection failed/i);
expect(error).toBeInTheDocument();
expect(error).toHaveClass("text-red-500");
});
it("shows loading state when first opening a tab", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders column headers and row data when loaded", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("NULL")).toBeInTheDocument();
});
it("renders NULL values as italic muted text", () => {
useDbViewerStore.getState().openTab("public", "users");
const tabId = useDbViewerStore.getState().tabs[0].id;
useDbViewerStore.getState().setTabData(tabId, mockData);
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
const nullCell = screen.getByText("NULL");
expect(nullCell).toHaveClass("italic");
expect(nullCell).toHaveClass("text-text-muted");
});
});
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Key, Braces } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "./FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "./JsonCellPopover";
// TODO: Replace this plain HTML table with @tanstack/react-virtual for large
// result sets so we can render millions of rows without DOM overhead.
type ColumnWidths = Record<string, number>;
type TabColumnWidths = Record<string, ColumnWidths>;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
const CHECKBOX_COL_WIDTH = 40;
interface DataGridProps {
connectionId: string;
rows: unknown[][];
hiddenColumns?: Set<string>;
selectedRows: Set<number>;
onSelectionChange: (selected: Set<number>) => void;
}
export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const [colWidths, setColWidths] = useState<TabColumnWidths>({});
// FK preview popover state
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
// JSON cell popover state
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── helpers ────────────────────────────────────────────
const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null;
const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {};
const getWidth = useCallback(
(colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH,
[widths],
);
// ── selection logic ────────────────────────────────────
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length;
const checkboxRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const toggleAll = () => {
if (allSelected) {
onSelectionChange(new Set());
} else {
onSelectionChange(new Set(rows.map((_, i) => i)));
}
};
const toggleRow = (rowIndex: number) => {
const next = new Set(selectedRows);
if (next.has(rowIndex)) next.delete(rowIndex);
else next.add(rowIndex);
onSelectionChange(next);
};
// ── resize handler (ref-based to avoid stale closures) ─
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(colName: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
const onMove = (ev: MouseEvent) => {
if (!resizeRef.current) return;
const delta = ev.clientX - resizeRef.current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta));
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next },
}));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[activeTabId, getWidth],
);
// ── FK row-click handler ───────────────────────────────
const handleFkClick = useCallback(
(col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const schema = activeTab?.schema ?? "public";
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[activeTab],
);
// ── empty / loading / error states ─────────────────────
if (!activeTabId) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Select a table to view data
</div>
);
}
if (activeTab.loading && !activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading...
</div>
);
}
if (activeTab.error) {
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-red-500">
{activeTab.error}
</div>
);
}
if (!activeTab.data) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
Loading table data...
</div>
);
}
const { columns } = activeTab.data;
// Filter visible columns
const visibleColumns = hiddenColumns
? columns.filter((c) => !hiddenColumns.has(c.name))
: columns;
return (
<div
className="flex-1 overflow-auto min-w-0 relative"
style={{ overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
{/* Loading indicator bar when refreshing with existing data */}
{activeTab.loading && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 animate-pulse" />
)}
<table
className="border-collapse text-left text-sm"
style={{ tableLayout: "fixed", width: "100%" }}
>
<colgroup>
{/* Checkbox column */}
<col style={{ width: CHECKBOX_COL_WIDTH, minWidth: CHECKBOX_COL_WIDTH }} />
{visibleColumns.map((col) => (
<col key={col.name} style={{ width: getWidth(col.name) }} />
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-surface">
<tr>
{/* Header checkbox */}
<th
scope="col"
className="border-b border-r border-border px-0 py-2 w-[40px]"
>
<div className="flex items-center justify-center">
<input
ref={checkboxRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</th>
{visibleColumns.map((col) => (
<th
key={col.name}
scope="col"
role="columnheader"
className="group relative border-b border-r border-border px-3 py-2 font-heading text-text-muted last:border-r-0"
style={{ width: getWidth(col.name), maxWidth: getWidth(col.name) }}
>
<div className="truncate flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
<span className="text-text text-xs">{col.name}</span>
<span className="ml-1 text-[10px] text-text-muted/60" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
{/* resize handle */}
<div
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
onMouseDown={(e) => startResize(col.name, e)}
onDoubleClick={() => {
setColWidths((prev) => ({
...prev,
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH },
}));
}}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const isSelected = selectedRows.has(rowIndex);
return (
<tr
key={rowIndex}
className={`border-b border-border hover:bg-surface/50 ${isSelected ? "bg-accent/5" : ""}`}
>
{/* Row checkbox */}
<td className="border-r border-border px-0 py-2" style={{ overflow: "hidden" }}>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(rowIndex)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
</td>
{visibleColumns.map((col) => {
const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined;
const isNull = cell === null || cell === undefined;
const isFk = col.is_fk && col.fk_ref && !isNull;
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
};
return (
<td key={col.name} className="border-r border-border px-3 py-2 last:border-r-0 font-heading text-xs" style={{ overflow: "hidden" }}>
<div
className={`truncate max-w-full select-text ${isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
title={isNull ? "NULL" : isFk ? `FK → ${col!.fk_ref![0]}.${col!.fk_ref![1]}: ${String(cell)}` : isJson ? "Click to view JSON" : String(cell)}
onClick={isFk ? (e) => handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined}
role={isFk || isJson ? "button" : undefined}
tabIndex={isFk || isJson ? 0 : undefined}
onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined}
>
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</span>
) : (
String(cell)
)}
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{/* FK preview popover */}
{fkPreview && (
<FkPreviewPopover
connectionId={fkPreview.connectionId}
schema={fkPreview.schema}
table={fkPreview.table}
column={fkPreview.column}
value={fkPreview.value}
anchorRect={fkPreview.anchorRect}
onClose={() => setFkPreview(null)}
/>
)}
{/* JSON cell popover */}
{jsonPopover && (
<JsonCellPopover
value={jsonPopover.value}
anchorRect={jsonPopover.anchorRect}
onClose={() => setJsonPopover(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.setState({
tabs: [], activeTabId: null, changesQueue: [],
databases: ["mydb"], schemas: ["public"],
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
currentDatabase: "mydb", currentSchema: "public",
});
});
it("renders the sidebar", () => {
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
});
+408
View File
@@ -0,0 +1,408 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { TableTree } from "./TableTree";
import { TabBar } from "./TabBar";
import { DataGrid } from "./DataGrid";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
export interface DbViewerScreenProps {
connectionId: string;
onHome: () => void;
onSettings: () => void;
}
// ─── client-side filter/sort helpers ─────────────────────
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = { id: string; column: string; order: "asc" | "desc" };
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
if (rules.length === 0) return rows;
return rows.filter((row) =>
rules.every((rule) => {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) return true;
const cell = row[ci];
const str = cell === null || cell === undefined ? "" : String(cell);
switch (rule.operator) {
case "null": return cell === null;
case "notnull": return cell !== null;
case "eq": return str === rule.value;
case "neq": return str !== rule.value;
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
case "gt": return Number(str) > Number(rule.value);
case "lt": return Number(str) < Number(rule.value);
default: return true;
}
}),
);
}
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
if (rules.length === 0) return rows;
return [...rows].sort((a, b) => {
for (const rule of rules) {
const ci = columns.findIndex((c) => c.name === rule.column);
if (ci < 0) continue;
const va = a[ci];
const vb = b[ci];
const cmp =
va === null && vb === null ? 0
: va === null ? -1
: vb === null ? 1
: String(va).localeCompare(String(vb), undefined, { numeric: true });
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
}
return 0;
});
}
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
const [sortRules, setSortRules] = useState<SortRule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const smartSortApplied = useRef<Set<string>>(new Set());
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
const settings = useSettingsStore((s) => s.settings);
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
setDefaultPageSize(settings.table_page_size);
}
}, [settings?.table_page_size, setDefaultPageSize]);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null);
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
});
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const schemas = useDbViewerStore((s) => s.schemas);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const fetchingRef = useRef<Set<string>>(new Set());
const fetchData = useCallback(async (tab: NonNullable<typeof activeTab>) => {
if (fetchingRef.current.has(tab.id)) return;
fetchingRef.current.add(tab.id);
try {
const result = await cmd.getTableData(
connectionId,
tab.schema,
tab.table,
tab.page,
tab.pageSize,
);
setTabData(tab.id, result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setTabError(tab.id, msg);
} finally {
fetchingRef.current.delete(tab.id);
}
}, [connectionId, setTabData, setTabError]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
if (state.activeTabId) {
state.closeTab(state.activeTabId);
} else {
onHome();
}
});
useEffect(() => {
if (!activeTab) return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
}, [activeTab, fetchData]);
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (smartSortApplied.current.has(activeTab.id)) return;
const cols = activeTab.data.columns;
const getColType = (name: string) => {
const col = cols.find((c) => c.name.toLowerCase() === name.toLowerCase());
return col?.data_type.toLowerCase() ?? "";
};
const isNumeric = (name: string) => {
const t = getColType(name);
return ["integer", "int", "int2", "int4", "int8", "smallint", "bigint",
"serial", "bigserial", "smallserial", "tinyint", "mediumint",
"numeric", "decimal", "real", "float", "float4", "float8",
"double precision", "double", "number"].includes(t);
};
const isTimestamp = (name: string) => {
const t = getColType(name);
return ["timestamp", "timestamptz", "timestamp without time zone",
"timestamp with time zone", "date", "datetime", "datetime2",
"smalldatetime"].some((pt) => t.includes(pt));
};
// Find the first column name that exists and passes type checks
const findCol = (candidates: string[], numericOnly = false): string | undefined => {
for (const cand of candidates) {
const match = cols.find((c) => c.name.toLowerCase() === cand.toLowerCase());
if (!match) continue;
if (numericOnly && !isNumeric(match.name)) continue;
return match.name;
}
return undefined;
};
const findBySuffix = (suffixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (suffixes.some((s) => name.endsWith(s))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
const findByPrefix = (prefixes: string[], numericOnly = false): string | undefined => {
for (const c of cols) {
const name = c.name.toLowerCase();
if (prefixes.some((p) => name.startsWith(p))) {
if (numericOnly && !isNumeric(c.name)) continue;
return c.name;
}
}
return undefined;
};
// Priority-ordered rules: each returns [columnName | undefined, order]
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
// Tier 1: Explicit recency columns
() => [findCol(["updated_at", "modified_at", "changed_at", "altered_at", "revised_at"]), "desc"],
() => [findCol(["created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at"]), "desc"],
() => [findCol(["deleted_at", "removed_at", "expired_at", "archived_at"]), "desc"],
// Tier 2: Generic date/timestamp columns (DESC = newest)
() => {
const col = cols.find((c) => isTimestamp(c.name));
return col ? [col.name, "desc"] : [undefined, "desc"];
},
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
() => [findBySuffix(["_at"]), "desc"],
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
() => [findBySuffix(["_on"]), "desc"],
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
() => [findByPrefix(["last_"]), "desc"],
// Tier 6: Numeric ID (DESC = highest/newest)
() => [findCol(["id", "uid", "pk"], true), "desc"],
// Tier 7: Any *_id suffix (numeric FKs usually increment)
() => [findBySuffix(["_id"], true), "desc"],
// Tier 8: Sequence/order columns (ASC = natural order)
() => [findCol(["seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order"], true), "asc"],
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
() => [findCol(["rank", "ranking", "priority", "weight", "score", "rating"], true), "desc"],
// Tier 10: Version/revision tracking (DESC = latest)
() => [findCol(["version", "revision", "rev", "build", "release"], true), "desc"],
// Tier 11: Count/quantity (DESC = most)
() => [findCol(["count", "total", "amount", "quantity", "qty", "num", "number", "no"], true), "desc"],
];
for (const rule of rules) {
const [colName, order] = rule();
if (colName) {
smartSortApplied.current.add(activeTab.id);
setSortRules([{ id: crypto.randomUUID(), column: colName, order }]);
return;
}
}
}, [activeTab]);
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
setFilterRules((prev) => {
const exists = prev.some((r) => r.column === column && r.value === value);
if (exists) return prev;
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
});
}, [activeTab?.columnFilter]);
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
useEffect(() => {
if (!activeTab?.columnFilter) return;
const { column, value } = activeTab.columnFilter;
const stillExists = filterRules.some((r) => r.column === column && r.value === value);
if (!stillExists) {
clearColumnFilter(activeTab.id);
}
}, [filterRules, activeTab, clearColumnFilter]);
// Refresh: clear data so auto-fetch effect re-fetches
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
),
}));
}, []);
const rawRows = activeTab?.data?.rows ?? [];
const columns = activeTab?.data?.columns ?? [];
const processedRows = useMemo(() => {
let result = rawRows;
result = applyFilters(result, columns, filterRules);
result = applySorts(result, columns, sortRules);
return result;
}, [rawRows, columns, filterRules, sortRules]);
const onPanelResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth };
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const w = Math.max(180, Math.min(600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX)));
setTablePanelWidth(w);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [tablePanelWidth]);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
else if (view === "settings") onSettings();
},
[onHome, onSettings],
);
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="border-r border-border flex flex-col shrink-0" style={{ width: tablePanelWidth }}>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(col)) next.delete(col); else next.add(col);
return next;
})
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={setFilterRules}
sortRules={sortRules}
onSortChange={setSortRules}
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
onClearSelection={() => setSelectedRows(new Set())}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
</div>
</div>
</div>
<ChangesQueuePanel />
</div>
{currentConnection && (
<EditConnectionModal
connection={currentConnection}
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
onSaved={() => {}}
/>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { TooltipProvider } from "../ui/Tooltip";
describe("DbViewerSidebar", () => {
it("renders all navigation icons", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
});
it("calls onNavigate when home is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/home/i));
expect(onNavigate).toHaveBeenCalledWith("home");
});
it("calls onNavigate when settings is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/settings/i));
expect(onNavigate).toHaveBeenCalledWith("settings");
});
});
@@ -0,0 +1,57 @@
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
}
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
}
export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{ id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: <Grid2x2 size={20} />, stub: true },
{ id: "functions", label: "Functions coming soon", icon: <FunctionSquare size={20} />, stub: true },
{ id: "triggers", label: "Triggers coming soon", icon: <GitBranch size={20} />, stub: true },
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
];
function renderItem(item: NavItem) {
const isActive = currentView === item.id;
const baseClass = "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
const activeClass = "text-accent";
const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
const stubClass = "opacity-40 cursor-not-allowed";
return (
<Tooltip key={item.id} content={item.label} side="right">
<button
type="button"
aria-label={item.label}
disabled={item.stub}
onClick={() => onNavigate(item.id)}
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
>
{item.icon}
</button>
</Tooltip>
);
}
return (
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">{topItems.map(renderItem)}</div>
<div className="flex flex-col gap-2">{bottomItems.map(renderItem)}</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
const defaultProps = {
databases: [] as string[],
currentDatabase: null as string | null,
setCurrentDatabase: () => {},
schemas: [] as string[],
currentSchema: null as string | null,
setCurrentSchema: () => {},
searchQuery: "",
onSearchChange: () => {},
};
describe("DbViewerToolbar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders Tables label", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByText("Tables")).toBeInTheDocument();
});
it("renders database dropdown when multiple databases", () => {
render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
databases={["mydb", "otherdb"]}
currentDatabase="mydb"
/>
</TooltipProvider>,
);
expect(screen.getByText("mydb")).toBeInTheDocument();
});
it("renders refresh and create table buttons", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,195 @@
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
export function DbViewerToolbar({
databases,
currentDatabase,
setCurrentDatabase,
schemas,
currentSchema,
setCurrentSchema,
onEdit,
connectionId,
searchQuery,
onSearchChange,
}: {
databases: string[];
currentDatabase: string | null;
setCurrentDatabase: (db: string | null) => void;
schemas: string[];
currentSchema: string | null;
setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void;
connectionId?: string;
searchQuery: string;
onSearchChange: (q: string) => void;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) onSearchChange(""); // clear when closing
return next;
});
}, [onSearchChange]);
// Cleanup result timer on unmount
useEffect(() => {
return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
}, []);
const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return;
setRefreshing(true);
setResult('idle');
try {
const dbs = await cmd.getDatabases(connectionId);
const scs = await cmd.getSchemas(connectionId);
const tbls = await cmd.getTables(connectionId);
populate(dbs, scs, tbls);
setResult('success');
} catch {
setResult('error');
} finally {
setRefreshing(false);
resultTimer.current = setTimeout(() => setResult('idle'), 1500);
}
}, [connectionId, refreshing, populate]);
return (
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
<button
aria-label="Edit Connection"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Pencil size={14} />
</button>
</Tooltip>
)}
<Tooltip content={result === 'success' ? 'Refreshed' : result === 'error' ? 'Refresh failed' : 'Refresh Database'} side="bottom">
<button
aria-label="Refresh"
onClick={handleRefresh}
disabled={refreshing}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
{refreshing ? (
<RefreshCw size={14} className="animate-spin" />
) : result === 'success' ? (
<Check size={14} className="text-emerald-400" />
) : result === 'error' ? (
<AlertCircle size={14} className="text-red-400" />
) : (
<RefreshCw size={14} />
)}
</button>
</Tooltip>
<Tooltip content="Create Table" side="bottom">
<button
aria-label="Create Table"
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
>
<Plus size={14} />
</button>
</Tooltip>
<Tooltip content="Search Tables" side="bottom">
<button
aria-label="Search Tables"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</Tooltip>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search size={12} className="absolute left-2.5 text-text-muted pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter tables…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({ value: d, label: d }))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={setCurrentSchema}
options={schemas.map((s) => ({ value: s, label: s }))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,123 @@
import { useState, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
interface EditConnectionModalProps {
connection: Connection;
open: boolean;
onClose: () => void;
onSaved: (updated: Connection) => void;
}
export function EditConnectionModal({
connection,
open,
onClose,
onSaved,
}: EditConnectionModalProps) {
const [form, setForm] = useState<ConnectionFormData>(() => ({
name: connection.name,
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
folder_id: connection.folder_id,
tag_ids: [...connection.tag_ids],
connection_string: "",
db_type: connection.db_type,
host: connection.host,
port: connection.port,
username: connection.username,
password: null,
database: connection.database ?? null,
use_keychain: false,
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const loadAll = useConnectionStore((s) => s.loadAll);
const notify = useNotificationStore((s) => s.notify);
const handleSave = useCallback(async () => {
if (!form.name.trim()) return;
setSaving(true);
try {
const input: ConnectionInput = {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password: form.password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
};
const updated = await updateConnection(connection.id, input);
if (form.password) {
await saveConnectionPassword(connection.id, form.password).catch(() => {});
}
notify("Connection updated", "success");
onSaved(updated);
onClose();
loadAll();
} catch (e) {
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setSaving(false);
}
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
const handleTest = useCallback(async () => {
setTesting(true);
try {
// Fetch password from keychain if not provided in form
let password = form.password;
if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
}
const result = await testConnection({
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
});
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setTesting(false);
}
}, [form, notify, connection.id]);
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={handleTest} disabled={testing}>
{testing ? "Testing..." : "Test"}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
import * as cmd from "../../lib/commands";
import type { QueryResult } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
import { useDbViewerStore } from "../../stores/dbViewerStore";
interface FkPreviewPopoverProps {
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
onClose: () => void;
}
export function FkPreviewPopover({
connectionId,
schema,
table,
column,
value,
anchorRect,
onClose,
}: FkPreviewPopoverProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const openTab = useDbViewerStore((s) => s.openTab);
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
const handleOpen = () => {
openTab(schema, table);
// Find the newly created tab and apply the column filter
const newTab = useDbViewerStore.getState().tabs.find(
(t) => t.schema === schema && t.table === table,
);
if (newTab) {
setColumnFilter(newTab.id, column, value);
}
onClose();
};
// Fetch the referenced row
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
cmd
.getFkPreview(connectionId, schema, table, column, value)
.then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch((e: any) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [connectionId, schema, table, column, value]);
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay to avoid closing immediately from the same click that opened it
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
// Compute position to keep popover within viewport
const popoverWidth = 360;
const popoverMaxHeight = 320;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
// Flip horizontally if off-screen
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
// Flip vertically if not enough space below
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Key size={12} className="text-amber-400 shrink-0" />
<span className="text-xs font-heading text-text truncate">
{schema}.{table}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleOpen}
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
title="Open table in new tab"
>
<ExternalLink size={11} />
<span>Open</span>
</button>
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
<Loader2 size={14} className="animate-spin" />
Loading...
</div>
)}
{error && (
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
{error}
</div>
)}
{data && data.rows.length === 0 && !loading && (
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
No matching row found
</div>
)}
{data && data.rows.length > 0 && (
<table className="w-full text-xs">
<tbody>
{data.columns.map((col, ci) => {
const cell = data.rows[0][ci];
const isNull = cell === null || cell === undefined;
return (
<tr
key={col.name}
className="border-b border-border last:border-0 hover:bg-surface/30"
>
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
<div className="flex items-center gap-1">
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
<span className="truncate">{col.name}</span>
<span
className="text-[10px] text-text-muted/50 shrink-0"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</span>
</div>
</td>
<td className="px-3 py-1.5 text-text">
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : (
<span className="break-all">{String(cell)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,163 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Braces, Copy, Check, X } from "lucide-react";
interface JsonCellPopoverProps {
value: unknown;
anchorRect: DOMRect | null;
onClose: () => void;
}
function safeJsonParse(value: unknown): object | null {
if (typeof value === "object" && value !== null) return value as object;
if (typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null ? parsed : null;
} catch {
return null;
}
}
function formatJson(obj: object): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
const [copied, setCopied] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const parsed = safeJsonParse(value);
const rawText = typeof value === "string" ? value : JSON.stringify(value);
const formattedText = parsed ? formatJson(parsed) : rawText;
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
const popoverWidth = 420;
const popoverMaxHeight = 360;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
const handleCopy = async () => {
const text = tab === "formatted" ? formattedText : rawText;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Braces size={12} className="text-accent shrink-0" />
<span className="text-xs font-heading text-text">JSON</span>
</div>
<div className="flex items-center gap-1">
{/* Tabs */}
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
<button
onClick={() => setTab("formatted")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Formatted
</button>
<button
onClick={() => setTab("raw")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Raw
</button>
</div>
{/* Copy */}
<button
onClick={handleCopy}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
title="Copy to clipboard"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
{/* Close */}
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div
className="overflow-auto p-3"
style={{ maxHeight: popoverMaxHeight - 41 }}
>
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
{tab === "formatted" ? formattedText : rawText}
</pre>
</div>
</div>,
document.body,
);
}
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
const parsed = safeJsonParse(value);
if (!parsed) return { label: "", isJson: false };
if (Array.isArray(parsed)) {
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
}
const keys = Object.keys(parsed);
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "./TabBar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
const user = userEvent.setup();
describe("TabBar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no tabs", () => {
render(<TabBar />);
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
});
it("renders open tab names", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
render(<TabBar />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("posts")).toBeInTheDocument();
});
it("sets active tab when clicked", async () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
await user.click(screen.getByText("users"));
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
});
it("closes tab when close button clicked", async () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
const closeButton = screen.getByRole("button", {
name: /close users/i,
});
await user.click(closeButton);
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
expect(
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
).toBeUndefined();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
export function TabBar() {
const tabs = useDbViewerStore((state) => state.tabs);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
if (tabs.length === 0) {
return (
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
No tables open
</div>
);
}
return (
<div
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
role="tablist"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<button
type="button"
onClick={() => setActiveTab(tab.id)}
className="flex-1 text-left outline-none cursor-pointer"
>
{tab.table}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
}
+875
View File
@@ -0,0 +1,875 @@
import { useState, useRef, useEffect } from "react";
import {
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { Tooltip } from "../ui/Tooltip";
import type { ColumnInfo } from "../../lib/types";
const AUTO_REFRESH_OPTIONS = [
{ label: "Off", value: 0 },
{ label: "5s", value: 5000 },
{ label: "10s", value: 10_000 },
{ label: "30s", value: 30_000 },
{ label: "1m", value: 60_000 },
{ label: "5m", value: 300_000 },
] as const;
const PAGE_SIZES = [50, 100, 200] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
{ label: "CSV", ext: "csv" },
{ label: "SQL", ext: "sql" },
{ label: "Markdown", ext: "md" },
] as const;
type FilterRule = {
id: string;
column: string;
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
value: string;
};
type SortRule = {
id: string;
column: string;
order: "asc" | "desc";
};
// ─── helpers ────────────────────────────────────────────
function exportData(
rows: unknown[][],
columns: ColumnInfo[],
format: string,
tableName: string,
) {
const headers = columns.map((c) => c.name);
let content: string;
let mime: string;
switch (format) {
case "json": {
const jsonRows = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
content = JSON.stringify(jsonRows, null, 2);
mime = "application/json";
break;
}
case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of rows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
content = csvRows.join("\n");
mime = "text/csv";
break;
}
case "sql": {
const lines = [`-- ${tableName}`];
for (const row of rows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
content = lines.join("\n");
mime = "application/sql";
break;
}
case "md": {
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
for (const row of rows) {
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
}
content = mdRows.join("\n");
mime = "text/markdown";
break;
}
default:
return;
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tableName}.${format === "md" ? "md" : format}`;
a.click();
URL.revokeObjectURL(url);
}
// ─── sub-components ─────────────────────────────────────
function DropdownMenu({
open,
setOpen,
align,
children,
}: {
open: boolean;
setOpen: (v: boolean) => void;
align?: "left" | "right";
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
return (
<div
ref={ref}
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{children}
</div>
);
}
function FilterModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.operator}
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="eq">=</option>
<option value="neq"></option>
<option value="contains">contains</option>
<option value="starts">starts with</option>
<option value="ends">ends with</option>
<option value="gt">&gt;</option>
<option value="lt">&lt;</option>
<option value="null">is null</option>
<option value="notnull">not null</option>
</select>
{rule.operator !== "null" && rule.operator !== "notnull" && (
<input
type="text"
value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
)}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add filter
</button>
</div>
);
}
function SortModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: SortRule[];
onChange: (rules: SortRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<SortRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.order}
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="asc">ASC</option>
<option value="desc">DESC</option>
</select>
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add sort
</button>
</div>
);
}
// ─── bulk actions dropdown ──────────────────────────────
function BulkActionsDropdown({
columns,
selectedRows,
schema,
table,
onClearSelection,
}: {
columns: ColumnInfo[];
selectedRows: unknown[][];
schema: string;
table: string;
onClearSelection: () => void;
}) {
const [open, setOpen] = useState(false);
const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
setOpen(false);
};
const handleCopyJSON = () => {
const json = selectedRows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
copyToClipboard(JSON.stringify(json, null, 2));
};
const handleCopyCSV = () => {
const headers = columns.map((c) => c.name);
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of selectedRows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
copyToClipboard(csvRows.join("\n"));
};
const handleCopySQL = () => {
const headers = columns.map((c) => c.name);
const lines: string[] = [];
for (const row of selectedRows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
copyToClipboard(lines.join("\n"));
};
const handleDeleteSelected = () => {
const pkCol = columns.find((c) => c.is_pk);
for (const row of selectedRows) {
const pk: Record<string, unknown> = {};
if (pkCol) {
const ci = columns.findIndex((c) => c.name === pkCol.name);
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
}
addChange({
type: "delete",
schema,
table,
primaryKey: pk,
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
description: `Delete row from ${table}`,
});
}
setOpen(false);
onClearSelection();
};
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} />
</button>
<DropdownMenu open={open} setOpen={setOpen} align="right">
<button
type="button"
onClick={handleCopyJSON}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileJson size={13} className="text-text-muted" />
Copy as JSON
</button>
<button
type="button"
onClick={handleCopyCSV}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileText size={13} className="text-text-muted" />
Copy as CSV
</button>
<button
type="button"
onClick={handleCopySQL}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<Terminal size={13} className="text-text-muted" />
Copy as SQL INSERT
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={handleDeleteSelected}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
>
<Trash2 size={13} />
Delete selected rows
</button>
</DropdownMenu>
</div>
);
}
// ─── main component ─────────────────────────────────────
interface TableControlsProps {
connectionId: string;
schema: string;
table: string;
columns: ColumnInfo[];
rows: unknown[][];
hiddenColumns: Set<string>;
onToggleColumn: (col: string) => void;
onRefresh: () => void;
filterRules: FilterRule[];
onFilterChange: (rules: FilterRule[]) => void;
sortRules: SortRule[];
onSortChange: (rules: SortRule[]) => void;
selectedCount: number;
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
}
export function TableControls({
connectionId: _connectionId,
schema,
table,
columns,
rows,
hiddenColumns,
onToggleColumn,
onRefresh,
filterRules,
onFilterChange,
sortRules,
onSortChange,
selectedCount,
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
}: TableControlsProps) {
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage);
const setPageSize = useDbViewerStore((s) => s.setPageSize);
const openTab = useDbViewerStore((s) => s.openTab);
const addChange = useDbViewerStore((s) => s.addChange);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const cancelChange = useDbViewerStore((s) => s.cancelChange);
const activeTab = tabs.find((t) => t.id === activeTabId);
// local state
const [filterOpen, setFilterOpen] = useState(false);
const [sortOpen, setSortOpen] = useState(false);
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
// auto-refresh timer
useEffect(() => {
if (autoRefresh === 0) return;
const id = setInterval(onRefresh, autoRefresh);
return () => clearInterval(id);
}, [autoRefresh, onRefresh]);
// pagination
const totalRows = activeTab?.data?.total_rows ?? rows.length;
const pageSize = activeTab?.pageSize ?? 50;
const currentPage = activeTab?.page ?? 1;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
const startRow = (clampedPage - 1) * pageSize + 1;
const endRow = Math.min(clampedPage * pageSize, totalRows);
const handlePrev = () => {
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
};
const handleNext = () => {
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
};
const handleInsertRow = () => {
const newData: Record<string, unknown> = {};
columns.forEach((c) => { newData[c.name] = null; });
addChange({
type: "insert",
schema,
table,
primaryKey: {},
newData,
description: `Insert row into ${table}`,
});
openTab(schema, table);
};
const handleExport = (format: string) => {
exportData(rows, columns, format, table);
setExportOpen(false);
};
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 bg-surface/50 text-xs text-text-muted">
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
type="button"
onClick={handleInsertRow}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Insert row"
>
<Plus size={14} />
</button>
</Tooltip>
{/* Refresh */}
<Tooltip content="Refresh" side="bottom">
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Refresh"
>
<RefreshCw size={14} />
</button>
</Tooltip>
{/* Auto-refresh */}
<div className="relative">
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button
type="button"
onClick={() => setAutoRefreshOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Auto-refresh"
>
<Clock size={14} />
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button>
</Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
{AUTO_REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh === opt.value ? "text-accent" : "text-text"
}`}
>
{autoRefresh === opt.value && <Check size={12} />}
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
</button>
))}
</DropdownMenu>
</div>
<div className="w-px h-4 bg-border mx-1" />
{/* Filter */}
<div className="relative">
<Tooltip content="Column filters" side="bottom">
<button
type="button"
onClick={() => setFilterOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
filterRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Column filters"
>
<Filter size={14} />
{filterRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{filterRules.length}
</span>
)}
</button>
</Tooltip>
<FilterModal
columns={columns}
rules={filterRules}
onChange={onFilterChange}
open={filterOpen}
setOpen={setFilterOpen}
/>
</div>
{/* Sort */}
<div className="relative">
<Tooltip content="Sort rules" side="bottom">
<button
type="button"
onClick={() => setSortOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
sortRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Sort rules"
>
<ArrowUpDown size={14} />
{sortRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{sortRules.length}
</span>
)}
</button>
</Tooltip>
<SortModal
columns={columns}
rules={sortRules}
onChange={onSortChange}
open={sortOpen}
setOpen={setSortOpen}
/>
</div>
{/* Export */}
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
onClick={() => setExportOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Export"
>
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
type="button"
onClick={() => handleExport(fmt.ext)}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{fmt.label}
</button>
))}
</DropdownMenu>
</div>
</div>
{/* ── spacer ──────────────────────────────────── */}
<div className="flex-1" />
{/* ── right side ─────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Action queue button */}
<div className="relative">
<button
type="button"
onClick={() => setQueueOpen((v) => !v)}
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
changesQueue.some((c) => c.status === "pending")
? "text-amber-400 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
aria-label="Action queue"
>
<span className="text-xs font-medium">Queue</span>
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
{changesQueue.filter((c) => c.status === "pending").length}
</span>
)}
</button>
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
</div>
<div className="max-h-64 overflow-y-auto">
{changesQueue.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
)}
{changesQueue.map((item) => (
<div
key={item.id}
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
item.status === "pending" ? "text-text" : "text-text-muted/50"
}`}
>
<span className="truncate flex-1">
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
item.status === "pending" ? "bg-amber-500"
: item.status === "committed" ? "bg-emerald-500"
: "bg-red-500"
}`} />
{item.type.toUpperCase()} {item.table}
{item.description && <span className="ml-1 text-text-muted/50"> {item.description}</span>}
</span>
{item.status === "pending" && (
<button
type="button"
onClick={() => cancelChange(item.id)}
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</DropdownMenu>
</div>
{/* Selected count + bulk actions */}
{selectedCount > 0 && (
<>
<span className="text-accent font-medium tabular-nums">
{selectedCount} selected
</span>
<BulkActionsDropdown
columns={columns}
selectedRows={selectedRows}
schema={schema}
table={table}
onClearSelection={onClearSelection}
/>
<button
type="button"
onClick={onClearSelection}
className="text-text-muted hover:text-text transition-colors cursor-pointer"
aria-label="Clear selection"
>
<X size={14} />
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{/* Columns toggle */}
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
onClick={() => setColumnMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Toggle columns"
>
<Columns size={14} />
</button>
</Tooltip>
<DropdownMenu open={columnMenuOpen} setOpen={setColumnMenuOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">Visible columns</div>
<div className="max-h-64 overflow-y-auto">
{columns.map((col) => (
<button
key={col.name}
type="button"
onClick={() => onToggleColumn(col.name)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
hiddenColumns.has(col.name) ? "border-border bg-transparent" : "border-accent bg-accent"
}`}>
{!hiddenColumns.has(col.name) && <Check size={10} className="text-white" />}
</span>
<span className="truncate">{col.name}</span>
</button>
))}
</div>
</DropdownMenu>
</div>
<div className="w-px h-4 bg-border" />
{/* Row count */}
<span className="tabular-nums">
{startRow}-{endRow} of {totalRows}
</span>
{/* Page size */}
<select
value={pageSize}
onChange={handlePageSizeChange}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{/* Pagination */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handlePrev}
disabled={clampedPage <= 1}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Previous page"
>
<ChevronLeft size={14} />
</button>
<span className="tabular-nums min-w-[3rem] text-center">
{clampedPage}/{totalPages}
</span>
<button
type="button"
onClick={handleNext}
disabled={clampedPage >= totalPages}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Next page"
>
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableOverflowMenu } from "./TableOverflowMenu";
describe("TableOverflowMenu", () => {
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn() },
configurable: true,
writable: true,
});
});
it("renders menu trigger button", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
});
it("shows menu options on click", async () => {
const user = userEvent.setup();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
await user.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
});
it("fires onOpenTab when menu item clicked", async () => {
const user = userEvent.setup();
const onOpenTab = vi.fn().mockReturnValue("tab-1");
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
await user.click(screen.getByLabelText(/table options/i));
await user.click(screen.getByText("Open in new tab"));
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
});
});
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { MoreVertical } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog";
interface TableOverflowMenuProps {
schema: string;
table: string;
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
}
interface MenuItem {
id: string;
label: string;
stub?: boolean;
danger?: boolean;
}
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleAction = (id: string) => {
switch (id) {
case "open":
onOpenTab(schema, table, true);
setOpen(false);
break;
case "copy-schema": {
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
if (navigator.clipboard) {
void navigator.clipboard.writeText(sql);
}
setOpen(false);
break;
}
case "empty":
setConfirmAction("empty");
setOpen(false);
break;
case "delete":
setConfirmAction("delete");
setOpen(false);
break;
default:
break;
}
};
const items: MenuItem[] = [
{ id: "open", label: "Open in new tab" },
{ id: "copy-schema", label: "Copy table schema" },
{ id: "export-csv", label: "Export data (CSV)", stub: true },
{ id: "export-json", label: "Export data (JSON)", stub: true },
{ id: "export-sql", label: "Export data (SQL)", stub: true },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
return (
<div className="relative" ref={menuRef}>
<button
aria-label="Table options"
onClick={() => setOpen((o) => !o)}
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical size={14} />
</button>
{open && (
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleAction(item.id)}
disabled={item.stub}
className={[
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
item.stub ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
>
<span>{item.label}</span>
{item.stub && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
Soon
</span>
)}
</button>
))}
</div>
)}
{confirmAction === "empty" && (
<ConfirmDialog
open
title={`Empty Table: ${table}`}
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
confirmLabel="Empty Table"
onConfirm={() => {
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "delete" && (
<ConfirmDialog
open
title={`Delete Table: ${table}`}
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
confirmLabel="Delete Table"
onConfirm={() => {
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableTree } from "./TableTree";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("TableTree", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders table names from store", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{ name: "users", schema: "public", table_type: "TABLE" },
{ name: "orders", schema: "public", table_type: "TABLE" },
],
});
render(<TableTree />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("orders")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
});
render(<TableTree />);
await user.click(screen.getByText("users"));
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
});
});
+114
View File
@@ -0,0 +1,114 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
import { abbreviateType } from "../../lib/utils";
import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) {
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [columnCache, setColumnCache] = useState<Record<string, ColumnInfo[]>>({});
const q = (searchQuery ?? "").toLowerCase().trim();
const filteredTables = (currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables).filter((t) => !q || t.name.toLowerCase().includes(q));
const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (isExpanded) next.delete(key);
else next.add(key);
return next;
});
// Fetch columns if not cached
if (!isExpanded && !columnCache[key] && connectionId) {
try {
const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0);
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
} catch { /* ignore, columns will remain unknowns */ }
}
};
const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => {
openTab(schema, table, forceNew);
return "tab";
};
return (
<div className="py-2">
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">No tables</div>
)}
{filteredTables.map((table) => {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => openTab(table.schema, table.name)}
>
<button
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
toggle(key, table.schema, table.name);
}}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Table2 size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}
table={table.name}
onOpenTab={handleOpenTab}
/>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{cols.length === 0 && (
<div className="text-xs text-text-muted">No columns</div>
)}
{cols.map((col) => (
<div
key={col.name}
className="flex items-center gap-2 text-xs text-text-muted"
title={col.is_fk && col.fk_ref
? `${col.data_type}${col.fk_ref[0]}.${col.fk_ref[1]}`
: col.data_type}
>
{col.is_pk ? (
<Key size={12} className="text-accent shrink-0" />
) : col.is_fk ? (
<Key size={12} className="text-amber-400 shrink-0" />
) : (
<Type size={12} className="shrink-0" />
)}
<span className="truncate">{col.name}</span>
<span className="text-text-subtle truncate" title={col.data_type}>{abbreviateType(col.data_type)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { CreateFolderDialog } from "./CreateFolderDialog";
const folders = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const sampleTags = [
{ id: "t1", name: "Production", color: "#ef4444", created_at: "", updated_at: "" },
{ id: "t2", name: "Staging", color: "#3b82f6", created_at: "", updated_at: "" },
];
describe("CreateFolderDialog", () => {
it("calls onCreate with name, parent, and tags", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={fn} onClose={() => {}} />);
await user.type(screen.getByPlaceholderText(/folder name/i), "New Folder");
await user.click(screen.getByText(/create/i));
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "New Folder", parent_id: null, tag_ids: [] }));
});
it("auto-sets parent to current folder", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<CreateFolderDialog open parentOptions={folders} tags={[]} currentFolderId="f1" onCreate={fn} onClose={() => {}} />);
expect(screen.getByText("Work")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText(/folder name/i), "Sub Folder");
await user.click(screen.getByText(/create/i));
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "Sub Folder", parent_id: "f1", tag_ids: [] }));
});
it("does not call onCreate when name empty", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={fn} onClose={() => {}} />);
await user.click(screen.getByText(/create/i));
expect(fn).not.toHaveBeenCalled();
});
it("removes content from DOM after exit animation", async () => {
const { rerender } = render(
<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={() => {}} />,
);
expect(screen.getByText("New Folder")).toBeInTheDocument();
rerender(
<CreateFolderDialog open={false} parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={() => {}} />,
);
await waitForElementToBeRemoved(() => screen.queryByText("New Folder"));
expect(screen.queryByText("New Folder")).not.toBeInTheDocument();
});
it("calls onCreate when Escape is pressed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={onClose} />);
await user.keyboard("{Escape}");
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows tags and includes selected tags in onCreate", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<CreateFolderDialog open parentOptions={folders} tags={sampleTags} onCreate={fn} onClose={() => {}} />);
expect(screen.getByPlaceholderText(/search tags/i)).toBeInTheDocument();
expect(screen.getByText("Production")).toBeInTheDocument();
expect(screen.getByText("Staging")).toBeInTheDocument();
await user.click(screen.getByText("Production"));
await user.type(screen.getByPlaceholderText(/folder name/i), "Tagged Folder");
await user.click(screen.getByText(/create/i));
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({
name: "Tagged Folder",
parent_id: null,
tag_ids: ["t1"],
}),
);
});
});
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import type { Folder, Tag } from "../../lib/types";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Folder as FolderIcon } from "lucide-react";
import { useNotificationStore } from "../../stores/notificationStore";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
interface CreateFolderDialogProps {
open: boolean;
parentOptions: Folder[];
currentFolderId?: string | null;
tags: Tag[];
onCreate: (input: { name: string; parent_id: string | null; tag_ids: string[] }) => void;
onClose: () => void;
}
export function CreateFolderDialog({ open, parentOptions, currentFolderId = null, tags, onCreate, onClose }: CreateFolderDialogProps) {
const [name, setName] = useState("");
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const notify = useNotificationStore((s) => s.notify);
const parentName = currentFolderId
? parentOptions.find((f) => f.id === currentFolderId)?.name ?? null
: null;
useEffect(() => {
if (open) {
setName("");
setSelectedTagIds([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const handleCreate = () => {
const trimmed = name.trim();
if (!trimmed) {
notify("Name must not be empty", "error");
return;
}
onCreate({ name: trimmed, parent_id: currentFolderId ?? null, tag_ids: selectedTagIds });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
}
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
);
};
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-96">
<h3 className="font-heading text-text text-lg mb-1">New Folder</h3>
{parentName && (
<div className="flex items-center gap-1.5 text-xs text-text-muted mb-4">
<FolderIcon size={12} />
<span>{parentName}</span>
</div>
)}
<Input
ref={inputRef}
placeholder="Folder name"
value={name}
onChange={setName}
onKeyDown={handleKeyDown}
/>
{tags.length > 0 && (
<SearchableTagPicker
tags={tags}
selectedTagIds={selectedTagIds}
onToggle={toggleTag}
/>
)}
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleCreate}>Create</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { EditFolderDialog } from "./EditFolderDialog";
const folder = {
id: "f1",
name: "Work",
parent_id: null,
tag_ids: ["t1"],
created_at: "",
updated_at: "",
};
const tags = [{ id: "t1", name: "red", color: "#ff0000", created_at: "", updated_at: "" }];
describe("EditFolderDialog", () => {
it("calls onSave with updated name and tags", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<EditFolderDialog open folder={folder} tags={tags} onSave={fn} onClose={() => {}} />);
await user.clear(screen.getByPlaceholderText(/folder name/i));
await user.type(screen.getByPlaceholderText(/folder name/i), "Work Updated");
await user.click(screen.getByText(/save/i));
expect(fn).toHaveBeenCalledWith("f1", expect.objectContaining({ name: "Work Updated", tag_ids: ["t1"] }));
});
it("does not call onSave when name is empty", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<EditFolderDialog open folder={folder} tags={tags} onSave={fn} onClose={() => {}} />);
await user.clear(screen.getByPlaceholderText(/folder name/i));
await user.click(screen.getByText(/save/i));
expect(fn).not.toHaveBeenCalled();
});
it("removes content from DOM after exit animation", async () => {
const { rerender } = render(
<EditFolderDialog open folder={folder} tags={tags} onSave={vi.fn()} onClose={() => {}} />,
);
expect(screen.getByText("Edit Folder")).toBeInTheDocument();
rerender(<EditFolderDialog open={false} folder={folder} tags={tags} onSave={vi.fn()} onClose={() => {}} />);
await waitForElementToBeRemoved(() => screen.queryByText("Edit Folder"));
expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument();
});
it("renders nothing when folder is null", () => {
render(<EditFolderDialog open folder={null} tags={tags} onSave={vi.fn()} onClose={() => {}} />);
expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument();
});
it("calls onClose when Escape is pressed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<EditFolderDialog open folder={folder} tags={tags} onSave={vi.fn()} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";
import type { Folder, Tag } from "../../lib/types";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { AnimatedModal } from "../ui/AnimatedModal";
import { useNotificationStore } from "../../stores/notificationStore";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
interface EditFolderDialogProps {
open: boolean;
folder: Folder | null;
tags: Tag[];
onSave: (id: string, input: { name: string; tag_ids: string[] }) => void;
onClose: () => void;
}
export function EditFolderDialog({ open, folder, tags, onSave, onClose }: EditFolderDialogProps) {
const [name, setName] = useState("");
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const notify = useNotificationStore((s) => s.notify);
useEffect(() => {
if (open && folder) {
setName(folder.name);
setSelectedTagIds(folder.tag_ids);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open, folder]);
const handleSave = () => {
if (!folder) return;
const trimmed = name.trim();
if (!trimmed) {
notify("Name must not be empty", "error");
return;
}
onSave(folder.id, { name: trimmed, tag_ids: selectedTagIds });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
};
const toggleTag = (tagId: string) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
);
};
return (
<AnimatedModal open={open} onClose={onClose}>
{folder ? (
<div className="w-96">
<h3 className="font-heading text-text text-lg mb-4">Edit Folder</h3>
<Input
ref={inputRef}
placeholder="Folder name"
value={name}
onChange={setName}
onKeyDown={handleKeyDown}
/>
{tags.length > 0 && (
<SearchableTagPicker
tags={tags}
selectedTagIds={selectedTagIds}
onToggle={toggleTag}
/>
)}
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</div>
) : null}
</AnimatedModal>
);
}
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FolderBreadcrumb } from "./FolderBreadcrumb";
import type { Folder } from "../../lib/types";
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
];
describe("FolderBreadcrumb", () => {
it("shows root when no folder active", () => {
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} />);
expect(screen.getByText("All Connections")).toBeInTheDocument();
});
it("shows path to active folder", () => {
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={() => {}} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Client A")).toBeInTheDocument();
});
it("navigates when clicking a breadcrumb item", async () => {
const fn = vi.fn();
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={fn} />);
await userEvent.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
it("navigates to root", async () => {
const fn = vi.fn();
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={fn} />);
await userEvent.click(screen.getByText("All Connections"));
expect(fn).toHaveBeenCalledWith(null);
});
});
@@ -0,0 +1,40 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Home } from "lucide-react";
import { getFolderPath } from "../../lib/utils";
interface FolderBreadcrumbProps {
folders: Folder[];
activeFolderId: string | null;
onNavigate: (folderId: string | null) => void;
}
export function FolderBreadcrumb({ folders, activeFolderId, onNavigate }: FolderBreadcrumbProps) {
const path = getFolderPath(folders, activeFolderId);
return (
<nav className="flex items-center gap-1 text-sm text-text-muted">
<button
onClick={() => onNavigate(null)}
className={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors ${
activeFolderId === null ? "text-text" : "hover:text-text hover:bg-surface-raised"
}`}
>
<Home size={14} />
<span>All Connections</span>
</button>
{path.map((folder) => (
<div key={folder.id} className="flex items-center gap-1">
<ChevronRight size={14} />
<button
onClick={() => onNavigate(folder.id)}
className={`px-2 py-1 rounded-md transition-colors ${
folder.id === activeFolderId ? "text-text" : "hover:text-text hover:bg-surface-raised"
}`}
>
{folder.name}
</button>
</div>
))}
</nav>
);
}
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FolderTree } from "./FolderTree";
import type { Folder } from "../../lib/types";
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: "" },
];
describe("FolderTree", () => {
it("renders all folders", () => {
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("ClientA")).toBeInTheDocument();
});
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} />);
await user.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
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} />);
await user.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { Folder } from "../../lib/types";
import { ChevronRight, Folder as FolderIcon } from "lucide-react";
interface FolderTreeProps {
folders: Folder[];
activeFolderId: string | null;
onSelect: (id: string | null) => void;
}
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 renderFolder = (folder: Folder, depth: number) => {
const isActive = activeFolderId === folder.id;
return (
<div key={folder.id}>
<button
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"}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FolderIcon size={14} /> {folder.name}
</button>
{childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))}
</div>
);
};
return (
<div className="space-y-0.5">
<button
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"}`}
>
<ChevronRight size={14} /> All Connections
</button>
{roots.map((r) => renderFolder(r, 0))}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ActionRow } from "./ActionRow";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => useUiStore.setState({ activeView: "home" }));
describe("ActionRow", () => {
it("renders Saved Connections title", () => {
render(<ActionRow />);
expect(screen.getByText("Saved Connections")).toBeInTheDocument();
});
it("New Connection button switches view", async () => {
render(<ActionRow />);
await userEvent.click(screen.getByText(/new connection/i));
expect(useUiStore.getState().activeView).toBe("new-connection");
});
it("Settings button switches view", async () => {
render(<ActionRow />);
await userEvent.click(screen.getByText(/settings/i));
expect(useUiStore.getState().activeView).toBe("settings");
});
it("Tags button switches to settings view", async () => {
render(<ActionRow />);
await userEvent.click(screen.getByText(/^tags$/i));
expect(useUiStore.getState().activeView).toBe("settings");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Settings as SettingsIcon, Tag, Filter, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
import { Button } from "../ui/Button";
import { useUiStore } from "../../stores/uiStore";
import { ImportExportMenu } from "./ImportExportMenu";
interface ActionRowProps {
onImport?: () => void;
onExport?: () => void;
onNewFolder?: () => void;
onFilters?: () => void;
onDeleteSelected?: () => void;
visibleItemIds?: string[];
}
export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
const setActiveView = useUiStore((s) => s.setActiveView);
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const selectAllItems = useUiStore((s) => s.selectAllItems);
const clearSelection = useUiStore((s) => s.clearSelection);
const hasSelection = selectedItemIds.length > 0;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuOpen]);
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center justify-between w-full">
<h2 className="font-heading text-lg text-text">Saved Connections</h2>
<div className="flex items-center gap-2">
<Button onClick={() => setActiveView("new-connection")}>
<Plus size={14} /> New Connection
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
<Tag size={14} /> Tags
</Button>
<Button variant="ghost" className="text-xs" onClick={onFilters ?? (() => {})}>
<Filter size={14} /> Filters
</Button>
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
<FolderPlus size={14} /> New Folder
</Button>
{hasSelection && (
<div className="relative" ref={menuRef}>
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
{selectedItemIds.length} selected <ChevronDown size={12} />
</Button>
{menuOpen && (
<div className="absolute left-0 mt-1 rounded-xl bg-surface border border-border py-1 z-10 min-w-[180px] shadow-lg">
<button
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
onClick={() => { selectAllItems(visibleItemIds); setMenuOpen(false); }}
>
<Check size={14} /> Select All
</button>
<button
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
onClick={() => { clearSelection(); setMenuOpen(false); }}
>
<X size={14} /> Clear Selection
</button>
<div className="border-t border-border my-1" />
<button
className="flex items-center gap-2 px-3 py-2 text-sm !text-red-400 hover:!text-red-300 hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
onClick={() => { onDeleteSelected?.(); setMenuOpen(false); }}
>
<Trash2 size={14} /> Delete ({selectedItemIds.length})
</button>
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<ImportExportMenu onImport={onImport ?? (() => {})} onExport={onExport ?? (() => {})} />
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
<SettingsIcon size={14} /> Settings
</Button>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HomeScreen } from "./HomeScreen";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
vi.mock("../../lib/commands", () => ({
getConnections: vi.fn().mockResolvedValue([]),
getFolders: vi.fn().mockResolvedValue([]),
getTags: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() }));
vi.mock("@tauri-apps/plugin-fs", () => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}));
describe("HomeScreen", () => {
beforeEach(() => {
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home" });
});
it("renders SearchBar and ActionRow", () => {
render(<HomeScreen />);
expect(screen.getByPlaceholderText(/search connections/i)).toBeInTheDocument();
expect(screen.getByText("New Connection")).toBeInTheDocument();
});
it("renders empty state when no connections", () => {
useConnectionStore.setState({ connections: [] });
render(<HomeScreen />);
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
});
it("opens new connection screen when a connection string is typed in search", async () => {
const user = userEvent.setup();
render(<HomeScreen />);
const input = screen.getByPlaceholderText(/search connections/i);
await user.type(input, "postgresql://user:pass@localhost:5432/mydb");
expect(useUiStore.getState().activeView).toBe("new-connection");
expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb");
expect(useUiStore.getState().searchQuery).toBe("");
});
});
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useConnectionStore } from "../../stores/connectionStore";
import { useUiStore } from "../../stores/uiStore";
import { useFilteredConnections } from "../../hooks/useConnections";
import { useSortedTags } from "../../hooks/useSortedTags";
import { SearchBar } from "../search/SearchBar";
import type { SearchBarHandle } from "../search/SearchBar";
import { ActionRow } from "./ActionRow";
import { ConnectionGrid } from "../connections/ConnectionGrid";
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
import { EditFolderDialog } from "../folders/EditFolderDialog";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { handleImport, handleExport } from "../../lib/importExport";
import { getChildFolders } from "../../lib/utils";
import { useShortcut } from "../../hooks/useShortcut";
import type { Folder } from "../../lib/types";
export function HomeScreen() {
const connections = useFilteredConnections();
const tags = useSortedTags();
const folders = useConnectionStore((s) => s.folders);
const activeFolderId = useUiStore((s) => s.activeFolderId);
const setActiveFolderId = useUiStore((s) => s.setActiveFolderId);
const searchQuery = useUiStore((s) => s.searchQuery);
const toggleTag = useUiStore((s) => s.toggleTag);
const createFolder = useConnectionStore((s) => s.createFolder);
const updateFolder = useConnectionStore((s) => s.updateFolder);
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
const loadAll = useConnectionStore((s) => s.loadAll);
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const clearSelection = useUiStore((s) => s.clearSelection);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [editFolder, setEditFolder] = useState<Folder | null>(null);
const [confirmDelete, setConfirmDelete] = useState<{
type: "folder" | "selected";
folder?: Folder;
} | null>(null);
const searchRef = useRef<SearchBarHandle>(null);
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const setPrefilledConnectionString = useUiStore(
(s) => s.setPrefilledConnectionString,
);
const setActiveView = useUiStore((s) => s.setActiveView);
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
const handleOpenDbViewer = (connectionId: string) => {
setActiveConnectionId(connectionId);
setActiveView("db-viewer");
};
const handleSearchUrl = (url: string) => {
setSearchQuery("");
setPrefilledConnectionString(url);
setActiveView("new-connection");
};
// Cmd+K to focus search (configurable in Settings → Shortcuts)
useShortcut("command_palette", () => {
searchRef.current?.focus();
});
const currentFolderId =
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
? activeFolderId
: null;
const visibleFolderIds = useMemo(
() => getChildFolders(folders, currentFolderId).map((f) => f.id),
[folders, currentFolderId],
);
const visibleConnectionIds = useMemo(
() =>
connections
.filter((c) => c.folder_id === currentFolderId)
.map((c) => c.id),
[connections, currentFolderId],
);
const visibleItemIds = useMemo(
() => [...visibleFolderIds, ...visibleConnectionIds],
[visibleFolderIds, visibleConnectionIds],
);
// Reset to root if the active folder no longer exists
useEffect(() => {
if (
activeFolderId !== null &&
!folders.some((f) => f.id === activeFolderId)
) {
setActiveFolderId(null);
}
}, [folders, activeFolderId, setActiveFolderId]);
const executeDeleteSelected = async () => {
const folderIds = new Set(folders.map((f) => f.id));
for (const id of selectedItemIds) {
try {
if (folderIds.has(id)) {
await deleteFolder(id);
} else {
await deleteConnection(id);
}
} catch (e) {
console.error("Failed to delete item:", e);
}
}
clearSelection();
setConfirmDelete(null);
};
const executeDeleteFolder = async (folder: Folder) => {
try {
await deleteFolder(folder.id);
if (activeFolderId === folder.id) {
setActiveFolderId(null);
}
} catch (e) {
console.error("Failed to delete folder:", e);
}
setConfirmDelete(null);
};
return (
<main className="min-h-screen p-6 bg-canvas select-none max-w-7xl mx-auto">
<div className="mb-6">
<SearchBar ref={searchRef} onDetectUrl={handleSearchUrl} />
</div>
<div className="mb-4">
<ActionRow
onNewFolder={() => setFolderDialogOpen(true)}
onImport={async () => {
const r = await handleImport();
if (r) await loadAll();
}}
onExport={async () => {
await handleExport();
}}
onDeleteSelected={() =>
setConfirmDelete({ type: "selected" })
}
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 })
}
/>
<CreateFolderDialog
open={folderDialogOpen}
parentOptions={folders}
currentFolderId={activeFolderId}
tags={tags}
onCreate={async (input) => {
try {
await createFolder(input);
} catch (e) {
console.error("Failed to create folder:", e);
}
setFolderDialogOpen(false);
}}
onClose={() => setFolderDialogOpen(false)}
/>
<EditFolderDialog
open={editFolder !== null}
folder={editFolder}
tags={tags}
onSave={async (id, input) => {
try {
const folder = folders.find((f) => f.id === id);
await updateFolder(id, {
name: input.name,
parent_id: folder?.parent_id ?? null,
tag_ids: input.tag_ids,
});
} catch (e) {
console.error("Failed to update folder:", e);
}
setEditFolder(null);
}}
onClose={() => setEditFolder(null)}
/>
{confirmDelete?.type === "selected" && (
<ConfirmDialog
open
title="Delete Items"
message={`Are you sure you want to delete ${selectedItemIds.length} item${selectedItemIds.length !== 1 ? "s" : ""}?`}
confirmLabel="Delete"
confirmVariant="ghost"
onConfirm={executeDeleteSelected}
onCancel={() => setConfirmDelete(null)}
/>
)}
{confirmDelete?.type === "folder" && confirmDelete.folder && (
<ConfirmDialog
open
title="Delete Folder"
message={`Are you sure you want to delete "${confirmDelete.folder.name}"? Any items inside this folder will be moved to the parent folder.`}
confirmLabel="Delete"
confirmVariant="ghost"
onConfirm={() => executeDeleteFolder(confirmDelete.folder!)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</main>
);
}
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
import { Download, Upload, ChevronDown } from "lucide-react";
import { Button } from "../ui/Button";
interface ImportExportMenuProps {
onImport: () => void;
onExport: () => void;
}
export function ImportExportMenu({ onImport, onExport }: ImportExportMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<Button variant="secondary" onClick={() => setOpen((o) => !o)}>
Import / Export <ChevronDown size={14} />
</Button>
{open && (
<div className="absolute right-0 mt-2 rounded-xl bg-surface border border-border py-1 z-10 min-w-[160px] shadow-lg">
<button className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer" onClick={() => { setOpen(false); onImport(); }}>
<Upload size={14} /> Import Connections
</button>
<button className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer" onClick={() => { setOpen(false); onExport(); }}>
<Download size={14} /> Export Connections
</button>
</div>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import { SearchBar } from "./SearchBar";
import { useUiStore } from "../../stores/uiStore";
beforeEach(() => { useUiStore.setState({ searchQuery: "" }); vi.useFakeTimers(); });
afterEach(() => vi.useRealTimers());
describe("SearchBar", () => {
it("renders a search input", () => {
render(<SearchBar />);
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
it("updates store after 150ms debounce", () => {
render(<SearchBar />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: "prod" } });
act(() => vi.advanceTimersByTime(149));
expect(useUiStore.getState().searchQuery).toBe("");
act(() => vi.advanceTimersByTime(1));
expect(useUiStore.getState().searchQuery).toBe("prod");
});
it("calls onDetectUrl when a connection string is typed", () => {
const onDetectUrl = vi.fn();
render(<SearchBar onDetectUrl={onDetectUrl} />);
const input = screen.getByPlaceholderText(/search/i);
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Search, Command } from "lucide-react";
import { Input } from "../ui/Input";
import { useUiStore } from "../../stores/uiStore";
import { looksLikeConnectionString } from "../../lib/connectionString";
export interface SearchBarHandle {
focus: () => void;
}
interface SearchBarProps {
onDetectUrl?: (url: string) => void;
}
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function SearchBar({ onDetectUrl }, ref) {
const [value, setValue] = useState("");
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}));
return (
<div className="flex items-center justify-center w-full">
<div className="relative w-full max-w-xl">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={16} />
<Input
ref={inputRef}
value={value}
placeholder="Search connections, folders, tags... or paste a DB URL"
onChange={(v) => {
setValue(v);
if (looksLikeConnectionString(v)) {
onDetectUrl?.(v);
return;
}
window.clearTimeout((window as any).__sb);
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
}}
className="pl-10 pr-14"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
<Command size={10} />
<span>K</span>
</div>
</div>
</div>
);
});
@@ -0,0 +1,69 @@
import { useSettingsStore } from "../../stores/settingsStore";
import { Input } from "../ui/Input";
import { Toggle } from "../ui/Toggle";
import { SettingsRow } from "../ui/SettingsRow";
import { SettingsSection } from "../ui/SettingsSection";
import type { DbType } from "../../lib/types";
const DB_TYPES: { id: DbType; label: string }[] = [
{ id: "postgresql", label: "PostgreSQL" },
{ id: "mysql", label: "MySQL" },
{ id: "sqlite", label: "SQLite" },
{ id: "redis", label: "Redis" },
];
export function AdvancedSettingsTab() {
const { settings, updateSetting } = useSettingsStore();
if (!settings) return null;
const defaultPorts = settings.default_ports ?? {
postgresql: 5432,
mysql: 3306,
sqlite: null,
redis: 6379,
};
const updatePort = (key: string, value: string) => {
const port = value === "" ? null : Number(value);
const next = { ...defaultPorts, [key]: port };
updateSetting("default_ports", JSON.stringify(next));
};
return (
<>
<SettingsSection title="Safety">
<SettingsRow
title="Confirm before delete"
description="Show a confirmation dialog before deleting connections or folders."
>
<Toggle
checked={settings.confirm_before_delete}
onChange={(checked) =>
updateSetting("confirm_before_delete", checked ? "true" : "false")
}
label="Confirm before delete"
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Default ports">
{DB_TYPES.map((db) => (
<SettingsRow
key={db.id}
title={db.label}
description={`Default port for new ${db.label} connections.`}
>
<Input
type="number"
value={defaultPorts[db.id]?.toString() ?? ""}
onChange={(value) => updatePort(db.id, value)}
className="w-24"
aria-label={`Default port for ${db.label}`}
/>
</SettingsRow>
))}
</SettingsSection>
</>
);
}
@@ -0,0 +1,129 @@
import { useSettingsStore } from "../../stores/settingsStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { Select } from "../ui/Select";
import { ThemePicker } from "../ui/ThemePicker";
import { SettingsRow } from "../ui/SettingsRow";
import { SettingsSection } from "../ui/SettingsSection";
import * as cmd from "../../lib/commands";
import type { FontSize } from "../../lib/types";
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
];
const REFRESH_RATE_OPTIONS = [
{ value: "0", label: "Off" },
{ value: "5000", label: "5 seconds" },
{ value: "10000", label: "10 seconds" },
{ value: "30000", label: "30 seconds" },
{ value: "60000", label: "1 minute" },
{ value: "300000", label: "5 minutes" },
];
const PAGE_SIZE_OPTIONS = [
{ value: "50", label: "50 rows" },
{ value: "100", label: "100 rows" },
{ value: "200", label: "200 rows" },
{ value: "500", label: "500 rows" },
];
export function GeneralSettingsTab() {
const { settings, updateSetting, load } = useSettingsStore();
const folders = useConnectionStore((s) => s.folders);
if (!settings) return null;
const folderOptions = [
{ value: "", label: "None" },
...folders.map((f) => ({ value: f.id, label: f.name })),
];
const handleReAddDemo = async () => {
try {
await cmd.recreateDemoDb();
await load();
} catch (e) {
// ignore
}
};
return (
<>
<SettingsSection title="Appearance">
<SettingsRow title="Theme" description="Choose your preferred appearance.">
<ThemePicker
value={settings.theme}
onChange={(theme) => updateSetting("theme", theme)}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Interface">
<SettingsRow title="Font size" description="Adjust the application font size.">
<Select
value={settings.font_size}
onChange={(value) => updateSetting("font_size", value)}
options={FONT_SIZE_OPTIONS}
label="Font size"
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Workspace">
<SettingsRow
title="Default folder"
description="Select the folder to show on startup."
>
<Select
value={settings.default_folder_id ?? ""}
onChange={(value) => updateSetting("default_folder_id", value)}
options={folderOptions}
label="Default folder"
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Table defaults">
<SettingsRow
title="Auto-refresh rate"
description="How often tables auto-refresh by default."
>
<Select
value={String(settings.table_refresh_rate ?? 0)}
onChange={(value) => updateSetting("table_refresh_rate", value)}
options={REFRESH_RATE_OPTIONS}
label="Auto-refresh rate"
/>
</SettingsRow>
<SettingsRow
title="Rows per page"
description="Default number of rows shown per page."
>
<Select
value={String(settings.table_page_size ?? 50)}
onChange={(value) => updateSetting("table_page_size", value)}
options={PAGE_SIZE_OPTIONS}
label="Rows per page"
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Demo">
<SettingsRow
title="Re-add demo database"
description="Re-create the demo SQLite connection if it was deleted."
>
<button
type="button"
onClick={handleReAddDemo}
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
>
Re-add demo
</button>
</SettingsRow>
</SettingsSection>
</>
);
}
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsPage } from "./SettingsPage";
import { GeneralSettingsTab } from "./GeneralSettingsTab";
import { TagsSettingsTab } from "./TagsSettingsTab";
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
import { useSettingsStore } from "../../stores/settingsStore";
import { useConnectionStore } from "../../stores/connectionStore";
import * as commands from "../../lib/commands";
vi.mock("motion/react", () => ({
motion: {
div: React.forwardRef((props: any, ref: any) => (
<div ref={ref} {...props} />
)),
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
vi.mock("../../lib/commands", () => ({
getSettings: vi.fn().mockResolvedValue({
theme: "system",
font_size: "medium",
default_folder_id: null,
confirm_before_delete: true,
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
tag_order: null,
}),
updateSetting: vi.fn().mockResolvedValue(undefined),
getConnections: vi.fn().mockResolvedValue([]),
getFolders: vi.fn().mockResolvedValue([]),
getTags: vi.fn().mockResolvedValue([]),
createConnection: vi.fn().mockResolvedValue({}),
deleteConnection: vi.fn().mockResolvedValue(undefined),
addConnectionTags: vi.fn().mockResolvedValue(undefined),
createFolder: vi.fn().mockResolvedValue({}),
updateFolder: vi.fn().mockResolvedValue({}),
deleteFolder: vi.fn().mockResolvedValue(undefined),
addFolderTags: vi.fn().mockResolvedValue(undefined),
createTag: vi.fn().mockResolvedValue({}),
updateTag: vi.fn().mockResolvedValue({}),
deleteTag: vi.fn().mockResolvedValue(undefined),
importConnections: vi.fn().mockResolvedValue({ imported: 0, skipped: 0, skippedRecords: [] }),
exportConnections: vi.fn().mockResolvedValue(""),
}));
const mockFolders = [
{ id: "folder-1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "folder-2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const baseSettings = {
theme: "system" as const,
font_size: "medium" as const,
default_folder_id: null,
confirm_before_delete: true,
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
tag_order: null,
table_refresh_rate: 0,
table_page_size: 50,
shortcuts: {} as Record<string, string>,
};
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
settings: baseSettings,
loading: false,
error: null,
});
useConnectionStore.setState({
connections: [],
folders: mockFolders,
tags: [],
tagOrder: [],
loading: false,
error: null,
});
});
it("renders settings header with back button and title", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument();
});
expect(screen.getByText(/back/i)).toBeInTheDocument();
});
it("renders all five sidebar tabs with proper ARIA roles", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /general/i })).toBeInTheDocument();
});
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
it("shows the General tab by default and marks it selected", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
});
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
});
it("switches to the Editor tab and shows placeholder", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("tab", { name: /editor/i }));
expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
});
it("switches to the Tags tab and shows accessible tag management", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("tab", { name: /tags/i }));
expect(screen.getByText(/create tag/i)).toBeInTheDocument();
expect(screen.getByText(/manage tags/i)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags");
});
it("switches to the Shortcuts tab and shows keyboard shortcuts", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("tab", { name: /shortcuts/i }));
expect(screen.getByText(/command palette/i)).toBeInTheDocument();
});
it("switches to the Advanced tab and shows safety and ports settings", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("tab", { name: /advanced/i }));
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
expect(screen.getByText(/default ports/i)).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /advanced/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-advanced");
});
it("calls updateSetting with the correct key and value when a setting changes", async () => {
const user = userEvent.setup();
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("radio", { name: /dark/i }));
await waitFor(() => {
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "dark");
});
});
});
describe("GeneralSettingsTab", () => {
beforeEach(() => {
useSettingsStore.setState({
settings: baseSettings,
loading: false,
error: null,
});
useConnectionStore.setState({
connections: [],
folders: mockFolders,
tags: [],
tagOrder: [],
loading: false,
error: null,
});
});
it("renders appearance, interface, and workspace sections", () => {
render(<GeneralSettingsTab />);
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument();
});
});
describe("TagsSettingsTab", () => {
beforeEach(() => {
useConnectionStore.setState({
connections: [],
folders: [],
tags: [
{ id: "tag-1", name: "Production", color: "#ef4444", created_at: "" },
{ id: "tag-2", name: "Staging", color: "#3b82f6", created_at: "" },
],
tagOrder: ["tag-1", "tag-2"],
loading: false,
error: null,
});
});
it("renders tag creation and management controls with accessible labels", () => {
render(<TagsSettingsTab />);
expect(screen.getByLabelText(/new tag name/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
expect(screen.getByText(/production/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /delete tag production/i })).toBeInTheDocument();
const moveUpButtons = screen.getAllByRole("button", { name: /move tag up/i });
expect(moveUpButtons.length).toBeGreaterThanOrEqual(2);
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
});
});
describe("AdvancedSettingsTab", () => {
beforeEach(() => {
useSettingsStore.setState({
settings: baseSettings,
loading: false,
error: null,
});
});
it("renders safety toggle and labeled default port inputs", () => {
render(<AdvancedSettingsTab />);
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
expect(screen.getByLabelText(/default port for postgresql/i)).toBeInTheDocument();
expect(screen.getByLabelText(/default port for mysql/i)).toBeInTheDocument();
expect(screen.getByLabelText(/default port for sqlite/i)).toBeInTheDocument();
expect(screen.getByLabelText(/default port for redis/i)).toBeInTheDocument();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { useSettingsStore } from "../../stores/settingsStore";
import { useUiStore } from "../../stores/uiStore";
import { Button } from "../ui/Button";
import { SettingsSection } from "../ui/SettingsSection";
import { GeneralSettingsTab } from "./GeneralSettingsTab";
import { TagsSettingsTab } from "./TagsSettingsTab";
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
import {
ChevronLeft,
Cog,
Keyboard,
Paintbrush,
Settings,
Tag as TagIcon,
} from "lucide-react";
import type { ComponentType } from "react";
type SettingsTab = "general" | "editor" | "tags" | "shortcuts" | "advanced";
interface TabDefinition {
id: SettingsTab;
label: string;
icon: ComponentType<{ size?: number }>;
}
const TABS: TabDefinition[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "editor", label: "Editor", icon: Paintbrush },
{ id: "tags", label: "Tags", icon: TagIcon },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "advanced", label: "Advanced", icon: Cog },
];
export function SettingsPage() {
const setActiveView = useUiStore((s) => s.setActiveView);
const { load } = useSettingsStore();
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
useEffect(() => {
load();
}, [load]);
const renderEditor = () => (
<SettingsSection title="Editor">
<div className="py-8 text-center text-sm text-text-muted">
Editor settings are coming soon.
</div>
</SettingsSection>
);
const renderTabContent = () => {
switch (activeTab) {
case "general":
return <GeneralSettingsTab />;
case "editor":
return renderEditor();
case "tags":
return <TagsSettingsTab />;
case "shortcuts":
return <ShortcutsSettingsTab />;
case "advanced":
return <AdvancedSettingsTab />;
}
};
return (
<div className="min-h-screen bg-canvas">
<div className="flex gap-6 px-6 py-6">
{/* Sidebar */}
<aside className="w-48 shrink-0 space-y-1">
<Button
variant="ghost"
onClick={() => setActiveView("home")}
className="w-full justify-start gap-1 px-3 py-2 mb-4"
>
<ChevronLeft size={16} /> Back
</Button>
<nav
className="space-y-1"
role="tablist"
aria-label="Settings sections"
>
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
id={`settings-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls="settings-tabpanel"
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer ${
isActive
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<Icon size={16} />
{tab.label}
</button>
);
})}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 pt-1">
<h1 className="font-heading text-xl text-text mb-6">
Settings
</h1>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
id="settings-tabpanel"
role="tabpanel"
aria-labelledby={`settings-tab-${activeTab}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{renderTabContent()}
</motion.div>
</AnimatePresence>
</main>
</div>
</div>
);
}
@@ -0,0 +1,172 @@
import { useState, useEffect, useRef } from "react";
import { Pencil } from "lucide-react";
import { SettingsSection } from "../ui/SettingsSection";
import { useSettingsStore } from "../../stores/settingsStore";
type ShortcutDef = {
id: string;
description: string;
defaultKeys: string;
defaultWin: string;
};
const SHORTCUTS: ShortcutDef[] = [
{
id: "command_palette",
description: "Open command palette / search",
defaultKeys: "⌘K",
defaultWin: "Ctrl+K",
},
{
id: "close_tab",
description: "Close current tab (or return home if none)",
defaultKeys: "⌘W",
defaultWin: "Ctrl+W",
},
];
const STATIC_SHORTCUTS = [
{ description: "Close modal, dropdown, or popover", keys: ["Esc"] },
{ description: "Submit / confirm in dialogs", keys: ["↵ Enter"] },
{ description: "Follow foreign-key link on focused cell", keys: ["↵ Enter", "Space"] },
{ description: "Toggle row selection", keys: ["Click"] },
{ description: "Select / deselect all visible rows", keys: ["Header ☐"] },
];
function displayCombo(combo: string): string {
return combo
.replace(/meta/gi, "⌘")
.replace(/ctrl/gi, "⌃")
.replace(/shift/gi, "⇧")
.replace(/alt/gi, "⌥")
.replace(/\+/g, "")
.replace(/\b(\w)\b/g, (_, c) => c.toUpperCase());
}
function Kbd({ children }: { children: string }) {
return (
<kbd className="inline-flex items-center rounded border border-border bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-text-muted font-heading">
{children}
</kbd>
);
}
export function ShortcutsSettingsTab() {
const settings = useSettingsStore((s) => s.settings);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const customShortcuts = settings?.shortcuts ?? {};
const [recording, setRecording] = useState<string | null>(null);
const recordingRef = useRef<string | null>(null);
// Listen for keypress when recording
useEffect(() => {
if (!recording) return;
recordingRef.current = recording;
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const parts: string[] = [];
if (e.metaKey) parts.push("Meta");
if (e.ctrlKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
// Ignore modifier-only presses
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
const combo = parts.join("+");
const action = recordingRef.current;
if (!action) return;
const next = { ...customShortcuts, [action]: combo };
const existing = { ...(settings?.shortcuts ?? {}) };
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next }));
setRecording(null);
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [recording, customShortcuts, settings?.shortcuts, updateSetting]);
const handleStartRecord = (action: string) => {
setRecording(action);
};
const handleReset = (action: string) => {
const next = { ...customShortcuts };
delete next[action];
const existing = { ...(settings?.shortcuts ?? {}) };
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next, [action]: undefined as any }));
};
return (
<>
<SettingsSection title="Customizable Shortcuts">
<p className="text-xs text-text-muted mb-3">
Click the pencil icon to record a new key combination. Click the shortcut to reset to default.
</p>
<div className="space-y-1">
{SHORTCUTS.map((s) => {
const custom = customShortcuts[s.id];
const isRecording = recording === s.id;
return (
<div
key={s.id}
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-surface-raised/50 group"
>
<span className="text-sm text-text">{s.description}</span>
<div className="flex items-center gap-2 shrink-0 ml-4">
{isRecording ? (
<Kbd>Listening</Kbd>
) : custom ? (
<button
type="button"
onClick={() => handleReset(s.id)}
className="text-xs text-accent hover:underline"
title="Reset to default"
>
{displayCombo(custom)}
</button>
) : (
<Kbd>{s.defaultKeys}</Kbd>
)}
<button
type="button"
onClick={() => handleStartRecord(s.id)}
className={`p-0.5 rounded transition-colors ${
isRecording
? "text-accent bg-accent/10"
: "text-text-muted hover:text-text opacity-0 group-hover:opacity-100"
}`}
aria-label={`Record shortcut for ${s.description}`}
title="Record new shortcut"
>
<Pencil size={13} />
</button>
</div>
</div>
);
})}
</div>
</SettingsSection>
<SettingsSection title="System Shortcuts">
<p className="text-xs text-text-muted mb-3">
These shortcuts are standard across all applications and cannot be changed.
</p>
<div className="space-y-2">
{STATIC_SHORTCUTS.map((s) => (
<div key={s.description} className="flex items-center justify-between py-1">
<span className="text-sm text-text">{s.description}</span>
<div className="flex items-center gap-1 shrink-0 ml-4">
{s.keys.map((key, i) => (
<Kbd key={i}>{key}</Kbd>
))}
</div>
</div>
))}
</div>
</SettingsSection>
</>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { useState } from "react";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useSortedTags } from "../../hooks/useSortedTags";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { SettingsSection } from "../ui/SettingsSection";
import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react";
import type { Tag } from "../../lib/types";
const TAG_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#d946ef",
"#ec4899",
"#78716c",
];
export function TagsSettingsTab() {
const tags = useSortedTags();
const tagOrder = useConnectionStore((s) => s.tagOrder);
const setTagOrder = useConnectionStore((s) => s.setTagOrder);
const createTag = useConnectionStore((s) => s.createTag);
const updateTag = useConnectionStore((s) => s.updateTag);
const deleteTag = useConnectionStore((s) => s.deleteTag);
const notify = useNotificationStore((s) => s.notify);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(TAG_COLORS[0]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editColor, setEditColor] = useState("");
const handleCreateTag = async () => {
const trimmed = newName.trim();
if (!trimmed) {
notify("Tag name must not be empty", "error");
return;
}
try {
await createTag({ name: trimmed, color: newColor });
setNewName("");
setNewColor(TAG_COLORS[0]);
} catch (e) {
notify(`Failed to create tag: ${e}`, "error");
}
};
const handleUpdateTag = async (id: string) => {
const trimmed = editName.trim();
if (!trimmed) {
notify("Tag name must not be empty", "error");
return;
}
try {
await updateTag(id, { name: trimmed, color: editColor });
setEditingId(null);
} catch (e) {
notify(`Failed to update tag: ${e}`, "error");
}
};
const handleDeleteTag = async (id: string, name: string) => {
try {
await deleteTag(id);
notify(`Deleted tag "${name}"`, "info");
} catch (e) {
notify(`Failed to delete tag: ${e}`, "error");
}
};
const handleMoveTag = async (index: number, direction: "up" | "down") => {
const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id);
const newOrder = [...currentOrder];
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
try {
await setTagOrder(newOrder);
} catch (e) {
notify(`Failed to reorder tags: ${e}`, "error");
}
};
const startEdit = (tag: Tag) => {
setEditingId(tag.id);
setEditName(tag.name);
setEditColor(tag.color);
};
return (
<>
<SettingsSection title="Create tag">
<div className="py-4 flex items-center gap-3">
<Input
placeholder="Tag name"
value={newName}
onChange={setNewName}
className="flex-1"
aria-label="New tag name"
/>
<div className="flex items-center gap-1">
{TAG_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
newColor === color ? "border-text scale-110" : "border-transparent"
}`}
style={{ backgroundColor: color }}
aria-label={`Select color ${color}`}
/>
))}
</div>
<Button onClick={handleCreateTag}>
<Plus size={14} /> Add
</Button>
</div>
</SettingsSection>
<SettingsSection title="Manage tags">
{tags.length === 0 ? (
<div className="text-center py-12 text-text-muted text-sm">
No tags yet. Create one above.
</div>
) : (
<div className="space-y-2 py-2">
{tags.map((tag, index) => {
const isEditing = editingId === tag.id;
return (
<div
key={tag.id}
className="bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3"
>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
onClick={() => handleMoveTag(index, "up")}
disabled={index === 0}
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
aria-label="Move tag up"
>
<ChevronUp size={14} />
</button>
<button
type="button"
onClick={() => handleMoveTag(index, "down")}
disabled={index === tags.length - 1}
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
aria-label="Move tag down"
>
<ChevronDown size={14} />
</button>
</div>
{isEditing ? (
<>
<Input
value={editName}
onChange={setEditName}
className="flex-1"
aria-label="Edit tag name"
/>
<div className="flex items-center gap-1">
{TAG_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setEditColor(color)}
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
editColor === color ? "border-text scale-110" : "border-transparent"
}`}
style={{ backgroundColor: color }}
aria-label={`Select color ${color}`}
/>
))}
</div>
<Button onClick={() => handleUpdateTag(tag.id)}>
<Check size={14} />
</Button>
<Button variant="ghost" onClick={() => setEditingId(null)}>
<X size={14} />
</Button>
</>
) : (
<>
<div
className="w-4 h-4 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm text-text flex-1">{tag.name}</span>
<Button
variant="ghost"
className="text-xs"
onClick={() => startEdit(tag)}
>
Edit
</Button>
<button
type="button"
onClick={() => handleDeleteTag(tag.id, tag.name)}
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
aria-label={`Delete tag ${tag.name}`}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
);
})}
</div>
)}
</SettingsSection>
</>
);
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import type { Tag } from "../../lib/types";
import { Search } from "lucide-react";
interface SearchableTagPickerProps {
tags: Tag[];
selectedTagIds: string[];
onToggle: (tagId: string) => void;
}
export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: SearchableTagPickerProps) {
const [search, setSearch] = useState("");
const filtered = search.trim()
? tags.filter((t) => t.name.toLowerCase().includes(search.trim().toLowerCase()))
: tags;
return (
<div className="mt-3">
<div className="text-xs text-text-muted mb-2">Tags</div>
<div className="relative mb-2">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tags..."
className="w-full rounded-full bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
/>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{filtered.length === 0 && (
<div className="text-xs text-text-muted py-1">No tags found</div>
)}
{filtered.map((tag) => {
const active = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => onToggle(tag.id)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-xs text-left transition-colors cursor-pointer ${
active
? "bg-accent/10"
: "hover:bg-surface-raised"
}`}
>
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className={active ? "text-accent-muted" : "text-text-muted"}>{tag.name}</span>
</button>
);
})}
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TagBadge } from "./TagBadge";
import type { Tag } from "../../lib/types";
const tag: Tag = { id: "t1", name: "production", color: "#ef4444", created_at: "" };
describe("TagBadge", () => {
it("renders tag name", () => {
render(<TagBadge tag={tag} />);
expect(screen.getByText("production")).toBeInTheDocument();
});
it("toggles active state on click", async () => {
const fn = vi.fn();
render(<TagBadge tag={tag} active={false} onToggle={fn} />);
await userEvent.click(screen.getByText("production"));
expect(fn).toHaveBeenCalledWith("t1");
});
it("shows active styling when active", () => {
render(<TagBadge tag={tag} active={true} onToggle={() => {}} />);
expect(screen.getByText("production").className).toContain("brightness");
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { Tag } from "../../lib/types";
interface TagBadgeProps {
tag: Tag;
active?: boolean;
onToggle?: (id: string) => void;
}
export function TagBadge({ tag, active = false, onToggle }: TagBadgeProps) {
const Comp = onToggle ? "button" : "span";
return (
<Comp
onClick={onToggle ? () => onToggle(tag.id) : undefined}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${onToggle ? "cursor-pointer hover:brightness-125" : ""} ${active ? "brightness-150" : ""}`}
style={{ borderColor: tag.color, color: tag.color, backgroundColor: `${tag.color}15` }}
>
{tag.name}
</Comp>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AnimatedModal } from "./AnimatedModal";
describe("AnimatedModal", () => {
it("renders content when open", () => {
render(
<AnimatedModal open={true} onClose={vi.fn()}>
<p>Modal body</p>
</AnimatedModal>
);
expect(screen.getByText("Modal body")).toBeInTheDocument();
});
it("calls onClose when backdrop is clicked", async () => {
const onClose = vi.fn();
render(
<AnimatedModal open={true} onClose={onClose}>
<div data-testid="panel">Panel</div>
</AnimatedModal>
);
await userEvent.click(screen.getByTestId("animated-backdrop"));
expect(onClose).toHaveBeenCalled();
});
it("removes content from DOM after exit animation", async () => {
const { rerender } = render(
<AnimatedModal open={true} onClose={vi.fn()}>
<p>Modal body</p>
</AnimatedModal>
);
rerender(
<AnimatedModal open={false} onClose={vi.fn()}>
<p>Modal body</p>
</AnimatedModal>
);
await waitForElementToBeRemoved(() => screen.queryByText("Modal body"));
expect(screen.queryByText("Modal body")).not.toBeInTheDocument();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect } from "react";
import type { ReactNode } from "react";
interface AnimatedModalProps {
open: boolean;
onClose: () => void;
children: ReactNode;
}
export function AnimatedModal({ open, onClose, children }: AnimatedModalProps) {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
data-testid="animated-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 bg-canvas/60 backdrop-blur-sm flex items-center justify-center z-50"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="glass rounded-2xl p-6 shadow-2xl ring-1 ring-white/10"
style={{ background: "linear-gradient(145deg, rgba(24,24,27,0.85), rgba(10,10,11,0.65))" }}
onClick={(e) => e.stopPropagation()}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Badge } from "./Badge";
describe("Badge", () => {
it("renders label and color", () => {
render(<Badge label="production" color="#ef4444" />);
const el = screen.getByText("production");
expect(el).toBeInTheDocument();
expect(el).toHaveStyle({ color: "#ef4444", backgroundColor: "#ef444433" });
});
it("fires onClick when provided", async () => {
const fn = vi.fn();
render(<Badge label="x" color="#fff" onClick={fn} />);
await userEvent.click(screen.getByText("x"));
expect(fn).toHaveBeenCalledOnce();
});
});
+18
View File
@@ -0,0 +1,18 @@
interface BadgeProps {
label: string;
color: string;
onClick?: () => void;
}
export function Badge({ label, color, onClick }: BadgeProps) {
const Comp = onClick ? "button" : "span";
return (
<Comp
onClick={onClick}
className={`text-xs px-2 py-0.5 rounded ${onClick ? "cursor-pointer hover:brightness-125" : ""}`}
style={{ backgroundColor: `${color}33`, color }}
>
{label}
</Comp>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Save</Button>);
expect(screen.getByText("Save")).toBeInTheDocument();
});
it("fires onClick", async () => {
const fn = vi.fn();
render(<Button onClick={fn}>Click</Button>);
await userEvent.click(screen.getByText("Click"));
expect(fn).toHaveBeenCalledOnce();
});
it("does not fire onClick when disabled", async () => {
const fn = vi.fn();
render(<Button onClick={fn} disabled>Click</Button>);
await userEvent.click(screen.getByText("Click"));
expect(fn).not.toHaveBeenCalled();
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
children: ReactNode;
}
export function Button({ variant = "primary", className = "", children, ...rest }: ButtonProps) {
const base = "inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border border-border hover:border-border-hover";
const styles = {
primary: "bg-accent text-white hover:bg-accent-hover shadow-none",
secondary: "bg-surface-raised text-text-muted hover:text-text",
ghost: "bg-transparent text-text-muted hover:text-text",
}[variant];
return (
<button className={`${base} ${styles} ${className}`} {...rest}>
{children}
</button>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { HTMLAttributes, ReactNode } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function Card({ children, className = "", ...rest }: CardProps) {
return (
<div className={`bg-surface border border-border rounded-xl p-4 transition-colors hover:border-border-hover ${className}`} {...rest}>
{children}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { ConfirmDialog } from "./ConfirmDialog";
describe("ConfirmDialog", () => {
it("renders title and message", () => {
render(
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(screen.getByText("Delete?")).toBeInTheDocument();
expect(screen.getByText("Are you sure?")).toBeInTheDocument();
});
it("calls onConfirm when confirm button is clicked", async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={onConfirm} onCancel={vi.fn()} />,
);
await user.click(screen.getByText(/confirm/i));
expect(onConfirm).toHaveBeenCalled();
});
it("calls onCancel when cancel button is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={onCancel} />,
);
await user.click(screen.getByText(/cancel/i));
expect(onCancel).toHaveBeenCalled();
});
it("removes content from DOM after exit animation", async () => {
const { rerender } = render(
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
expect(screen.getByText("Delete?")).toBeInTheDocument();
rerender(
<ConfirmDialog open={false} title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
);
await waitForElementToBeRemoved(() => screen.queryByText("Delete?"));
expect(screen.queryByText("Delete?")).not.toBeInTheDocument();
});
it("calls onCancel when Escape is pressed", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={onCancel} />,
);
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Button } from "../ui/Button";
import { AnimatedModal } from "../ui/AnimatedModal";
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: "primary" | "ghost";
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", confirmVariant = "primary", onConfirm, onCancel }: ConfirmDialogProps) {
return (
<AnimatedModal open={open} onClose={onCancel}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">{title}</h3>
<p className="text-sm text-text-muted mb-4">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</div>
</div>
</AnimatedModal>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ErrorBanner } from "./ErrorBanner";
describe("ErrorBanner", () => {
it("renders nothing when no error", () => {
const { container } = render(<ErrorBanner error={null} onRetry={() => {}} />);
expect(container.firstChild).toBeNull();
});
it("renders message and retry button when error", () => {
const fn = vi.fn();
render(<ErrorBanner error="Storage error" onRetry={fn} />);
expect(screen.getByText(/storage error/i)).toBeInTheDocument();
expect(screen.getByText(/retry/i)).toBeInTheDocument();
});
it("fires onRetry", async () => {
const fn = vi.fn();
render(<ErrorBanner error="x" onRetry={fn} />);
await userEvent.click(screen.getByText(/retry/i));
expect(fn).toHaveBeenCalledOnce();
});
});
+14
View File
@@ -0,0 +1,14 @@
interface ErrorBannerProps {
error: string | null;
onRetry: () => void;
}
export function ErrorBanner({ error, onRetry }: ErrorBannerProps) {
if (!error) return null;
return (
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/30 rounded-md px-4 py-2 mb-4">
<span className="text-red-300 text-sm">{error}</span>
<button onClick={onRetry} className="text-sm text-text-muted underline hover:text-text">Retry</button>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Input } from "./Input";
describe("Input", () => {
it("renders placeholder", () => {
render(<Input placeholder="Search..." />);
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("fires onChange with value", async () => {
const fn = vi.fn();
render(<Input onChange={fn} placeholder="x" />);
await userEvent.type(screen.getByPlaceholderText("x"), "hi");
expect(fn).toHaveBeenLastCalledWith("hi");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { forwardRef } from "react";
import type { KeyboardEvent } from "react";
interface InputProps {
value?: string;
placeholder?: string;
className?: string;
type?: string;
disabled?: boolean;
"aria-label"?: string;
onChange?: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
function Input({ onChange, onKeyDown, className = "", ...rest }, ref) {
return (
<input
ref={ref}
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 cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
/>
);
},
);
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Select } from "./Select";
describe("Select", () => {
it("renders options and calls onChange", async () => {
const onChange = vi.fn();
render(
<Select
value="medium"
onChange={onChange}
options={[
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
]}
label="Font size"
/>,
);
const select = screen.getByLabelText("Font size");
expect(select).toHaveValue("medium");
await userEvent.selectOptions(select, "large");
expect(onChange).toHaveBeenCalledWith("large");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useId } from "react";
interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
label?: string;
disabled?: boolean;
}
export function Select({ value, onChange, options, label, disabled }: SelectProps) {
const id = useId();
return (
<div className="inline-flex items-center gap-2">
{label && (
<label htmlFor={id} className="sr-only">
{label}
</label>
)}
<select
id={id}
value={value}
disabled={disabled}
onChange={(e) => onChange(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 disabled:opacity-50"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SelectDropdown } from "./SelectDropdown";
describe("SelectDropdown", () => {
it("renders the selected label and opens a popover menu", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<SelectDropdown
value="staging"
onChange={onChange}
options={[
{ value: "", label: "None" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
]}
placeholder="None"
/>,
);
const trigger = screen.getByRole("button", { name: /Staging/i });
expect(trigger).toBeInTheDocument();
await user.click(trigger);
expect(screen.getByRole("button", { name: /Production/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Development/i }));
expect(onChange).toHaveBeenCalledWith("development");
});
});
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
export interface SelectDropdownOption {
value: string;
label: string;
}
interface SelectDropdownProps {
value: string;
onChange: (value: string) => void;
options: SelectDropdownOption[];
placeholder?: string;
variant?: "pill" | "ghost";
"aria-label"?: string;
}
export function SelectDropdown({
value,
onChange,
options,
placeholder = "Select…",
variant = "pill",
"aria-label": ariaLabel,
}: SelectDropdownProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const selectedLabel =
options.find((opt) => opt.value === value)?.label ?? placeholder;
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleSelect = (nextValue: string) => {
onChange(nextValue);
setOpen(false);
};
const buttonClass = variant === "ghost"
? "flex items-center gap-1 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
: "w-full flex items-center justify-between rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer";
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={ariaLabel}
className={buttonClass}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDown
size={14}
className={variant === "ghost" ? "text-text-muted" : "absolute right-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"}
/>
</button>
{open && (
<div className="absolute left-0 mt-1 rounded-xl bg-surface border border-border py-1 z-10 min-w-[200px] w-full shadow-lg">
{options.map((opt) => (
<button
key={opt.value}
type="button"
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
onClick={() => handleSelect(opt.value)}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SettingsRow } from "./SettingsRow";
describe("SettingsRow", () => {
it("renders title, description, and control", () => {
render(
<SettingsRow title="Font size" description="Adjust text size.">
<button>Control</button>
</SettingsRow>
);
expect(screen.getByText("Font size")).toBeInTheDocument();
expect(screen.getByText("Adjust text size.")).toBeInTheDocument();
expect(screen.getByText("Control")).toBeInTheDocument();
});
it("has a bottom border by default", () => {
const { container } = render(
<SettingsRow title="Item">
<span />
</SettingsRow>
);
expect(container.firstChild).toHaveClass("border-b");
});
it("removes the bottom border on the last row", () => {
const { container } = render(
<>
<SettingsRow title="First">
<span />
</SettingsRow>
<SettingsRow title="Last">
<span />
</SettingsRow>
</>
);
const rows = container.querySelectorAll(".border-b");
expect(rows).toHaveLength(2);
const lastRow = rows[rows.length - 1];
expect(lastRow).toHaveClass("last:border-b-0");
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
interface SettingsRowProps {
title: string;
description?: string;
children: ReactNode;
}
export function SettingsRow({ title, description, children }: SettingsRowProps) {
return (
<div className="flex items-center justify-between gap-6 py-4 border-b border-border last:border-b-0">
<div className="min-w-0 overflow-hidden">
<div className="text-sm font-medium text-text truncate">{title}</div>
{description && (
<div className="text-xs text-text-muted mt-0.5 truncate">{description}</div>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SettingsSection } from "./SettingsSection";
describe("SettingsSection", () => {
it("renders the title and children", () => {
render(
<SettingsSection title="Appearance">
<div>Content</div>
</SettingsSection>
);
expect(screen.getByText("Appearance")).toBeInTheDocument();
expect(screen.getByText("Content")).toBeInTheDocument();
});
it("uses the surface background on the inner card", () => {
const { container } = render(
<SettingsSection title="Appearance">
<div />
</SettingsSection>
);
const card = container.querySelector(".bg-surface");
expect(card).toBeInTheDocument();
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
interface SettingsSectionProps {
title: string;
children: ReactNode;
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
return (
<section className="mb-8">
<h2 className="text-sm font-medium text-text mb-1">{title}</h2>
<div className="bg-surface border border-border rounded-xl px-4 overflow-hidden">
{children}
</div>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThemePicker } from "./ThemePicker";
describe("ThemePicker", () => {
it("renders three options and emits selected theme", async () => {
const onChange = vi.fn();
render(<ThemePicker value="dark" onChange={onChange} />);
await userEvent.click(screen.getByRole("radio", { name: "System" }));
expect(onChange).toHaveBeenCalledWith("system");
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { Theme } from "../../lib/types";
interface ThemePickerProps {
value: Theme;
onChange: (theme: Theme) => void;
}
const THEMES: { value: Theme; label: string; previewClass: string }[] = [
{ value: "light", label: "Light", previewClass: "bg-zinc-100" },
{ value: "dark", label: "Dark", previewClass: "bg-surface" },
{ value: "system", label: "System", previewClass: "bg-gradient-to-br from-zinc-100 to-surface" },
];
export function ThemePicker({ value, onChange }: ThemePickerProps) {
return (
<div className="flex gap-3" role="radiogroup" aria-label="Theme">
{THEMES.map((theme) => (
<button
key={theme.value}
type="button"
role="radio"
aria-checked={value === theme.value}
aria-label={theme.label}
onClick={() => onChange(theme.value)}
className={`group relative w-20 h-14 rounded-lg border-2 overflow-hidden transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
value === theme.value
? "border-accent"
: "border-border hover:border-border-hover"
}`}
>
<div className={`absolute inset-0 ${theme.previewClass}`} />
<div className="absolute top-1 left-1 right-1 h-2 rounded bg-black/10" />
<div className="absolute bottom-1 left-1 right-2 h-1 rounded bg-black/5" />
<span className="absolute bottom-1 right-1 text-[9px] font-medium text-text-muted opacity-70 group-hover:opacity-100">
{theme.label}
</span>
</button>
))}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useNotificationStore } from "../../stores/notificationStore";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
const ICONS = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const STYLES = {
success: "border-green-500/40 bg-green-500/10 text-green-300",
error: "border-red-500/40 bg-red-500/10 text-red-300",
info: "border-accent/40 bg-accent/10 text-accent-muted",
};
export function ToastContainer() {
const notifications = useNotificationStore((s) => s.notifications);
const dismiss = useNotificationStore((s) => s.dismiss);
if (notifications.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{notifications.map((n) => {
const Icon = ICONS[n.type];
return (
<div
key={n.id}
className={`flex items-start gap-3 px-4 py-3 rounded-xl border backdrop-blur-sm shadow-lg animate-in slide-in-from-right-2 ${STYLES[n.type]}`}
>
<Icon size={16} className="mt-0.5 shrink-0" />
<span className="text-sm flex-1">{n.message}</span>
<button onClick={() => dismiss(n.id)} className="shrink-0 opacity-60 hover:opacity-100 transition-opacity cursor-pointer">
<X size={14} />
</button>
</div>
);
})}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toggle } from "./Toggle";
describe("Toggle", () => {
it("renders checked state", () => {
render(<Toggle checked={true} onChange={vi.fn()} label="Enable" />);
expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "true");
});
it("renders unchecked state", () => {
render(<Toggle checked={false} onChange={vi.fn()} label="Enable" />);
expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "false");
});
it("calls onChange when clicked", async () => {
const onChange = vi.fn();
render(<Toggle checked={false} onChange={onChange} label="Enable" />);
await userEvent.click(screen.getByRole("switch", { name: "Enable" }));
expect(onChange).toHaveBeenCalledWith(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { useId } from "react";
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
disabled?: boolean;
}
export function Toggle({ checked, onChange, label, disabled }: ToggleProps) {
const id = useId();
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:opacity-50 ${
checked ? "bg-accent" : "bg-surface-raised"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Tooltip, TooltipProvider } from "./Tooltip";
describe("Tooltip", () => {
it("renders children", () => {
render(
<TooltipProvider>
<Tooltip content="Help text">
<button>Hover me</button>
</Tooltip>
</TooltipProvider>
);
expect(screen.getByText("Hover me")).toBeInTheDocument();
});
it("shows tooltip on hover", async () => {
const user = userEvent.setup();
render(
<TooltipProvider>
<Tooltip content="Help text">
<button>Hover me</button>
</Tooltip>
</TooltipProvider>
);
await user.hover(screen.getByText("Hover me"));
expect(await screen.findByText("Help text")).toBeInTheDocument();
});
it("hides tooltip on unhover", async () => {
const user = userEvent.setup();
render(
<TooltipProvider>
<Tooltip content="Help text">
<button>Hover me</button>
</Tooltip>
</TooltipProvider>
);
await user.hover(screen.getByText("Hover me"));
expect(await screen.findByText("Help text")).toBeInTheDocument();
await user.unhover(screen.getByText("Hover me"));
expect(screen.queryByText("Help text")).not.toBeInTheDocument();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { createContext, useContext, useId, useRef, useState, useCallback, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react";
interface TooltipContextValue {
activeId: string | null;
setActiveId: Dispatch<SetStateAction<string | null>>;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext() {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error("Tooltip must be used inside a TooltipProvider");
}
return ctx;
}
interface TooltipProviderProps {
children: ReactNode;
}
export function TooltipProvider({ children }: TooltipProviderProps) {
const [activeId, setActiveId] = useState<string | null>(null);
return (
<TooltipContext.Provider value={{ activeId, setActiveId }}>
{children}
</TooltipContext.Provider>
);
}
interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: "top" | "right" | "bottom" | "left";
}
function tooltipClasses(side: "top" | "right" | "bottom" | "left") {
switch (side) {
case "right":
return {
wrapper: "left-full ml-2 top-1/2 -translate-y-1/2",
arrow: "right-full top-1/2 -translate-y-1/2 border-r-surface-raised",
};
case "bottom":
return {
wrapper: "top-full left-1/2 -translate-x-1/2 mt-2",
arrow: "bottom-full left-1/2 -translate-x-1/2 border-b-surface-raised",
};
case "left":
return {
wrapper: "right-full mr-2 top-1/2 -translate-y-1/2",
arrow: "left-full top-1/2 -translate-y-1/2 border-l-surface-raised",
};
case "top":
default:
return {
wrapper: "bottom-full left-1/2 -translate-x-1/2 mb-2",
arrow: "top-full left-1/2 -translate-x-1/2 border-t-surface-raised",
};
}
}
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
const id = useId();
const { activeId, setActiveId } = useTooltipContext();
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isActive = activeId === id;
const tc = tooltipClasses(side);
const clearTimer = useCallback(() => {
if (showTimer.current) {
clearTimeout(showTimer.current);
showTimer.current = null;
}
}, []);
const show = useCallback(() => {
clearTimer();
showTimer.current = setTimeout(() => {
setActiveId(id);
}, 300);
}, [clearTimer, id, setActiveId]);
const hide = useCallback(() => {
clearTimer();
setActiveId((prev) => (prev === id ? null : prev));
}, [clearTimer, id, setActiveId]);
return (
<span
className="relative inline-flex cursor-pointer"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{children}
{isActive && (
<span
role="tooltip"
className={`absolute z-50 px-2 py-1 text-xs rounded-md bg-surface-raised border border-border text-text shadow-lg whitespace-nowrap ${tc.wrapper}`}
>
{content}
<span
className={`absolute border-4 border-transparent ${tc.arrow}`}
aria-hidden="true"
/>
</span>
)}
</span>
);
}