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
+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>
);
}