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
@@ -26,8 +26,10 @@ dist-ssr
|
||||
# AI
|
||||
.pi/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Env
|
||||
.env.*
|
||||
.env
|
||||
!.env.example
|
||||
.worktrees/
|
||||
|
||||
@@ -169,12 +169,139 @@ cargo test # Rust tests
|
||||
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
|
||||
- **Do NOT** log credentials, connection strings, or query data
|
||||
- **Do NOT** introduce Electron, Node.js server processes, or Docker dependencies
|
||||
- **Do NOT** execute data-modifying SQL (INSERT, UPDATE, DELETE, DROP, ALTER) or any destructive CRUD operation (deleting connections, folders, tags) directly without explicit user confirmation. For database data, always push to the changes queue first and require "Commit All". For app entities (connections, folders, tags), show a confirmation dialog before executing.
|
||||
- **DO** keep Tauri commands thin — business logic lives in `db/` and `store/` modules
|
||||
- **DO** type all IPC boundaries explicitly
|
||||
- **DO** validate and sanitize all user-provided SQL and connection parameters before execution
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
✅ = Complete 🟡 = Partial/Stub ❌ = Not Started
|
||||
|
||||
### Connection Management
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation |
|
||||
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
||||
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||
| DB Viewer: MySQL browse | ❌ | Test connection works; browsing not wired |
|
||||
| DB Viewer: Redis browse | ❌ | Test connection works; browsing not wired |
|
||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||
| SSH tunnel runtime | 🟡 | UI exists; backend is a **placeholder** (TODO: ssh2 crate integration) |
|
||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
||||
| SSL/TLS runtime | 🟡 | Config persisted; **not yet passed to sqlx/tokio-postgres** |
|
||||
|
||||
### Home Screen & Organization
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Connection cards grid (by folder) | ✅ | Grouped display, single-click to open DB viewer |
|
||||
| Folders CRUD | ✅ | Nested folders, reparent on delete, breadcrumb nav |
|
||||
| Tags CRUD | ✅ | Colors, drag reorder, filter connections by tag |
|
||||
| DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Toggle chips to filter connection grid |
|
||||
| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form |
|
||||
| Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting |
|
||||
| Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog |
|
||||
| Drag-and-drop connections to folders | ❌ | Currently only via edit form |
|
||||
| Move-to-folder bulk action | ❌ | |
|
||||
| Favorites / Recent connections | ❌ | |
|
||||
| Connection status indicator on cards | ❌ | |
|
||||
|
||||
### Database Viewer
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Multi-tab table browser | ✅ | Open tables in tabs, close with Cmd/Ctrl+W |
|
||||
| Schema/database selector | ✅ | Ghost-style dropdowns, single-row layout |
|
||||
| Refresh database (spin + success/error feedback) | ✅ | Re-fetches databases, schemas, and tables |
|
||||
| Search tables filter | ✅ | Animated input, real-time filter by name, auto-hide on blur |
|
||||
| Column metadata (PK, FK, type, nullable, default) | ✅ | Expand table row to see columns with icons |
|
||||
| FK detection | ✅ | `information_schema.constraint_column_usage` + `PRAGMA foreign_key_list` |
|
||||
| FK preview popover | ✅ | Click FK cell → popover with referenced row → "Open" button creates filtered tab |
|
||||
| JSON/JSONB cell popover | ✅ | Formatted/Raw tabs with copy button |
|
||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||
| Column filtering (client-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull |
|
||||
| Column sorting (client-side) | ✅ | Multi-column asc/desc |
|
||||
| Column show/hide | ✅ | Toggle visibility per column |
|
||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
|
||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||
| Changes queue (INSERT, UPDATE, DELETE) | ✅ | Queue changes → Commit All; cancel individual changes |
|
||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||
| Inline cell editing | ❌ | Cells are read-only; changes via queue Insert button only |
|
||||
| Virtualized data grid | ❌ | Plain HTML `<table>`; TODO: @tanstack/react-virtual for 100k+ rows |
|
||||
| Row detail / expandable row view | ❌ | |
|
||||
| Keyboard cell navigation (arrow keys, Tab) | ❌ | |
|
||||
| Cell-level copy (right-click or Ctrl+C) | ❌ | Only bulk copy via toolbar |
|
||||
|
||||
### Object Explorer (non-table objects)
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Functions | ❌ | Stub button in sidebar; not queried from `pg_proc` |
|
||||
| Triggers | ❌ | Stub button in sidebar |
|
||||
| Sequences | ❌ | Not listed anywhere |
|
||||
| Enums / user-defined types | ❌ | `udt_name` returned in column metadata but no enum viewer |
|
||||
| Extensions | ❌ | Not queried from `pg_extension` |
|
||||
| Indexes (per table) | ❌ | |
|
||||
| Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | |
|
||||
| Materialized views | ❌ | Not distinguished from regular views |
|
||||
| Stored procedures | ❌ | |
|
||||
| Schema visualizer (ER diagram) | ❌ | Stub button in sidebar |
|
||||
|
||||
### Query Editor
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| SQL text editor (Monaco) | ❌ | `src/components/editor/` does not exist yet |
|
||||
| SQL autocomplete (keywords, tables, columns) | ❌ | |
|
||||
| Custom query execution (arbitrary SQL) | ❌ | Only `SELECT * FROM table` via tab open |
|
||||
| Multiple result sets | ❌ | |
|
||||
| Query history / recent queries | ❌ | No persistence or UI |
|
||||
| Saved queries (named, organized) | ❌ | No `queries` table in local SQLite |
|
||||
| Query favorites / pinning | ❌ | |
|
||||
| Editor settings (font, tab size, word wrap, minimap) | ❌ | Settings page has "Editor" tab with "coming soon" placeholder |
|
||||
|
||||
### Backup & Restore
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| pg_dump wrapper | ❌ | No Rust command; shell out to system binary per design decision #3 |
|
||||
| pg_restore wrapper | ❌ | |
|
||||
| Backup UI | ❌ | |
|
||||
| DB-to-DB sync | ❌ | |
|
||||
| SQLite .dump | ❌ | |
|
||||
| Table structure export (DDL) | ❌ | |
|
||||
|
||||
### Settings
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Theme (dark/light/system) | ✅ | Tailwind dark-first with ThemePicker |
|
||||
| Font size | ✅ | |
|
||||
| Default folder for new connections | ✅ | |
|
||||
| Table page size default | ✅ | |
|
||||
| Auto-refresh rate | ✅ | |
|
||||
| Tags management | ✅ | Full CRUD with color picker, drag reorder |
|
||||
| Shortcuts (2 configurable) | ✅ | Open command palette, Close tab |
|
||||
| Confirm-before-delete toggle | ✅ | |
|
||||
| Default ports per DB type | ✅ | |
|
||||
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
||||
| Editor settings | ❌ | Placeholder tab |
|
||||
| SSH key management | ❌ | Only path inputs, no key file reading |
|
||||
| Settings export/import | ❌ | |
|
||||
|
||||
### Demo & Onboarding
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Demo SQLite database (auto-seeded) | ✅ | users, products, orders, order_items tables |
|
||||
| Re-add demo DB button | ✅ | Settings → Advanced |
|
||||
| Getting started / onboarding flow | ❌ | |
|
||||
| Welcome tooltips / tour | ❌ | |
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Tauri 2.0 Documentation](https://tauri.app/develop/)
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gridline",
|
||||
"dependencies": {
|
||||
"@fontsource/outfit": "^5.3.0",
|
||||
"@fontsource/space-mono": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.26.0",
|
||||
"motion": "^12.42.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tauri-plugin-keyring-store-api": "^0.2.0",
|
||||
"zustand": "^5.0.14",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
|
||||
|
||||
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@fontsource/outfit": ["@fontsource/outfit@5.3.0", "", {}, "sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA=="],
|
||||
|
||||
"@fontsource/space-mono": ["@fontsource/space-mono@5.3.0", "", {}, "sha512-FrpBMOVWn3PRdRHlrZWC55X3kZG/2BTH7SM5ZyS/bky3iKya2BturI5lk+t0RoDQL2JyHzURsVq/EisCsxpI5A=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
|
||||
|
||||
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.3", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||
|
||||
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tauri-plugin-keyring-store-api": ["tauri-plugin-keyring-store-api@0.2.0", "", { "dependencies": { "@tauri-apps/api": "2.11.0" } }, "sha512-ZQYb6Xj75AD+YFBNDr4Zjc751TlVCQqvcB3GXx9Q85dRkss2FKrfU1Bu3kATQAzMQcvPYT3Ww+Fi+3I+OlMVZA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.4.9", "", { "dependencies": { "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.9", "", {}, "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"tauri-plugin-keyring-store-api/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Gridline</title>
|
||||
<style>
|
||||
html, body, #root { background-color: #0A0A0B; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
{
|
||||
"name": "gridline",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
"name": "gridline",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev": "vite",
|
||||
"tauri:dev": "tauri dev",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/outfit": "^5.3.0",
|
||||
"@fontsource/space-mono": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.26.0",
|
||||
"motion": "^12.42.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tauri-plugin-keyring-store-api": "^0.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,20 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
# URL percent-encoding for tokio-postgres connection strings (user/password may contain special chars)
|
||||
urlencoding = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
ssh2 = { version = "0.9" }
|
||||
deadpool-postgres = { version = "0.14" }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"core:window:allow-set-title",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"keyring-store:default"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
|
||||
<path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
|
||||
</g>
|
||||
<g id="Icon">
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
|
||||
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
|
||||
</g>
|
||||
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,219 @@
|
||||
use crate::models::{Connection, ConnectionInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
|
||||
return Err(format!(
|
||||
"db_type must be one of: {}",
|
||||
VALID_DB_TYPES.join(", ")
|
||||
));
|
||||
}
|
||||
if input.host.is_empty() || input.host.chars().count() > 255 {
|
||||
return Err("host is required and must be 255 chars or fewer".into());
|
||||
}
|
||||
if input.db_type != "sqlite" {
|
||||
match input.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => {
|
||||
return Err(
|
||||
"port must be an integer between 1 and 65535 for this db_type".into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = &input.username {
|
||||
if u.chars().count() > 100 {
|
||||
return Err("username must be 100 chars or fewer".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_connections()
|
||||
}
|
||||
|
||||
pub fn create_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_connection(input)
|
||||
}
|
||||
|
||||
pub fn update_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_connection(&id, input)
|
||||
}
|
||||
|
||||
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_connection(id)
|
||||
}
|
||||
|
||||
pub fn add_connection_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_connection_tags(&connection_id, &tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
|
||||
get_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
create_connection_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
update_connection_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_connection(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_connection_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_connection_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_connections_returns_list() {
|
||||
let st = state();
|
||||
let result = get_connections_inner(&st);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_command_returns_connection() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_rejects_invalid_db_type() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "mongodb".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_connection_command_removes_it() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
const DEMO_DB_FILENAME: &str = "demo.db";
|
||||
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
|
||||
|
||||
/// Ensure the demo SQLite database exists and a corresponding connection is
|
||||
/// registered. Safe to call on every app start — it's idempotent.
|
||||
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Check if the demo connection already exists
|
||||
{
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
return Ok(()); // already set up
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve app data directory
|
||||
let data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Create the demo SQLite file if it doesn't exist
|
||||
if !db_path.exists() {
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
}
|
||||
|
||||
// Create the demo connection
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command to re-add the demo connection from the settings screen.
|
||||
#[tauri::command]
|
||||
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> {
|
||||
let store = &state.db_store;
|
||||
ensure_demo_db_inner(store)
|
||||
.map(|()| "Demo database connection re-created.".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Internal helper that does not need an AppHandle (for settings usage).
|
||||
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Use a temp dir since we don't have the app handle
|
||||
let data_dir = std::env::temp_dir().join("gridline-demo");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Check if file already exists
|
||||
if db_path.exists() {
|
||||
// Just re-create the connection if it was deleted
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
drop(s);
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create demo DB file and seed it
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_demo_schema() -> String {
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
stock INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
total REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_price REAL NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
|
||||
(1, 'Alice Johnson', 'alice@example.com', 'admin'),
|
||||
(2, 'Bob Smith', 'bob@example.com', 'user'),
|
||||
(3, 'Carol Davis', 'carol@example.com', 'user'),
|
||||
(4, 'Dan Wilson', 'dan@example.com', 'user'),
|
||||
(5, 'Eve Martinez', 'eve@example.com', 'moderator');
|
||||
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES
|
||||
(1, 'Wireless Mouse', 29.99, 'Electronics', 150),
|
||||
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75),
|
||||
(3, 'USB-C Hub', 34.99, 'Accessories', 200),
|
||||
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30),
|
||||
(5, 'Laptop Stand', 49.99, 'Accessories', 100),
|
||||
(6, 'Webcam 1080p', 59.99, 'Electronics', 60),
|
||||
(7, 'Desk Lamp LED', 39.99, 'Office', 120),
|
||||
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
|
||||
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES
|
||||
(1, 1, 119.98, 'completed'),
|
||||
(2, 2, 484.98, 'pending'),
|
||||
(3, 3, 59.99, 'completed'),
|
||||
(4, 1, 89.99, 'shipped'),
|
||||
(5, 4, 689.98, 'pending');
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
|
||||
(1, 1, 2, 29.99),
|
||||
(1, 3, 1, 34.99),
|
||||
(2, 4, 1, 449.99),
|
||||
(2, 5, 1, 49.99),
|
||||
(3, 6, 1, 59.99),
|
||||
(4, 2, 1, 89.99),
|
||||
(5, 8, 1, 599.99),
|
||||
(5, 1, 3, 29.99);
|
||||
".to_string()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use crate::models::{Folder, FolderInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &FolderInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_folders()
|
||||
}
|
||||
|
||||
pub fn create_folder_inner(
|
||||
state: &Mutex<Store>,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_folder(input)
|
||||
}
|
||||
|
||||
pub fn delete_folder_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_folder(id)
|
||||
}
|
||||
|
||||
pub fn add_folder_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_folder_tags(&folder_id, &tag_ids)
|
||||
}
|
||||
|
||||
pub fn update_folder_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_folder(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_folders(state: tauri::State<crate::AppState>) -> Result<Vec<Folder>, String> {
|
||||
get_folders_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
create_folder_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_folder(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_folder_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_folder_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_folder_tags_inner(&state.db_store, folder_id, tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
update_folder_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::FolderInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_command_works() {
|
||||
let st = state();
|
||||
let folder =
|
||||
create_folder_inner(&st, FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(folder.name, "Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_rejects_empty_name() {
|
||||
let st = state();
|
||||
let result = create_folder_inner(
|
||||
&st,
|
||||
FolderInput { tag_ids: None,
|
||||
name: "".into(),
|
||||
parent_id: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_command_works() {
|
||||
let st = state();
|
||||
let folder =
|
||||
create_folder_inner(&st, FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
delete_folder_inner(&st, &folder.id).unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ImportRecord {
|
||||
name: Option<String>,
|
||||
db_type: String,
|
||||
host: String,
|
||||
port: Option<i64>,
|
||||
username: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkippedRecord {
|
||||
pub index: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub skipped_records: Vec<SkippedRecord>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
|
||||
let records: Vec<ImportRecord> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
if rec.name.as_deref().unwrap_or("").is_empty() {
|
||||
return Err(format!("record {}: name is required", i));
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<ImportResult, String> {
|
||||
let records: Vec<ImportRecord> = serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0usize;
|
||||
let mut skipped_records = Vec::new();
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
let name = match &rec.name {
|
||||
Some(n) if !n.is_empty() => n.clone(),
|
||||
_ => {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: "missing or empty name".into() });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: format!("invalid db_type: {}", rec.db_type) });
|
||||
continue;
|
||||
}
|
||||
if rec.host.is_empty() {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: "missing or empty host".into() });
|
||||
continue;
|
||||
}
|
||||
let input = ConnectionInput {
|
||||
name,
|
||||
db_type: rec.db_type.clone(),
|
||||
host: rec.host.clone(),
|
||||
port: rec.port,
|
||||
username: rec.username.clone(),
|
||||
folder_id: rec.folder_id.clone(),
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
Err(e) => skipped_records.push(SkippedRecord { index: i, reason: e }),
|
||||
}
|
||||
}
|
||||
Ok(ImportResult { imported, skipped: skipped_records.len(), skipped_records })
|
||||
}
|
||||
|
||||
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let conns = store.get_connections()?;
|
||||
let export = serde_json::json!({ "version": 1, "connections": conns });
|
||||
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_connections(state: tauri::State<crate::AppState>, json: String) -> Result<ImportResult, String> {
|
||||
import_connections_inner(&state.db_store, json)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||
export_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
use crate::models::ConnectionInput;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_validates_required_fields() {
|
||||
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
let parsed = parse_import(json).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name.as_deref(), Some("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_missing_name() {
|
||||
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_invalid_db_type() {
|
||||
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_inserts_all() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_skips_invalid_keeps_valid() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 1);
|
||||
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_connections_returns_json() {
|
||||
let st = state();
|
||||
let _ = st.lock().unwrap().create_connection(ConnectionInput {
|
||||
name: "A".into(), db_type: "postgresql".into(), host: "h".into(),
|
||||
port: Some(5432), username: None, folder_id: None,
|
||||
password: None, database: None,
|
||||
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
||||
ssh_private_key_path: None, ssh_passphrase: None,
|
||||
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
assert!(json.contains("\"version\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use tauri_plugin_keyring_store::KeyringExt;
|
||||
|
||||
/// Store a connection password in the OS keychain.
|
||||
/// The connection ID is used as the keyring account name.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&connection_id, &password)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain.
|
||||
/// Returns None if no password was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod connections;
|
||||
pub mod db_viewer;
|
||||
pub mod folders;
|
||||
pub mod tags;
|
||||
pub mod settings;
|
||||
pub mod import_export;
|
||||
pub mod test_connection;
|
||||
pub mod ssh;
|
||||
pub mod keychain;
|
||||
pub mod demo;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::models::Settings;
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_setting(key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
|
||||
get_settings_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_setting(state: tauri::State<crate::AppState>, key: String, value: String) -> Result<(), String> {
|
||||
update_setting_inner(&state.db_store, &key, &value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_settings_returns_defaults() {
|
||||
let st = state();
|
||||
let s = get_settings_inner(&st).unwrap();
|
||||
assert_eq!(s.theme, "system");
|
||||
assert_eq!(s.font_size, "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_setting_persists() {
|
||||
let st = state();
|
||||
update_setting_inner(&st, "theme", "light").unwrap();
|
||||
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
auth_method: String,
|
||||
) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an active SSH tunnel connection.
|
||||
#[derive(Debug)]
|
||||
struct SshTunnel {
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
}
|
||||
|
||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||
///
|
||||
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
|
||||
/// or `async-ssh2`) will be added in a later task. Currently the manager
|
||||
/// stores mock entries when validation passes.
|
||||
#[derive(Debug)]
|
||||
pub struct SshTunnelManager {
|
||||
tunnels: HashMap<String, SshTunnel>,
|
||||
}
|
||||
|
||||
impl SshTunnelManager {
|
||||
/// Create a new empty tunnel manager.
|
||||
pub fn new() -> Self {
|
||||
SshTunnelManager {
|
||||
tunnels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel for the given config.
|
||||
///
|
||||
/// Returns the local port on success.
|
||||
///
|
||||
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
|
||||
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
|
||||
/// when `config.is_valid()` passes.
|
||||
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
|
||||
if !config.is_valid() {
|
||||
return Err("invalid SSH configuration".to_string());
|
||||
}
|
||||
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
|
||||
// For now, store a mock entry with local_port = 15432.
|
||||
self.tunnels.insert(
|
||||
key.to_string(),
|
||||
SshTunnel {
|
||||
local_port: 15432,
|
||||
remote_host: config.host.clone(),
|
||||
remote_port: config.port,
|
||||
},
|
||||
);
|
||||
Ok(15432)
|
||||
}
|
||||
|
||||
/// Close and remove the SSH tunnel for the given key.
|
||||
///
|
||||
/// TODO: When real SSH is implemented, this should disconnect the
|
||||
/// session and free the local port.
|
||||
pub fn close_tunnel(&mut self, key: &str) {
|
||||
self.tunnels.remove(key);
|
||||
}
|
||||
|
||||
/// Close all active SSH tunnels.
|
||||
pub fn close_all(&mut self) {
|
||||
self.tunnels.clear();
|
||||
}
|
||||
|
||||
/// Get the local port for an active tunnel, if any.
|
||||
pub fn get_local_port(&self, key: &str) -> Option<u16> {
|
||||
self.tunnels.get(key).map(|t| t.local_port)
|
||||
}
|
||||
|
||||
/// Return the number of active tunnels.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.tunnels.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SshConfig validation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ssh_config_validation() {
|
||||
// Invalid: empty host
|
||||
let config = SshConfig::new(
|
||||
"".to_string(),
|
||||
22,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty host should be invalid");
|
||||
|
||||
// Invalid: empty user
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
22,
|
||||
"".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty user should be invalid");
|
||||
|
||||
// Valid: all required fields present
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
2222,
|
||||
"tunnel".to_string(),
|
||||
"key".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "valid config should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_config_rejects_non_standard_ports() {
|
||||
// Port 0 is invalid
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
0,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "port 0 should be invalid");
|
||||
|
||||
// Port 1 is valid (boundary)
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
1,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "port 1 should be valid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::models::{Tag, TagInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &TagInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 50 {
|
||||
return Err("name is required and must be 50 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tags_inner(state: &Mutex<Store>) -> Result<Vec<Tag>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_tags()
|
||||
}
|
||||
|
||||
pub fn create_tag_inner(state: &Mutex<Store>, input: TagInput) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_tag(input)
|
||||
}
|
||||
|
||||
pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_tag(id)
|
||||
}
|
||||
|
||||
pub fn update_tag_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: TagInput,
|
||||
) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_tag(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tags(state: tauri::State<crate::AppState>) -> Result<Vec<Tag>, String> {
|
||||
get_tags_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(state: tauri::State<crate::AppState>, input: TagInput) -> Result<Tag, String> {
|
||||
create_tag_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_tag_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_tag(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: TagInput,
|
||||
) -> Result<Tag, String> {
|
||||
update_tag_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
use crate::models::TagInput;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_command_works() {
|
||||
let st = state();
|
||||
let tag = create_tag_inner(&st, TagInput { name: "prod".into(), color: "#ef4444".into() }).unwrap();
|
||||
assert_eq!(get_tags_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(tag.name, "prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_rejects_long_name() {
|
||||
let st = state();
|
||||
let result = create_tag_inner(&st, TagInput { name: "x".repeat(51), color: "#fff".into() });
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::pool::DbConfig;
|
||||
|
||||
/// Result of a test database connection attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestConnectionResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Strip credentials and sensitive information from error messages while
|
||||
/// preserving the useful diagnostic detail (severity, message, SQLSTATE).
|
||||
///
|
||||
/// Redacts `password=...`, `user=...`, `postgresql://user:pwd@host` URLs,
|
||||
/// and `@host` credential fragments rather than discarding the whole
|
||||
/// message — so the user can still see e.g. "password authentication
|
||||
/// failed for user 'foo'" without leaking the password itself.
|
||||
pub fn sanitize_error(msg: &str) -> String {
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let bytes = msg.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let lower = msg[i..].to_lowercase();
|
||||
if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
|
||||
out.push_str("[redacted-url://");
|
||||
let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3;
|
||||
let rest = &msg[scheme_end..];
|
||||
let end = match rest.find(['/', '?']) {
|
||||
Some(pos) => scheme_end + pos,
|
||||
None => msg.len(),
|
||||
};
|
||||
i = end;
|
||||
} else if lower.starts_with("password=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "password=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "password=".len() + skip;
|
||||
} else if lower.starts_with("user=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "user=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "user=".len() + skip;
|
||||
} else if lower.starts_with("secret") {
|
||||
out.push_str("secret=[redacted]");
|
||||
let rest = &msg[i + "secret".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "secret".len() + skip;
|
||||
} else {
|
||||
let ch = msg[i..].chars().next().unwrap();
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
// Truncate at 300 characters for safety.
|
||||
if out.len() > 300 {
|
||||
format!("{}...", &out[..297])
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate `DbConfig` before attempting a connection test.
|
||||
///
|
||||
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
||||
///
|
||||
/// Validation rules:
|
||||
/// - `db_type` must be one of: `postgresql`, `mysql`, `sqlite`, `redis`
|
||||
/// - For `postgresql`, `mysql`, `redis`: `host` must not be empty, `port` must
|
||||
/// be `Some(1..=65535)`
|
||||
/// - For `sqlite`: `host` (file path) must not be empty
|
||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||
let db_type = config.db_type.to_lowercase();
|
||||
|
||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
if !valid_types.contains(&db_type.as_str()) {
|
||||
return Some(format!(
|
||||
"unsupported database type: {}. Supported types: {}",
|
||||
config.db_type,
|
||||
valid_types.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if config.host.is_empty() {
|
||||
return Some("host must not be empty".to_string());
|
||||
}
|
||||
|
||||
// SQLite does not require a port (host is the file path)
|
||||
if db_type != "sqlite" {
|
||||
match config.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => {
|
||||
return Some(
|
||||
"port must be an integer between 1 and 65535 for this db_type"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Test a database connection for the given configuration.
|
||||
///
|
||||
/// Dispatches to the appropriate type-specific connection test based on
|
||||
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
||||
/// or failure with a sanitized error message.
|
||||
pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
// Validate input first
|
||||
if let Some(err) = validate_test_input(config) {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(err),
|
||||
};
|
||||
}
|
||||
|
||||
let result = match config.db_type.to_lowercase().as_str() {
|
||||
"postgresql" => test_pg_connection(config).await,
|
||||
"mysql" => test_mysql_connection(config).await,
|
||||
"sqlite" => test_sqlite_connection(config),
|
||||
"redis" => test_redis_connection(config).await,
|
||||
other => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(format!("unsupported database type: {other}")),
|
||||
},
|
||||
};
|
||||
|
||||
TestConnectionResult {
|
||||
ok: result.ok,
|
||||
error: result.error.map(|e| sanitize_error(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a PostgreSQL connection using `tokio-postgres`.
|
||||
///
|
||||
/// Connects without TLS. The connection handler is spawned and immediately
|
||||
/// dropped after confirming the connection is alive.
|
||||
async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use tokio_postgres::NoTls;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(5432) as u16;
|
||||
let user = config.username.as_deref().unwrap_or("postgres");
|
||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
|
||||
// Use a postgres URL rather than libpq key=value format: tokio-postgres
|
||||
// parses URLs reliably and urlencoding handles special chars safely.
|
||||
use urlencoding::encode as enc;
|
||||
let conn_str = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
||||
enc(user),
|
||||
enc(password),
|
||||
host,
|
||||
port,
|
||||
enc(dbname),
|
||||
);
|
||||
|
||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
||||
Ok((_client, connection)) => {
|
||||
// Spawn the connection handler so it keeps running while we test
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("connection error: {}", e);
|
||||
}
|
||||
});
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a MySQL connection using `sqlx`.
|
||||
///
|
||||
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
||||
/// `acquire_timeout`.
|
||||
async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(3306);
|
||||
let user = config.username.as_deref().unwrap_or("root");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
let dbname = config.database.as_deref().unwrap_or("mysql");
|
||||
|
||||
let conn_str = format!(
|
||||
"mysql://{}:{}@{}:{}/{}",
|
||||
user, password, host, port, dbname
|
||||
);
|
||||
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&conn_str)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
pool.close().await;
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a SQLite connection using `rusqlite`.
|
||||
///
|
||||
/// Opens the database file at `config.host`. Returns success if the file
|
||||
/// can be opened as a valid SQLite database.
|
||||
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
match rusqlite::Connection::open(&config.host) {
|
||||
Ok(_conn) => TestConnectionResult { ok: true, error: None },
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a Redis connection using the `redis` crate.
|
||||
///
|
||||
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
||||
/// 10-second timeout via `tokio::time::timeout`.
|
||||
async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use tokio::time::timeout;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(6379);
|
||||
let password = config.password.as_deref();
|
||||
|
||||
let conn_str = if let Some(pwd) = password {
|
||||
format!("redis://:{}@{}:{}/", pwd, host, port)
|
||||
} else {
|
||||
format!("redis://{}:{}/", host, port)
|
||||
};
|
||||
|
||||
match redis::Client::open(conn_str.as_str()) {
|
||||
Ok(client) => {
|
||||
match timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.get_multiplexed_async_connection(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None },
|
||||
Ok(Err(e)) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(_) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection timed out after 10 seconds".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to test a database connection.
|
||||
///
|
||||
/// Calls `test_database_connection` and returns the result.
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(config: DbConfig) -> Result<TestConnectionResult, String> {
|
||||
Ok(test_database_connection(&config).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TestConnectionResult serialization
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_result_serialization() {
|
||||
// ok=true result serializes correctly
|
||||
let result = TestConnectionResult { ok: true, error: None };
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON");
|
||||
|
||||
// error result includes the error message
|
||||
let result = TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection refused".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"connection refused\""), "error message should appear in JSON");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// sanitize_error
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_sanitizes_error() {
|
||||
let msg = "connection failed: password=secret123 user=admin";
|
||||
let sanitized = sanitize_error(msg);
|
||||
assert!(!sanitized.contains("secret123"), "should not leak password value");
|
||||
assert!(!sanitized.contains("admin"), "should not leak username value");
|
||||
assert!(!sanitized.contains("password="), "should remove password= pattern");
|
||||
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input rejection
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_rejects_invalid() {
|
||||
// Unsupported db type
|
||||
let config = DbConfig {
|
||||
db_type: "mongodb".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(27017),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"mongodb should be rejected"
|
||||
);
|
||||
|
||||
// Empty host
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "".to_string(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"empty host should be rejected"
|
||||
);
|
||||
|
||||
// Port 0
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(0),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"port 0 should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input acceptance
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_accepts_valid() {
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
password: None,
|
||||
database: Some("mydb".to_string()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"valid postgresql config should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_accepts_no_port() {
|
||||
// SQLite does not require a port
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite without port should be accepted"
|
||||
);
|
||||
|
||||
// SQLite should also accept a config with any port (port is ignored)
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: Some(9999),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite with any port should be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Schema introspection query builders.
|
||||
//!
|
||||
//! This module provides pure functions that generate SQL query strings
|
||||
//! for database schema introspection. No actual DB connections are needed
|
||||
//! for testing — all functions are deterministic string builders.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Approximate row count for a table via `pg_class.reltuples`.
|
||||
pub fn pg_reltuples_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT reltuples::bigint AS count FROM pg_class \
|
||||
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '{}') \
|
||||
AND relname = '{}'",
|
||||
schema, table
|
||||
)
|
||||
}
|
||||
|
||||
/// List tables and views in a schema (or all non-system schemata).
|
||||
///
|
||||
/// When `schema` is `None` all schemata except the built-in system schemata
|
||||
/// (`pg_catalog`, `information_schema`) are included.
|
||||
pub fn pg_tables_query(schema: Option<&str>) -> String {
|
||||
match schema {
|
||||
Some(s) => format!(
|
||||
"SELECT table_name, table_type FROM information_schema.tables \
|
||||
WHERE table_schema = '{}' ORDER BY table_name",
|
||||
s
|
||||
),
|
||||
None => {
|
||||
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
|
||||
ORDER BY table_schema, table_name"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all non-system schemata.
|
||||
pub fn pg_schemas_query() -> String {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||
ORDER BY schema_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// List non-template databases.
|
||||
pub fn pg_databases_query() -> String {
|
||||
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname".to_string()
|
||||
}
|
||||
|
||||
/// Column details with primary-key and foreign-key annotations.
|
||||
///
|
||||
/// Joins `information_schema.columns` with constraint metadata so that
|
||||
/// each row includes PK / FK information when applicable.
|
||||
pub fn pg_columns_query(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
r#"SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable,
|
||||
c.character_maximum_length,
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
c.column_default,
|
||||
c.ordinal_position,
|
||||
pk.constraint_type,
|
||||
fk.foreign_table_schema,
|
||||
fk.foreign_table_name,
|
||||
fk.foreign_column_name
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN (
|
||||
SELECT kcu.column_name, kcu.table_schema, kcu.table_name, 'PRIMARY KEY' AS constraint_type
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_catalog = kcu.constraint_catalog
|
||||
AND tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
) pk ON c.table_schema = pk.table_schema AND c.table_name = pk.table_name AND c.column_name = pk.column_name
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
kcu.table_schema,
|
||||
kcu.table_name,
|
||||
ccu.table_schema AS foreign_table_schema,
|
||||
ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_catalog = kcu.constraint_catalog
|
||||
AND tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_catalog = ccu.constraint_catalog
|
||||
AND tc.constraint_schema = ccu.constraint_schema
|
||||
AND tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
) fk ON c.table_schema = fk.table_schema AND c.table_name = fk.table_name AND c.column_name = fk.column_name
|
||||
WHERE c.table_schema = '{}' AND c.table_name = '{}'
|
||||
ORDER BY c.ordinal_position"#,
|
||||
schema, table
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List tables (and views) in the given schema.
|
||||
///
|
||||
/// When `schema` is `None` all non-system schemata are included (excluding
|
||||
/// `information_schema`, `performance_schema`, `mysql`, and `sys`).
|
||||
pub fn mysql_tables_query(schema: Option<&str>) -> String {
|
||||
match schema {
|
||||
Some(s) => format!(
|
||||
"SELECT table_name, table_type FROM information_schema.tables \
|
||||
WHERE table_schema = '{}' ORDER BY table_name",
|
||||
s
|
||||
),
|
||||
None => {
|
||||
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
||||
WHERE table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') \
|
||||
ORDER BY table_schema, table_name"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List tables and views from `sqlite_master`.
|
||||
pub fn sqlite_tables_query() -> String {
|
||||
"SELECT name AS table_name, type AS table_type FROM sqlite_master \
|
||||
WHERE type IN ('table', 'view') ORDER BY name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Column metadata via `PRAGMA table_info`.
|
||||
pub fn sqlite_columns_query(table: &str) -> String {
|
||||
format!("PRAGMA table_info('{}')", table)
|
||||
}
|
||||
|
||||
/// Foreign-key metadata via `PRAGMA foreign_key_list`.
|
||||
pub fn sqlite_foreign_keys_query(table: &str) -> String {
|
||||
format!("PRAGMA foreign_key_list('{}')", table)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a paginated `SELECT` query.
|
||||
///
|
||||
/// Returns the SQL string (with `$1` / `$2` placeholders for `LIMIT` and
|
||||
/// `OFFSET`) together with a vector of the corresponding `i64` parameter
|
||||
/// values `[page_size, page * page_size]`.
|
||||
///
|
||||
/// When `columns` is empty the query uses `*`.
|
||||
pub fn build_select_query(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> (String, Vec<i64>) {
|
||||
let cols = if columns.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
for (i, col) in columns.iter().enumerate() {
|
||||
if i > 0 {
|
||||
buf.push_str(", ");
|
||||
}
|
||||
buf.push('"');
|
||||
buf.push_str(col);
|
||||
buf.push('"');
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} FROM \"{}\".\"{}\" LIMIT $1 OFFSET $2",
|
||||
cols, schema, table
|
||||
);
|
||||
|
||||
let params = vec![page_size, page * page_size];
|
||||
|
||||
(sql, params)
|
||||
}
|
||||
|
||||
/// Build a `COUNT(*)` query.
|
||||
pub fn build_count_query(schema: &str, table: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// PostgreSQL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_count_approximation_query_is_valid() {
|
||||
let sql = pg_reltuples_query("public", "users");
|
||||
assert!(
|
||||
sql.contains("pg_class"),
|
||||
"should query pg_class for row estimates; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_list_query_is_valid() {
|
||||
let sql = pg_tables_query(Some("public"));
|
||||
assert!(
|
||||
sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain the given schema");
|
||||
|
||||
// Without schema filter — should exclude system schemata
|
||||
let all_sql = pg_tables_query(None);
|
||||
assert!(
|
||||
all_sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables"
|
||||
);
|
||||
assert!(
|
||||
all_sql.contains("pg_catalog"),
|
||||
"should exclude pg_catalog via NOT IN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_column_query_is_valid() {
|
||||
let sql = pg_columns_query("public", "orders");
|
||||
assert!(
|
||||
sql.contains("information_schema.columns"),
|
||||
"should query information_schema.columns; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
assert!(
|
||||
sql.contains("FOREIGN KEY"),
|
||||
"should include FK constraint metadata"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("PRIMARY KEY"),
|
||||
"should include PK constraint metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_schemas_query_is_valid() {
|
||||
let sql = pg_schemas_query();
|
||||
assert!(sql.contains("information_schema.schemata"));
|
||||
assert!(sql.contains("pg_catalog"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_databases_query_is_valid() {
|
||||
let sql = pg_databases_query();
|
||||
assert!(sql.contains("pg_database"));
|
||||
assert!(sql.contains("datistemplate"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// MySQL
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_table_list_query_is_valid() {
|
||||
let sql = mysql_tables_query(Some("mydb"));
|
||||
assert!(
|
||||
sql.contains("information_schema.tables"),
|
||||
"should query information_schema.tables; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("mydb"), "should contain the given schema");
|
||||
|
||||
let all_sql = mysql_tables_query(None);
|
||||
assert!(all_sql.contains("information_schema.tables"));
|
||||
assert!(all_sql.contains("performance_schema"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// SQLite
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sqlite_table_list_query_is_valid() {
|
||||
let sql = sqlite_tables_query();
|
||||
assert!(
|
||||
sql.contains("sqlite_master"),
|
||||
"should query sqlite_master; got: {}",
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_columns_query_is_valid() {
|
||||
let sql = sqlite_columns_query("users");
|
||||
assert!(
|
||||
sql.contains("PRAGMA table_info"),
|
||||
"should use PRAGMA table_info; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_foreign_keys_query_is_valid() {
|
||||
let sql = sqlite_foreign_keys_query("orders");
|
||||
assert!(
|
||||
sql.contains("PRAGMA foreign_key_list"),
|
||||
"should use PRAGMA foreign_key_list; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_paginated_query_with_limits() {
|
||||
let columns = vec!["id".to_string(), "name".to_string()];
|
||||
let (sql, params) = build_select_query("public", "users", &columns, 2, 25);
|
||||
|
||||
assert!(
|
||||
sql.contains("LIMIT"),
|
||||
"should contain LIMIT clause; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("OFFSET"),
|
||||
"should contain OFFSET clause; got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("users"), "should contain table name");
|
||||
assert!(sql.contains("\"id\""), "should quote column names");
|
||||
assert!(sql.contains("\"name\""), "should quote column names");
|
||||
|
||||
// page=2, page_size=25 => offset = 50
|
||||
assert_eq!(params, vec![25, 50], "params should be [page_size, offset]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_paginated_query_empty_columns_uses_star() {
|
||||
let (sql, _) = build_select_query("public", "users", &[], 0, 10);
|
||||
assert!(
|
||||
sql.contains('*'),
|
||||
"empty columns should produce SELECT *; got: {}",
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_count_query_is_valid() {
|
||||
let sql = build_count_query("public", "orders");
|
||||
assert!(
|
||||
sql.contains("COUNT(*)"),
|
||||
"should contain COUNT(*); got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(sql.contains("public"), "should contain schema name");
|
||||
assert!(sql.contains("orders"), "should contain table name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod pool;
|
||||
pub mod introspection;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
@@ -0,0 +1,264 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Configuration for establishing a database connection.
|
||||
///
|
||||
/// Fields map to connection parameters. For SQLite, `host` stores the
|
||||
/// file path and `port` is always `None`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
}
|
||||
|
||||
impl DbConfig {
|
||||
/// Create a `DbConfig` for a SQLite database at `path`.
|
||||
///
|
||||
/// `host` is set to the file path; all other optional fields are `None`.
|
||||
pub fn sqlite(path: &str) -> Self {
|
||||
Self {
|
||||
db_type: "SQLite".into(),
|
||||
host: path.into(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to an active database connection.
|
||||
///
|
||||
/// Supports `Sqlite` (synchronous via `rusqlite`) and
|
||||
/// `Postgresql` (async via `tokio-postgres`). MySQL and Redis
|
||||
/// variants will be added in later tasks.
|
||||
#[derive(Debug)]
|
||||
pub enum DbHandle {
|
||||
/// A synchronous SQLite connection via `rusqlite`.
|
||||
Sqlite(rusqlite::Connection),
|
||||
/// An asynchronous PostgreSQL connection via `tokio-postgres`.
|
||||
/// Stores the client handle and the background connection task.
|
||||
Postgresql(tokio_postgres::Client, tokio::task::JoinHandle<()>),
|
||||
}
|
||||
|
||||
/// Internal entry stored in the pool manager.
|
||||
///
|
||||
/// Tracks the database handle and the last time it was accessed for LRU
|
||||
/// eviction.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DbPoolEntry {
|
||||
pub(crate) handle: DbHandle,
|
||||
pub(crate) last_accessed: Instant,
|
||||
}
|
||||
|
||||
/// A connection pool manager with LRU eviction.
|
||||
///
|
||||
/// Manages a set of active database handles keyed by a user-defined
|
||||
/// identifier. When the number of registered pools exceeds `max_pools`,
|
||||
/// the least-recently-used entry (i.e. the pool whose handle was accessed
|
||||
/// furthest in the past) is evicted.
|
||||
///
|
||||
/// Default `max_pools` is 5.
|
||||
pub struct ConnectionPoolManager {
|
||||
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
||||
max_pools: usize,
|
||||
}
|
||||
|
||||
impl ConnectionPoolManager {
|
||||
/// Create a new manager with a maximum of 5 pools.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pools: indexmap::IndexMap::new(),
|
||||
max_pools: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the maximum number of pools before LRU eviction kicks in.
|
||||
///
|
||||
/// If the current pool count exceeds the new maximum, the oldest
|
||||
/// entries are evicted immediately.
|
||||
pub fn set_max_pools(&mut self, max: usize) {
|
||||
self.max_pools = max;
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new database handle under `id`.
|
||||
///
|
||||
/// * If `id` already exists the old entry is removed first.
|
||||
/// * The new entry is inserted as the most-recently-used.
|
||||
/// * If the total pool count exceeds `max_pools` the least-recently-used
|
||||
/// (oldest) entry is evicted.
|
||||
pub fn register(&mut self, id: &str, handle: DbHandle) {
|
||||
// Remove existing entry if present
|
||||
self.pools.shift_remove(id);
|
||||
|
||||
let entry = DbPoolEntry {
|
||||
handle,
|
||||
last_accessed: Instant::now(),
|
||||
};
|
||||
self.pools.insert(id.to_string(), entry);
|
||||
|
||||
// LRU eviction: remove oldest (front) entries until within capacity
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the handle for `id`, or `None`.
|
||||
///
|
||||
/// Updates the last-accessed timestamp and re-orders the entry to
|
||||
/// mark it as most-recently-used.
|
||||
pub fn get(&mut self, id: &str) -> Option<&mut DbHandle> {
|
||||
if let Some((key, mut entry)) = self.pools.shift_remove_entry(id) {
|
||||
entry.last_accessed = Instant::now();
|
||||
self.pools.insert(key, entry);
|
||||
// The newly inserted entry is at the end (MRU position)
|
||||
self.pools.last_mut().map(|(_, e)| &mut e.handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the pool with `id` from the manager.
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.pools.shift_remove(id);
|
||||
}
|
||||
|
||||
/// Return a reference to the underlying pool map.
|
||||
pub(crate) fn pools(&self) -> &indexmap::IndexMap<String, DbPoolEntry> {
|
||||
&self.pools
|
||||
}
|
||||
|
||||
/// Return `true` if a pool with `id` is registered.
|
||||
pub fn contains(&self, id: &str) -> bool {
|
||||
self.pools.contains_key(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DbConfig tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_pg_pool_with_minimal_config() {
|
||||
let cfg = DbConfig {
|
||||
db_type: "PostgreSQL".into(),
|
||||
host: "pg.example.com".into(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".into()),
|
||||
password: Some("secret".into()),
|
||||
database: Some("mydb".into()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(cfg.db_type, "PostgreSQL");
|
||||
assert_eq!(cfg.host, "pg.example.com");
|
||||
assert_eq!(cfg.port, Some(5432));
|
||||
assert_eq!(cfg.username.as_deref(), Some("admin"));
|
||||
assert_eq!(cfg.password.as_deref(), Some("secret"));
|
||||
assert_eq!(cfg.database.as_deref(), Some("mydb"));
|
||||
assert!(cfg.ssl_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_for_sqlite_has_no_port() {
|
||||
let cfg = DbConfig::sqlite("/tmp/test.db");
|
||||
|
||||
assert_eq!(cfg.db_type, "SQLite");
|
||||
assert_eq!(cfg.host, "/tmp/test.db");
|
||||
assert!(cfg.port.is_none());
|
||||
assert!(cfg.username.is_none());
|
||||
assert!(cfg.database.is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ConnectionPoolManager tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pool_manager_starts_empty() {
|
||||
let manager = ConnectionPoolManager::new();
|
||||
assert_eq!(manager.pools().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_register_and_evict() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(2);
|
||||
|
||||
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
|
||||
|
||||
// Register A, B, then C with max=2 -- A should be evicted (LRU)
|
||||
manager.register("a", DbHandle::Sqlite(conn_a));
|
||||
manager.register("b", DbHandle::Sqlite(conn_b));
|
||||
manager.register("c", DbHandle::Sqlite(conn_c));
|
||||
|
||||
assert_eq!(manager.pools().len(), 2);
|
||||
assert!(!manager.contains("a"), "'a' should have been evicted (LRU)");
|
||||
assert!(manager.contains("b"));
|
||||
assert!(manager.contains("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_remove_closes_pool() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
manager.register("tmp", DbHandle::Sqlite(conn));
|
||||
assert!(manager.contains("tmp"));
|
||||
|
||||
manager.remove("tmp");
|
||||
assert!(!manager.contains("tmp"));
|
||||
assert_eq!(manager.pools().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_manager_get_updates_access_time() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(3);
|
||||
|
||||
let conn_a = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_b = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let conn_c = rusqlite::Connection::open_in_memory().unwrap();
|
||||
|
||||
manager.register("a", DbHandle::Sqlite(conn_a));
|
||||
manager.register("b", DbHandle::Sqlite(conn_b));
|
||||
manager.register("c", DbHandle::Sqlite(conn_c));
|
||||
|
||||
// Access "a" -- makes it MRU
|
||||
let _handle = manager.get("a").unwrap();
|
||||
|
||||
// Register "d" with max=3 -- "b" (now LRU) should be evicted, not "a"
|
||||
let conn_d = rusqlite::Connection::open_in_memory().unwrap();
|
||||
manager.register("d", DbHandle::Sqlite(conn_d));
|
||||
|
||||
assert_eq!(manager.pools().len(), 3);
|
||||
assert!(manager.contains("a"), "'a' was recently accessed, should survive");
|
||||
assert!(!manager.contains("b"), "'b' is LRU and should be evicted");
|
||||
assert!(manager.contains("c"));
|
||||
assert!(manager.contains("d"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,26 @@
|
||||
// Infrastructure modules: types, introspection, and DB viewer commands are built ahead
|
||||
// of runtime usage, producing expected dead_code/unused warnings during development.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod db;
|
||||
mod models;
|
||||
mod store;
|
||||
mod commands;
|
||||
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tauri::Manager;
|
||||
use store::Store;
|
||||
use commands::ssh::SshTunnelManager;
|
||||
use db::pool::ConnectionPoolManager;
|
||||
|
||||
pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||
}
|
||||
|
||||
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
@@ -6,9 +29,62 @@ fn greet(name: &str) -> String {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let store = Store::open("gridline.db").expect("failed to open db");
|
||||
let store_ref = StdMutex::new(store);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
.manage(AppState {
|
||||
db_store: store_ref,
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new()),
|
||||
})
|
||||
.setup(move |app| {
|
||||
let state = app.state::<AppState>();
|
||||
demo::ensure_demo_db(app.handle(), &state.db_store)
|
||||
.map_err(|e| {
|
||||
eprintln!("Failed to set up demo DB: {e}");
|
||||
})
|
||||
.ok();
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
connections::get_connections,
|
||||
connections::create_connection,
|
||||
connections::update_connection,
|
||||
connections::delete_connection,
|
||||
connections::add_connection_tags,
|
||||
folders::get_folders,
|
||||
folders::create_folder,
|
||||
folders::delete_folder,
|
||||
folders::update_folder,
|
||||
folders::add_folder_tags,
|
||||
tags::get_tags,
|
||||
tags::create_tag,
|
||||
tags::delete_tag,
|
||||
tags::update_tag,
|
||||
settings::get_settings,
|
||||
settings::update_setting,
|
||||
import_export::import_connections,
|
||||
import_export::export_connections,
|
||||
commands::test_connection::test_connection,
|
||||
db_viewer::db_connect,
|
||||
db_viewer::db_disconnect,
|
||||
db_viewer::get_databases,
|
||||
db_viewer::get_schemas,
|
||||
db_viewer::get_tables,
|
||||
db_viewer::get_table_data,
|
||||
db_viewer::get_fk_preview,
|
||||
db_viewer::execute_change,
|
||||
db_viewer::refresh_connection,
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
demo::recreate_demo_db,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Connection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub keychain_ref: Option<String>,
|
||||
pub environment: Option<String>,
|
||||
pub ssh_host: Option<String>,
|
||||
pub ssh_port: Option<i64>,
|
||||
pub ssh_user: Option<String>,
|
||||
pub ssh_auth_method: Option<String>,
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionInput {
|
||||
pub name: String,
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub password: Option<String>,
|
||||
pub database: Option<String>,
|
||||
pub environment: Option<String>,
|
||||
pub ssh_host: Option<String>,
|
||||
pub ssh_port: Option<i64>,
|
||||
pub ssh_user: Option<String>,
|
||||
pub ssh_auth_method: Option<String>,
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
pub ssh_passphrase: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connection_input_roundtrip_with_all_fields() {
|
||||
let input = ConnectionInput {
|
||||
name: "Test DB".to_string(),
|
||||
db_type: "PostgreSQL".to_string(),
|
||||
host: "db.example.com".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".to_string()),
|
||||
folder_id: Some("folder1".to_string()),
|
||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
password: Some("secret123".to_string()),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("jumphost.example.com".to_string()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("tunnel".to_string()),
|
||||
ssh_auth_method: Some("Key".to_string()),
|
||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||
ssh_passphrase: Some("passphrase".to_string()),
|
||||
ssl_mode: Some("require".to_string()),
|
||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||
ssl_cert_path: Some("/path/to/cert".to_string()),
|
||||
ssl_key_path: Some("/path/to/key".to_string()),
|
||||
environment: Some("production".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&input).unwrap();
|
||||
let deserialized: ConnectionInput = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.name, "Test DB");
|
||||
assert_eq!(deserialized.db_type, "PostgreSQL");
|
||||
assert_eq!(deserialized.host, "db.example.com");
|
||||
assert_eq!(deserialized.port, Some(5432));
|
||||
assert_eq!(deserialized.username, Some("admin".to_string()));
|
||||
assert_eq!(deserialized.folder_id, Some("folder1".to_string()));
|
||||
assert_eq!(deserialized.tag_ids, vec!["tag1".to_string(), "tag2".to_string()]);
|
||||
assert_eq!(deserialized.password, Some("secret123".to_string()));
|
||||
assert_eq!(deserialized.database, Some("mydb".to_string()));
|
||||
assert_eq!(deserialized.ssh_host, Some("jumphost.example.com".to_string()));
|
||||
assert_eq!(deserialized.ssh_port, Some(2222));
|
||||
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
|
||||
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
|
||||
assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string()));
|
||||
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
|
||||
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
|
||||
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
|
||||
assert_eq!(deserialized.ssl_cert_path, Some("/path/to/cert".to_string()));
|
||||
assert_eq!(deserialized.ssl_key_path, Some("/path/to/key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_persisted_does_not_include_password() {
|
||||
let conn = Connection {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test".to_string(),
|
||||
db_type: "PostgreSQL".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
folder_id: Some("folder".to_string()),
|
||||
keychain_ref: Some("keychain-ref".to_string()),
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("ssh-host".to_string()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("ssh-user".to_string()),
|
||||
ssh_auth_method: Some("Key".to_string()),
|
||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||
ssl_mode: Some("require".to_string()),
|
||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||
ssl_cert_path: Some("/path/to/cert".to_string()),
|
||||
ssl_key_path: Some("/path/to/key".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&conn).unwrap();
|
||||
assert!(!json.contains("password"), "Connection JSON should not contain password field");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TableInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub table_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub is_pk: bool,
|
||||
pub is_fk: bool,
|
||||
pub fk_ref: Option<(String, String)>,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryResult {
|
||||
pub columns: Vec<ColumnInfo>,
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
pub total_rows: i64,
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
pub total_rows: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
Update {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
primary_key: String,
|
||||
old_data: String,
|
||||
new_data: String,
|
||||
},
|
||||
Insert {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
data: String,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
primary_key: String,
|
||||
},
|
||||
AlterTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
sql: String,
|
||||
rollback_sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Change::Update { id, .. }
|
||||
| Change::Insert { id, .. }
|
||||
| Change::Delete { id, .. }
|
||||
| Change::AlterTable { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_info_serialization() {
|
||||
let info = TableInfo {
|
||||
name: "users".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table_type: "TABLE".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("users"));
|
||||
assert!(json.contains("public"));
|
||||
assert!(json.contains("TABLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_result_can_be_empty() {
|
||||
let result = QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
total_rows: 0,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains(r#""rows":[]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_id_method() {
|
||||
let update = Change::Update {
|
||||
id: "chg-1".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 1}".to_string(),
|
||||
old_data: "{\"name\": \"old\"}".to_string(),
|
||||
new_data: "{\"name\": \"new\"}".to_string(),
|
||||
};
|
||||
assert_eq!(update.id(), "chg-1");
|
||||
|
||||
let inserted = Change::Insert {
|
||||
id: "chg-2".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
data: "{\"name\": \"alice\"}".to_string(),
|
||||
};
|
||||
assert_eq!(inserted.id(), "chg-2");
|
||||
|
||||
let deleted = Change::Delete {
|
||||
id: "chg-3".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 2}".to_string(),
|
||||
};
|
||||
assert_eq!(deleted.id(), "chg-3");
|
||||
|
||||
let alter = Change::AlterTable {
|
||||
id: "chg-4".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
sql: "ALTER TABLE users ADD COLUMN age INT".to_string(),
|
||||
rollback_sql: "ALTER TABLE users DROP COLUMN age".to_string(),
|
||||
};
|
||||
assert_eq!(alter.id(), "chg-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_serde_tag() {
|
||||
let update = Change::Update {
|
||||
id: "chg-1".to_string(),
|
||||
schema: "public".to_string(),
|
||||
table: "users".to_string(),
|
||||
primary_key: "{\"id\": 1}".to_string(),
|
||||
old_data: "{\"name\": \"old\"}".to_string(),
|
||||
new_data: "{\"name\": \"new\"}".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"update""#),
|
||||
"serialized Change::Update should use snake_case tag 'update'; got: {}",
|
||||
json
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_info_fk_ref() {
|
||||
let col = ColumnInfo {
|
||||
name: "user_id".to_string(),
|
||||
data_type: "integer".to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
fk_ref: Some(("users".to_string(), "id".to_string())),
|
||||
default_value: None,
|
||||
};
|
||||
let json = serde_json::to_string(&col).unwrap();
|
||||
assert!(json.contains("user_id"));
|
||||
assert!(json.contains("users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_serialization() {
|
||||
let pagination = Pagination {
|
||||
page: 2,
|
||||
page_size: 50,
|
||||
total_rows: 250,
|
||||
};
|
||||
let json = serde_json::to_string(&pagination).unwrap();
|
||||
assert!(json.contains(r#""page":2"#));
|
||||
assert!(json.contains(r#""page_size":50"#));
|
||||
assert!(json.contains(r#""total_rows":250"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderInput {
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod connection;
|
||||
pub mod db_viewer;
|
||||
pub mod folder;
|
||||
pub mod tag;
|
||||
pub mod settings;
|
||||
|
||||
pub use connection::{Connection, ConnectionInput};
|
||||
#[allow(unused_imports)]
|
||||
pub use db_viewer::{Change, ColumnInfo, Pagination, QueryResult, TableInfo};
|
||||
pub use folder::{Folder, FolderInput};
|
||||
pub use settings::Settings;
|
||||
pub use tag::{Tag, TagInput};
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub confirm_before_delete: bool,
|
||||
pub default_folder_id: Option<String>,
|
||||
pub theme: String,
|
||||
pub font_size: String,
|
||||
pub default_ports: HashMap<String, Option<i64>>,
|
||||
pub tag_order: Option<String>,
|
||||
pub table_refresh_rate: i64,
|
||||
pub table_page_size: i64,
|
||||
pub shortcuts: HashMap<String, String>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TagInput {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// All new-column additions for the connections table since version 1.
|
||||
const CONNECTION_COLUMNS_V2: &[(&str, &str)] = &[
|
||||
("database", "TEXT"),
|
||||
("ssh_host", "TEXT"),
|
||||
("ssh_port", "INTEGER"),
|
||||
("ssh_user", "TEXT"),
|
||||
("ssh_auth_method", "TEXT"),
|
||||
("ssh_private_key_path", "TEXT"),
|
||||
("ssl_mode", "TEXT"),
|
||||
("ssl_ca_path", "TEXT"),
|
||||
("ssl_cert_path", "TEXT"),
|
||||
("ssl_key_path", "TEXT"),
|
||||
];
|
||||
|
||||
/// New columns added since version 2.
|
||||
const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[
|
||||
("environment", "TEXT"),
|
||||
];
|
||||
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
db_type TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER,
|
||||
username TEXT,
|
||||
database TEXT,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
keychain_ref TEXT,
|
||||
ssh_host TEXT,
|
||||
ssh_port INTEGER,
|
||||
ssh_user TEXT,
|
||||
ssh_auth_method TEXT,
|
||||
ssh_private_key_path TEXT,
|
||||
ssl_mode TEXT,
|
||||
ssl_ca_path TEXT,
|
||||
ssl_cert_path TEXT,
|
||||
ssl_key_path TEXT,
|
||||
environment TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL DEFAULT '#8b5cf6',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS connection_tags (
|
||||
connection_id TEXT NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (connection_id, tag_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_tags (
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, tag_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// --- Version-specific migrations ----------------------------------------
|
||||
|
||||
let current_ver: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if current_ver < 2 {
|
||||
// Discover which columns the connections table already has.
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (col_name, col_type) in CONNECTION_COLUMNS_V2 {
|
||||
if !existing.contains(&col_name.to_string()) {
|
||||
let sql = format!(
|
||||
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||
col_name, col_type
|
||||
);
|
||||
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration.
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (2)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if current_ver < 3 {
|
||||
let existing: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(connections)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (col_name, col_type) in CONNECTION_COLUMNS_V3 {
|
||||
if !existing.contains(&col_name.to_string()) {
|
||||
let sql = format!(
|
||||
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||
col_name, col_type
|
||||
);
|
||||
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (3)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fresh_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_create_all_tables() {
|
||||
let conn = fresh_db();
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(tables.contains(&"folders".to_string()));
|
||||
assert!(tables.contains(&"connections".to_string()));
|
||||
assert!(tables.contains(&"tags".to_string()));
|
||||
assert!(tables.contains(&"connection_tags".to_string()));
|
||||
assert!(tables.contains(&"settings".to_string()));
|
||||
assert!(tables.contains(&"schema_version".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_are_idempotent() {
|
||||
let conn = fresh_db();
|
||||
// Running again must not error
|
||||
run_migrations(&conn).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
pub mod migrations;
|
||||
|
||||
use rusqlite::params;
|
||||
use rusqlite::Connection as SqliteConnection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::{Connection, ConnectionInput, Folder, FolderInput, Settings, Tag, TagInput};
|
||||
|
||||
pub struct Store {
|
||||
conn: Mutex<SqliteConnection>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn from_connection(conn: SqliteConnection) -> Self {
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(path: &str) -> Result<Self, String> {
|
||||
let conn = SqliteConnection::open(path).map_err(|e| e.to_string())?;
|
||||
migrations::run_migrations(&conn).map_err(|e| e.to_string())?;
|
||||
Ok(Self::from_connection(conn))
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
pub fn get_folders(&self) -> Result<Vec<Folder>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Folder {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
parent_id: row.get(2)?,
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut folders: Vec<Folder> = rows.filter_map(|r| r.ok()).collect();
|
||||
// Load tags for each folder
|
||||
for f in folders.iter_mut() {
|
||||
let mut tag_stmt = conn
|
||||
.prepare("SELECT tag_id FROM folder_tags WHERE folder_id = ?1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_rows = tag_stmt
|
||||
.query_map(params![f.id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
f.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
pub fn create_folder(&self, input: FolderInput) -> Result<Folder, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO folders (id, name, parent_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, input.name, input.parent_id, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_ids = input.tag_ids.unwrap_or_default();
|
||||
for tag_id in &tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Folder {
|
||||
id,
|
||||
name: input.name,
|
||||
parent_id: input.parent_id,
|
||||
tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_folder(&self, id: &str, input: FolderInput) -> Result<Folder, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE folders SET name = ?1, parent_id = ?2, updated_at = ?3 WHERE id = ?4",
|
||||
params![input.name, input.parent_id, now, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Replace all tags: clear existing, insert new
|
||||
conn.execute("DELETE FROM folder_tags WHERE folder_id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_ids = input.tag_ids.unwrap_or_default();
|
||||
for tag_id in &tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Folder {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
parent_id: input.parent_id,
|
||||
tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
// Get the folder's parent_id to reparent children
|
||||
let parent_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM folders WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Move child folders to the parent
|
||||
conn.execute(
|
||||
"UPDATE folders SET parent_id = ?1 WHERE parent_id = ?2",
|
||||
params![parent_id, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Move child connections to the parent
|
||||
conn.execute(
|
||||
"UPDATE connections SET folder_id = ?1 WHERE folder_id = ?2",
|
||||
params![parent_id, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Delete the folder
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_folder_tags(&self, folder_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
for tag_id in tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
||||
params![folder_id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_connection_tags(&self, conn_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
for tag_id in tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![conn_id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tags(&self) -> Result<Vec<Tag>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, color, created_at FROM tags ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
color: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
pub fn create_tag(&self, input: TagInput) -> Result<Tag, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, input.name, input.color, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Tag {
|
||||
id,
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_tag(&self, id: &str, input: TagInput) -> Result<Tag, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
|
||||
params![input.name, input.color, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Tag {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_connections(&self) -> Result<Vec<Connection>, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Connection {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
db_type: row.get(2)?,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
database: row.get(6)?,
|
||||
folder_id: row.get(7)?,
|
||||
keychain_ref: row.get(8)?,
|
||||
ssh_host: row.get(9)?,
|
||||
ssh_port: row.get(10)?,
|
||||
ssh_user: row.get(11)?,
|
||||
ssh_auth_method: row.get(12)?,
|
||||
ssh_private_key_path: row.get(13)?,
|
||||
ssl_mode: row.get(14)?,
|
||||
ssl_ca_path: row.get(15)?,
|
||||
ssl_cert_path: row.get(16)?,
|
||||
ssl_key_path: row.get(17)?,
|
||||
environment: row.get(18)?,
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut conns: Vec<Connection> = rows.filter_map(|r| r.ok()).collect();
|
||||
// Load tags for each connection
|
||||
for c in conns.iter_mut() {
|
||||
let mut tag_stmt = conn
|
||||
.prepare("SELECT tag_id FROM connection_tags WHERE connection_id = ?1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag_rows = tag_stmt
|
||||
.query_map(params![c.id], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
c.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
Ok(conns)
|
||||
}
|
||||
|
||||
pub fn create_connection(&self, input: ConnectionInput) -> Result<Connection, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Connection {
|
||||
id,
|
||||
name: input.name,
|
||||
db_type: input.db_type,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
folder_id: input.folder_id,
|
||||
database: input.database,
|
||||
keychain_ref: None,
|
||||
environment: input.environment,
|
||||
ssh_host: input.ssh_host,
|
||||
ssh_port: input.ssh_port,
|
||||
ssh_user: input.ssh_user,
|
||||
ssh_auth_method: input.ssh_auth_method,
|
||||
ssh_private_key_path: input.ssh_private_key_path,
|
||||
ssl_mode: input.ssl_mode,
|
||||
ssl_ca_path: input.ssl_ca_path,
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_connection(&self, id: &str, input: ConnectionInput) -> Result<Connection, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
conn.execute(
|
||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
||||
params![
|
||||
input.name, input.db_type, input.host, input.port, input.username,
|
||||
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
||||
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
||||
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
||||
input.environment, now, id
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
// Update tags
|
||||
conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
||||
params![id, tag_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Connection {
|
||||
id: id.to_string(),
|
||||
name: input.name,
|
||||
db_type: input.db_type,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
database: input.database,
|
||||
folder_id: input.folder_id,
|
||||
keychain_ref: None,
|
||||
environment: input.environment,
|
||||
ssh_host: input.ssh_host,
|
||||
ssh_port: input.ssh_port,
|
||||
ssh_user: input.ssh_user,
|
||||
ssh_auth_method: input.ssh_auth_method,
|
||||
ssh_private_key_path: input.ssh_private_key_path,
|
||||
ssl_mode: input.ssl_mode,
|
||||
ssl_ca_path: input.ssl_ca_path,
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids.clone(),
|
||||
created_at: String::new(), // not updated
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<Settings, String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let mut map: HashMap<String, String> = HashMap::new();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for r in rows.filter_map(|r| r.ok()) {
|
||||
map.insert(r.0, r.1);
|
||||
}
|
||||
let theme = map
|
||||
.get("theme")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "system".to_string());
|
||||
let font_size = map
|
||||
.get("font_size")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "medium".to_string());
|
||||
let confirm = map
|
||||
.get("confirm_before_delete")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true);
|
||||
let default_folder_id = map
|
||||
.get("default_folder_id")
|
||||
.filter(|v| v.as_str() != "null")
|
||||
.cloned();
|
||||
let mut default_ports = HashMap::new();
|
||||
default_ports.insert("postgresql".to_string(), Some(5432i64));
|
||||
default_ports.insert("mysql".to_string(), Some(3306i64));
|
||||
default_ports.insert("redis".to_string(), Some(6379i64));
|
||||
default_ports.insert("sqlite".to_string(), None);
|
||||
if let Some(ports_json) = map.get("default_ports") {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_str::<HashMap<String, Option<i64>>>(ports_json)
|
||||
{
|
||||
default_ports = parsed;
|
||||
}
|
||||
}
|
||||
Ok(Settings {
|
||||
confirm_before_delete: confirm,
|
||||
default_folder_id,
|
||||
theme,
|
||||
font_size,
|
||||
default_ports,
|
||||
tag_order: map.get("tag_order").cloned(),
|
||||
table_refresh_rate: map
|
||||
.get("table_refresh_rate")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
table_page_size: map
|
||||
.get("table_page_size")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50),
|
||||
shortcuts: map
|
||||
.get("shortcuts")
|
||||
.and_then(|v| serde_json::from_str(v).ok())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_setting(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ConnectionInput, FolderInput, TagInput};
|
||||
|
||||
fn fresh_store() -> Store {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
Store::from_connection(conn)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_folder() {
|
||||
let store = fresh_store();
|
||||
let folder = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_folders().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "Work");
|
||||
assert_eq!(got[0].id, folder.id);
|
||||
assert!(got[0].parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_nested_folders() {
|
||||
let store = fresh_store();
|
||||
let parent = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "root".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
let child = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "child".into(),
|
||||
parent_id: Some(parent.id.clone()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(child.parent_id, Some(parent.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_tag() {
|
||||
let store = fresh_store();
|
||||
let tag = store
|
||||
.create_tag(TagInput {
|
||||
name: "production".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_tags().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "production");
|
||||
assert_eq!(got[0].color, "#ef4444");
|
||||
assert_eq!(got[0].id, tag.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_get_connection() {
|
||||
let store = fresh_store();
|
||||
let conn = store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "prod.example.com".into(),
|
||||
port: Some(5432),
|
||||
username: Some("admin".into()),
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name, "Prod");
|
||||
assert_eq!(got[0].port, Some(5432));
|
||||
assert!(got[0].tag_ids.is_empty());
|
||||
assert_eq!(got[0].id, conn.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_with_tags_persists_join() {
|
||||
let store = fresh_store();
|
||||
let t1 = store
|
||||
.create_tag(TagInput {
|
||||
name: "prod".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let t2 = store
|
||||
.create_tag(TagInput {
|
||||
name: "primary".into(),
|
||||
color: "#3b82f6".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got[0].tag_ids.len(), 2);
|
||||
assert!(got[0].tag_ids.contains(&t1.id));
|
||||
assert!(got[0].tag_ids.contains(&t2.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_sets_connection_folder_null() {
|
||||
let store = fresh_store();
|
||||
let folder = store
|
||||
.create_folder(FolderInput { tag_ids: None,
|
||||
name: "f".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "C".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: Some(folder.id.clone()),
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_folder(&folder.id).unwrap();
|
||||
let conns = store.get_connections().unwrap();
|
||||
assert!(conns[0].folder_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tag_removes_from_connection() {
|
||||
let store = fresh_store();
|
||||
let tag = store
|
||||
.create_tag(TagInput {
|
||||
name: "prod".into(),
|
||||
color: "#ef4444".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "C".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_tag(&tag.id).unwrap();
|
||||
let conns = store.get_connections().unwrap();
|
||||
assert!(conns[0].tag_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_get_returns_defaults_when_empty() {
|
||||
let store = fresh_store();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert_eq!(settings.theme, "system");
|
||||
assert_eq!(settings.font_size, "medium");
|
||||
assert!(settings.confirm_before_delete);
|
||||
assert_eq!(
|
||||
settings.default_ports.get("postgresql"),
|
||||
Some(&Some(5432))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_update_persists() {
|
||||
let store = fresh_store();
|
||||
store.update_setting("theme", "light").unwrap();
|
||||
let settings = store.get_settings().unwrap();
|
||||
assert_eq!(settings.theme, "light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_ssl_fields_persist_and_retrieve() {
|
||||
let store = fresh_store();
|
||||
let conn = store
|
||||
.create_connection(ConnectionInput {
|
||||
name: "SSH-Tunnel-DB".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "localhost".into(),
|
||||
port: Some(5432),
|
||||
username: Some("dbuser".into()),
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: Some("analytics".into()),
|
||||
ssh_host: Some("jumphost.example.com".into()),
|
||||
ssh_port: Some(2222),
|
||||
ssh_user: Some("tunneluser".into()),
|
||||
ssh_auth_method: Some("Key".into()),
|
||||
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: Some("verify-full".into()),
|
||||
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
||||
ssl_cert_path: Some("/etc/ssl/certs/client-cert.pem".into()),
|
||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].database.as_deref(), Some("analytics"));
|
||||
assert_eq!(got[0].ssh_host.as_deref(), Some("jumphost.example.com"));
|
||||
assert_eq!(got[0].ssh_port, Some(2222));
|
||||
assert_eq!(got[0].ssh_user.as_deref(), Some("tunneluser"));
|
||||
assert_eq!(got[0].ssh_auth_method.as_deref(), Some("Key"));
|
||||
assert_eq!(
|
||||
got[0].ssh_private_key_path.as_deref(),
|
||||
Some("/home/user/.ssh/id_rsa")
|
||||
);
|
||||
assert_eq!(got[0].ssl_mode.as_deref(), Some("verify-full"));
|
||||
assert_eq!(
|
||||
got[0].ssl_ca_path.as_deref(),
|
||||
Some("/etc/ssl/certs/ca.pem")
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].ssl_cert_path.as_deref(),
|
||||
Some("/etc/ssl/certs/client-cert.pem")
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].ssl_key_path.as_deref(),
|
||||
Some("/etc/ssl/private/client-key.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
{
|
||||
"title": "Gridline",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
"height": 800,
|
||||
"backgroundColor": "#0A0A0B",
|
||||
"titleBarStyle": "Transparent"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -31,5 +33,6 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafb);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
import { useConnectionStore } from "./stores/connectionStore";
|
||||
import { useSettingsStore } from "./stores/settingsStore";
|
||||
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({
|
||||
confirm_before_delete: true,
|
||||
default_folder_id: null,
|
||||
theme: "dark",
|
||||
font_size: "medium",
|
||||
default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null },
|
||||
}),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||
useUiStore.setState({ activeView: "home" });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("App", () => {
|
||||
it("renders home view on mount", async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByPlaceholderText(/search connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no connections", async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/no connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads data on mount", async () => {
|
||||
render(<App />);
|
||||
await screen.findByPlaceholderText(/search connections/i);
|
||||
expect(useConnectionStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it("renders settings page when activeView is settings", async () => {
|
||||
useUiStore.setState({ activeView: "settings" });
|
||||
render(<App />);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders new connection form when activeView is new-connection", async () => {
|
||||
useUiStore.setState({ activeView: "new-connection" });
|
||||
render(<App />);
|
||||
expect(await screen.findByText("Save Connection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error banner when connectionStore has error", async () => {
|
||||
const { getConnections } = await import("./lib/commands");
|
||||
vi.mocked(getConnections).mockRejectedValueOnce(new Error("Storage error"));
|
||||
useConnectionStore.setState({ connections: [], loading: false, error: null });
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/storage error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import reactLogo from "./assets/react.svg";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./App.css";
|
||||
import { useEffect } from "react";
|
||||
import { useConnectionStore } from "./stores/connectionStore";
|
||||
import { useSettingsStore } from "./stores/settingsStore";
|
||||
import { useUiStore } from "./stores/uiStore";
|
||||
import { HomeScreen } from "./components/layout/HomeScreen";
|
||||
import { SettingsPage } from "./components/settings/SettingsPage";
|
||||
import { NewConnectionScreen } from "./components/connections/NewConnectionScreen";
|
||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
function App() {
|
||||
const [greetMsg, setGreetMsg] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const VIEW_TITLES: Record<string, string> = {
|
||||
home: "Gridline",
|
||||
settings: "Settings",
|
||||
"new-connection": "New Connection",
|
||||
"db-viewer": "",
|
||||
};
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
setGreetMsg(await invoke("greet", { name }));
|
||||
}
|
||||
export default function App() {
|
||||
const activeView = useUiStore((s) => s.activeView);
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const loadConnections = useConnectionStore((s) => s.loadAll);
|
||||
const loadSettings = useSettingsStore((s) => s.load);
|
||||
const connectionError = useConnectionStore((s) => s.error);
|
||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString);
|
||||
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Welcome to Tauri + React</h1>
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
loadSettings();
|
||||
}, [loadConnections, loadSettings]);
|
||||
|
||||
<div className="row">
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
|
||||
useEffect(() => {
|
||||
let title = VIEW_TITLES[activeView] ?? "Gridline";
|
||||
if (activeView === "db-viewer") {
|
||||
const conn = useConnectionStore.getState().connections.find(
|
||||
(c) => c.id === useUiStore.getState().activeConnectionId
|
||||
);
|
||||
if (conn) title = conn.name;
|
||||
}
|
||||
document.title = title;
|
||||
try {
|
||||
getCurrentWindow().setTitle(title).catch(() => {
|
||||
// Ignore environments where the Tauri API is unavailable (tests, browser)
|
||||
});
|
||||
} catch {
|
||||
// getCurrentWindow can throw outside of a Tauri runtime
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="greet-input"
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="Enter a name..."
|
||||
/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-svh select-none">
|
||||
{connectionError && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={connectionError}
|
||||
onRetry={loadConnections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeView === "settings" && <SettingsPage />}
|
||||
{activeView === "new-connection" && (
|
||||
<NewConnectionScreen
|
||||
defaultFolderId={activeFolderId}
|
||||
prefilledConnectionString={prefilledConnectionString ?? ""}
|
||||
folders={folders}
|
||||
tags={tags}
|
||||
onSaved={() => {
|
||||
clearPrefilledConnectionString();
|
||||
setActiveView("home");
|
||||
}}
|
||||
onCancel={() => {
|
||||
clearPrefilledConnectionString();
|
||||
setActiveView("home");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === "home" && <HomeScreen />}
|
||||
{activeView === "db-viewer" && (
|
||||
<DbViewerScreen
|
||||
connectionId={useUiStore.getState().activeConnectionId ?? ""}
|
||||
onHome={() => setActiveView("home")}
|
||||
onSettings={() => setActiveView("settings")}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Key, Braces } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { FkPreviewPopover } from "./FkPreviewPopover";
|
||||
import { JsonCellPopover, jsonPreview } from "./JsonCellPopover";
|
||||
|
||||
// TODO: Replace this plain HTML table with @tanstack/react-virtual for large
|
||||
// result sets so we can render millions of rows without DOM overhead.
|
||||
|
||||
type ColumnWidths = Record<string, number>;
|
||||
type TabColumnWidths = Record<string, ColumnWidths>;
|
||||
|
||||
const DEFAULT_COL_WIDTH = 200;
|
||||
const MIN_COL_WIDTH = 60;
|
||||
const MAX_COL_WIDTH = 800;
|
||||
const CHECKBOX_COL_WIDTH = 40;
|
||||
|
||||
interface DataGridProps {
|
||||
connectionId: string;
|
||||
rows: unknown[][];
|
||||
hiddenColumns?: Set<string>;
|
||||
selectedRows: Set<number>;
|
||||
onSelectionChange: (selected: Set<number>) => void;
|
||||
}
|
||||
|
||||
export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const [colWidths, setColWidths] = useState<TabColumnWidths>({});
|
||||
|
||||
// FK preview popover state
|
||||
const [fkPreview, setFkPreview] = useState<{
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
anchorRect: DOMRect | null;
|
||||
} | null>(null);
|
||||
|
||||
// JSON cell popover state
|
||||
const [jsonPopover, setJsonPopover] = useState<{
|
||||
value: unknown;
|
||||
anchorRect: DOMRect | null;
|
||||
} | null>(null);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────
|
||||
|
||||
const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null;
|
||||
const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {};
|
||||
|
||||
const getWidth = useCallback(
|
||||
(colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH,
|
||||
[widths],
|
||||
);
|
||||
|
||||
// ── selection logic ────────────────────────────────────
|
||||
|
||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
||||
const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length;
|
||||
const checkboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = someSelected;
|
||||
}
|
||||
}, [someSelected]);
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
onSelectionChange(new Set());
|
||||
} else {
|
||||
onSelectionChange(new Set(rows.map((_, i) => i)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (rowIndex: number) => {
|
||||
const next = new Set(selectedRows);
|
||||
if (next.has(rowIndex)) next.delete(rowIndex);
|
||||
else next.add(rowIndex);
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
// ── resize handler (ref-based to avoid stale closures) ─
|
||||
|
||||
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(colName: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const delta = ev.clientX - resizeRef.current.startX;
|
||||
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta));
|
||||
setColWidths((prev) => ({
|
||||
...prev,
|
||||
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next },
|
||||
}));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
resizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[activeTabId, getWidth],
|
||||
);
|
||||
|
||||
// ── FK row-click handler ───────────────────────────────
|
||||
|
||||
const handleFkClick = useCallback(
|
||||
(col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => {
|
||||
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
|
||||
const [refTable] = col.fk_ref;
|
||||
const schema = activeTab?.schema ?? "public";
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setFkPreview({
|
||||
connectionId,
|
||||
schema,
|
||||
table: refTable,
|
||||
column: col.fk_ref[1],
|
||||
value: String(cellValue),
|
||||
anchorRect: rect,
|
||||
});
|
||||
},
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
// ── empty / loading / error states ─────────────────────
|
||||
|
||||
if (!activeTabId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a table to view data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a table to view data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab.loading && !activeTab.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab.error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-red-500">
|
||||
{activeTab.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTab.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Loading table data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { columns } = activeTab.data;
|
||||
|
||||
// Filter visible columns
|
||||
const visibleColumns = hiddenColumns
|
||||
? columns.filter((c) => !hiddenColumns.has(c.name))
|
||||
: columns;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-auto min-w-0 relative"
|
||||
style={{ overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
|
||||
>
|
||||
{/* Loading indicator bar when refreshing with existing data */}
|
||||
{activeTab.loading && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 animate-pulse" />
|
||||
)}
|
||||
<table
|
||||
className="border-collapse text-left text-sm"
|
||||
style={{ tableLayout: "fixed", width: "100%" }}
|
||||
>
|
||||
<colgroup>
|
||||
{/* Checkbox column */}
|
||||
<col style={{ width: CHECKBOX_COL_WIDTH, minWidth: CHECKBOX_COL_WIDTH }} />
|
||||
{visibleColumns.map((col) => (
|
||||
<col key={col.name} style={{ width: getWidth(col.name) }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 bg-surface">
|
||||
<tr>
|
||||
{/* Header checkbox */}
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-border px-0 py-2 w-[40px]"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
className="group relative border-b border-r border-border px-3 py-2 font-heading text-text-muted last:border-r-0"
|
||||
style={{ width: getWidth(col.name), maxWidth: getWidth(col.name) }}
|
||||
>
|
||||
<div className="truncate flex items-center gap-1">
|
||||
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||
<span className="text-text text-xs">{col.name}</span>
|
||||
<span className="ml-1 text-[10px] text-text-muted/60" title={col.data_type}>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
{/* resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
|
||||
onMouseDown={(e) => startResize(col.name, e)}
|
||||
onDoubleClick={() => {
|
||||
setColWidths((prev) => ({
|
||||
...prev,
|
||||
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isSelected = selectedRows.has(rowIndex);
|
||||
return (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={`border-b border-border hover:bg-surface/50 ${isSelected ? "bg-accent/5" : ""}`}
|
||||
>
|
||||
{/* Row checkbox */}
|
||||
<td className="border-r border-border px-0 py-2" style={{ overflow: "hidden" }}>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleRow(rowIndex)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
const ci = columns.findIndex((c) => c.name === col.name);
|
||||
const cell = ci >= 0 ? row[ci] : undefined;
|
||||
const isNull = cell === null || cell === undefined;
|
||||
const isFk = col.is_fk && col.fk_ref && !isNull;
|
||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
|
||||
|
||||
const handleJsonClick = (e: React.MouseEvent) => {
|
||||
if (isJson) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setJsonPopover({ value: cell, anchorRect: rect });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<td key={col.name} className="border-r border-border px-3 py-2 last:border-r-0 font-heading text-xs" style={{ overflow: "hidden" }}>
|
||||
<div
|
||||
className={`truncate max-w-full select-text ${isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
|
||||
title={isNull ? "NULL" : isFk ? `FK → ${col!.fk_ref![0]}.${col!.fk_ref![1]}: ${String(cell)}` : isJson ? "Click to view JSON" : String(cell)}
|
||||
onClick={isFk ? (e) => handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined}
|
||||
role={isFk || isJson ? "button" : undefined}
|
||||
tabIndex={isFk || isJson ? 0 : undefined}
|
||||
onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined}
|
||||
>
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : isJson ? (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Braces size={10} className="shrink-0" />
|
||||
{jp.label}
|
||||
</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* FK preview popover */}
|
||||
{fkPreview && (
|
||||
<FkPreviewPopover
|
||||
connectionId={fkPreview.connectionId}
|
||||
schema={fkPreview.schema}
|
||||
table={fkPreview.table}
|
||||
column={fkPreview.column}
|
||||
value={fkPreview.value}
|
||||
anchorRect={fkPreview.anchorRect}
|
||||
onClose={() => setFkPreview(null)}
|
||||
/>
|
||||
)}
|
||||
{/* JSON cell popover */}
|
||||
{jsonPopover && (
|
||||
<JsonCellPopover
|
||||
value={jsonPopover.value}
|
||||
anchorRect={jsonPopover.anchorRect}
|
||||
onClose={() => setJsonPopover(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DbViewerScreen } from "./DbViewerScreen";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("DbViewerScreen", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.setState({
|
||||
tabs: [], activeTabId: null, changesQueue: [],
|
||||
databases: ["mydb"], schemas: ["public"],
|
||||
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
|
||||
currentDatabase: "mydb", currentSchema: "public",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the sidebar", () => {
|
||||
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { DataGrid } from "./DataGrid";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
connectionId: string;
|
||||
onHome: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
// ─── client-side filter/sort helpers ─────────────────────
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = { id: string; column: string; order: "asc" | "desc" };
|
||||
|
||||
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return rows.filter((row) =>
|
||||
rules.every((rule) => {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) return true;
|
||||
const cell = row[ci];
|
||||
const str = cell === null || cell === undefined ? "" : String(cell);
|
||||
switch (rule.operator) {
|
||||
case "null": return cell === null;
|
||||
case "notnull": return cell !== null;
|
||||
case "eq": return str === rule.value;
|
||||
case "neq": return str !== rule.value;
|
||||
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
|
||||
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
|
||||
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
|
||||
case "gt": return Number(str) > Number(rule.value);
|
||||
case "lt": return Number(str) < Number(rule.value);
|
||||
default: return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const rule of rules) {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) continue;
|
||||
const va = a[ci];
|
||||
const vb = b[ci];
|
||||
const cmp =
|
||||
va === null && vb === null ? 0
|
||||
: va === null ? -1
|
||||
: vb === null ? 1
|
||||
: String(va).localeCompare(String(vb), undefined, { numeric: true });
|
||||
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
|
||||
const { connectionError, connect } = useDbConnection(connectionId);
|
||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
|
||||
const [sortRules, setSortRules] = useState<SortRule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const smartSortApplied = useRef<Set<string>>(new Set());
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||
|
||||
// Sync settings defaults to store
|
||||
useEffect(() => {
|
||||
if (settings?.table_page_size) {
|
||||
setDefaultPageSize(settings.table_page_size);
|
||||
}
|
||||
}, [settings?.table_page_size, setDefaultPageSize]);
|
||||
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||
|
||||
const activeTab = useDbViewerStore((s) => {
|
||||
if (!s.activeTabId) return null;
|
||||
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
|
||||
});
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const schemas = useDbViewerStore((s) => s.schemas);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const fetchingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const fetchData = useCallback(async (tab: NonNullable<typeof activeTab>) => {
|
||||
if (fetchingRef.current.has(tab.id)) return;
|
||||
fetchingRef.current.add(tab.id);
|
||||
try {
|
||||
const result = await cmd.getTableData(
|
||||
connectionId,
|
||||
tab.schema,
|
||||
tab.table,
|
||||
tab.page,
|
||||
tab.pageSize,
|
||||
);
|
||||
setTabData(tab.id, result);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setTabError(tab.id, msg);
|
||||
} finally {
|
||||
fetchingRef.current.delete(tab.id);
|
||||
}
|
||||
}, [connectionId, setTabData, setTabError]);
|
||||
|
||||
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
|
||||
useShortcut("close_tab", () => {
|
||||
const state = useDbViewerStore.getState();
|
||||
if (state.activeTabId) {
|
||||
state.closeTab(state.activeTabId);
|
||||
} else {
|
||||
onHome();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (!activeTab.loading) return;
|
||||
if (activeTab.error) return;
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Smart default sort: apply once when data first loads for a tab
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (activeTab.loading) return;
|
||||
if (!activeTab.data) return;
|
||||
if (smartSortApplied.current.has(activeTab.id)) return;
|
||||
|
||||
const cols = activeTab.data.columns;
|
||||
|
||||
const getColType = (name: string) => {
|
||||
const col = cols.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||
return col?.data_type.toLowerCase() ?? "";
|
||||
};
|
||||
const isNumeric = (name: string) => {
|
||||
const t = getColType(name);
|
||||
return ["integer", "int", "int2", "int4", "int8", "smallint", "bigint",
|
||||
"serial", "bigserial", "smallserial", "tinyint", "mediumint",
|
||||
"numeric", "decimal", "real", "float", "float4", "float8",
|
||||
"double precision", "double", "number"].includes(t);
|
||||
};
|
||||
const isTimestamp = (name: string) => {
|
||||
const t = getColType(name);
|
||||
return ["timestamp", "timestamptz", "timestamp without time zone",
|
||||
"timestamp with time zone", "date", "datetime", "datetime2",
|
||||
"smalldatetime"].some((pt) => t.includes(pt));
|
||||
};
|
||||
|
||||
// Find the first column name that exists and passes type checks
|
||||
const findCol = (candidates: string[], numericOnly = false): string | undefined => {
|
||||
for (const cand of candidates) {
|
||||
const match = cols.find((c) => c.name.toLowerCase() === cand.toLowerCase());
|
||||
if (!match) continue;
|
||||
if (numericOnly && !isNumeric(match.name)) continue;
|
||||
return match.name;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findBySuffix = (suffixes: string[], numericOnly = false): string | undefined => {
|
||||
for (const c of cols) {
|
||||
const name = c.name.toLowerCase();
|
||||
if (suffixes.some((s) => name.endsWith(s))) {
|
||||
if (numericOnly && !isNumeric(c.name)) continue;
|
||||
return c.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findByPrefix = (prefixes: string[], numericOnly = false): string | undefined => {
|
||||
for (const c of cols) {
|
||||
const name = c.name.toLowerCase();
|
||||
if (prefixes.some((p) => name.startsWith(p))) {
|
||||
if (numericOnly && !isNumeric(c.name)) continue;
|
||||
return c.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Priority-ordered rules: each returns [columnName | undefined, order]
|
||||
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
|
||||
// Tier 1: Explicit recency columns
|
||||
() => [findCol(["updated_at", "modified_at", "changed_at", "altered_at", "revised_at"]), "desc"],
|
||||
() => [findCol(["created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at"]), "desc"],
|
||||
() => [findCol(["deleted_at", "removed_at", "expired_at", "archived_at"]), "desc"],
|
||||
// Tier 2: Generic date/timestamp columns (DESC = newest)
|
||||
() => {
|
||||
const col = cols.find((c) => isTimestamp(c.name));
|
||||
return col ? [col.name, "desc"] : [undefined, "desc"];
|
||||
},
|
||||
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
|
||||
() => [findBySuffix(["_at"]), "desc"],
|
||||
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
|
||||
() => [findBySuffix(["_on"]), "desc"],
|
||||
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
|
||||
() => [findByPrefix(["last_"]), "desc"],
|
||||
// Tier 6: Numeric ID (DESC = highest/newest)
|
||||
() => [findCol(["id", "uid", "pk"], true), "desc"],
|
||||
// Tier 7: Any *_id suffix (numeric FKs usually increment)
|
||||
() => [findBySuffix(["_id"], true), "desc"],
|
||||
// Tier 8: Sequence/order columns (ASC = natural order)
|
||||
() => [findCol(["seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order"], true), "asc"],
|
||||
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
|
||||
() => [findCol(["rank", "ranking", "priority", "weight", "score", "rating"], true), "desc"],
|
||||
// Tier 10: Version/revision tracking (DESC = latest)
|
||||
() => [findCol(["version", "revision", "rev", "build", "release"], true), "desc"],
|
||||
// Tier 11: Count/quantity (DESC = most)
|
||||
() => [findCol(["count", "total", "amount", "quantity", "qty", "num", "number", "no"], true), "desc"],
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
const [colName, order] = rule();
|
||||
if (colName) {
|
||||
smartSortApplied.current.add(activeTab.id);
|
||||
setSortRules([{ id: crypto.randomUUID(), column: colName, order }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
|
||||
useEffect(() => {
|
||||
if (!activeTab?.columnFilter) return;
|
||||
const { column, value } = activeTab.columnFilter;
|
||||
setFilterRules((prev) => {
|
||||
const exists = prev.some((r) => r.column === column && r.value === value);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
|
||||
});
|
||||
}, [activeTab?.columnFilter]);
|
||||
|
||||
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
|
||||
useEffect(() => {
|
||||
if (!activeTab?.columnFilter) return;
|
||||
const { column, value } = activeTab.columnFilter;
|
||||
const stillExists = filterRules.some((r) => r.column === column && r.value === value);
|
||||
if (!stillExists) {
|
||||
clearColumnFilter(activeTab.id);
|
||||
}
|
||||
}, [filterRules, activeTab, clearColumnFilter]);
|
||||
|
||||
// Refresh: clear data so auto-fetch effect re-fetches
|
||||
const handleRefresh = useCallback(() => {
|
||||
const tabId = useDbViewerStore.getState().activeTabId;
|
||||
if (!tabId) return;
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, loading: true, error: null } : t,
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const rawRows = activeTab?.data?.rows ?? [];
|
||||
const columns = activeTab?.data?.columns ?? [];
|
||||
const processedRows = useMemo(() => {
|
||||
let result = rawRows;
|
||||
result = applyFilters(result, columns, filterRules);
|
||||
result = applySorts(result, columns, sortRules);
|
||||
return result;
|
||||
}, [rawRows, columns, filterRules, sortRules]);
|
||||
|
||||
const onPanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!panelResizeRef.current) return;
|
||||
const w = Math.max(180, Math.min(600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX)));
|
||||
setTablePanelWidth(w);
|
||||
};
|
||||
const onUp = () => {
|
||||
panelResizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [tablePanelWidth]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(view: string) => {
|
||||
if (view === "home") onHome();
|
||||
else if (view === "settings") onSettings();
|
||||
},
|
||||
[onHome, onSettings],
|
||||
);
|
||||
|
||||
const activeSchema = activeTab?.schema ?? "";
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen bg-canvas flex border-t border-border">
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
<ConnectionDropBanner
|
||||
error={connectionError}
|
||||
onRetry={() => {
|
||||
setDismissedError(null);
|
||||
connect();
|
||||
}}
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="border-r border-border flex flex-col shrink-0" style={{ width: tablePanelWidth }}>
|
||||
<DbViewerToolbar
|
||||
databases={databases}
|
||||
currentDatabase={currentDatabase}
|
||||
setCurrentDatabase={setCurrentDatabase}
|
||||
schemas={schemas}
|
||||
currentSchema={currentSchema}
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
<div
|
||||
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setTablePanelWidth(280)}
|
||||
/>
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
{activeTab?.data && (
|
||||
<TableControls
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
columns={columns}
|
||||
rows={rawRows}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onToggleColumn={(col) =>
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(col)) next.delete(col); else next.add(col);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onRefresh={handleRefresh}
|
||||
filterRules={filterRules}
|
||||
onFilterChange={setFilterRules}
|
||||
sortRules={sortRules}
|
||||
onSortChange={setSortRules}
|
||||
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
|
||||
selectedCount={selectedRows.size}
|
||||
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
|
||||
onClearSelection={() => setSelectedRows(new Set())}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChangesQueuePanel />
|
||||
</div>
|
||||
{currentConnection && (
|
||||
<EditConnectionModal
|
||||
connection={currentConnection}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
describe("DbViewerSidebar", () => {
|
||||
it("renders all navigation icons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onNavigate when home is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText(/home/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("home");
|
||||
});
|
||||
|
||||
it("calls onNavigate when settings is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText(/settings/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
|
||||
export interface DbViewerSidebarProps {
|
||||
currentView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
stub?: boolean;
|
||||
}
|
||||
|
||||
export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
|
||||
const topItems: NavItem[] = [
|
||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
|
||||
{ id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: <Grid2x2 size={20} />, stub: true },
|
||||
{ id: "functions", label: "Functions coming soon", icon: <FunctionSquare size={20} />, stub: true },
|
||||
{ id: "triggers", label: "Triggers coming soon", icon: <GitBranch size={20} />, stub: true },
|
||||
];
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ id: "home", label: "Home", icon: <Home size={20} /> },
|
||||
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
|
||||
];
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const isActive = currentView === item.id;
|
||||
const baseClass = "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
|
||||
const activeClass = "text-accent";
|
||||
const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
|
||||
const stubClass = "opacity-40 cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id} content={item.label} side="right">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
disabled={item.stub}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
||||
<div className="flex flex-col gap-2 flex-1">{topItems.map(renderItem)}</div>
|
||||
<div className="flex flex-col gap-2">{bottomItems.map(renderItem)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
const defaultProps = {
|
||||
databases: [] as string[],
|
||||
currentDatabase: null as string | null,
|
||||
setCurrentDatabase: () => {},
|
||||
schemas: [] as string[],
|
||||
currentSchema: null as string | null,
|
||||
setCurrentSchema: () => {},
|
||||
searchQuery: "",
|
||||
onSearchChange: () => {},
|
||||
};
|
||||
|
||||
describe("DbViewerToolbar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders Tables label", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Tables")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders database dropdown when multiple databases", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
databases={["mydb", "otherdb"]}
|
||||
currentDatabase="mydb"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("mydb")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders refresh and create table buttons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
currentDatabase,
|
||||
setCurrentDatabase,
|
||||
schemas,
|
||||
currentSchema,
|
||||
setCurrentSchema,
|
||||
onEdit,
|
||||
connectionId,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: {
|
||||
databases: string[];
|
||||
currentDatabase: string | null;
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
schemas: string[];
|
||||
currentSchema: string | null;
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
connectionId?: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
}) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
|
||||
// Focus input when search opens
|
||||
useEffect(() => {
|
||||
if (searchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
|
||||
// Auto-hide on blur when empty
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
// Small delay to allow clicks on clear button / search icon
|
||||
setTimeout(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
}, [searchQuery]);
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) onSearchChange(""); // clear when closing
|
||||
return next;
|
||||
});
|
||||
}, [onSearchChange]);
|
||||
|
||||
// Cleanup result timer on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!connectionId || refreshing) return;
|
||||
setRefreshing(true);
|
||||
setResult('idle');
|
||||
try {
|
||||
const dbs = await cmd.getDatabases(connectionId);
|
||||
const scs = await cmd.getSchemas(connectionId);
|
||||
const tbls = await cmd.getTables(connectionId);
|
||||
populate(dbs, scs, tbls);
|
||||
setResult('success');
|
||||
} catch {
|
||||
setResult('error');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
resultTimer.current = setTimeout(() => setResult('idle'), 1500);
|
||||
}
|
||||
}, [connectionId, refreshing, populate]);
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-text">Tables</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onEdit && (
|
||||
<Tooltip content="Edit Connection" side="bottom">
|
||||
<button
|
||||
aria-label="Edit Connection"
|
||||
onClick={onEdit}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={result === 'success' ? 'Refreshed' : result === 'error' ? 'Refresh failed' : 'Refresh Database'} side="bottom">
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : result === 'success' ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : result === 'error' ? (
|
||||
<AlertCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Search Tables" side="bottom">
|
||||
<button
|
||||
aria-label="Search Tables"
|
||||
onClick={toggleSearch}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search input */}
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Search size={12} className="absolute left-2.5 text-text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
placeholder="Filter tables…"
|
||||
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(databases.length > 1 || schemas.length > 1) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{databases.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentDatabase ?? ""}
|
||||
onChange={setCurrentDatabase}
|
||||
options={databases.map((d) => ({ value: d, label: d }))}
|
||||
placeholder="Select database"
|
||||
aria-label="Select database"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && schemas.length > 1 && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{schemas.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentSchema ?? ""}
|
||||
onChange={setCurrentSchema}
|
||||
options={schemas.map((s) => ({ value: s, label: s }))}
|
||||
placeholder="Select schema"
|
||||
aria-label="Select schema"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
interface EditConnectionModalProps {
|
||||
connection: Connection;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (updated: Connection) => void;
|
||||
}
|
||||
|
||||
export function EditConnectionModal({
|
||||
connection,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditConnectionModalProps) {
|
||||
const [form, setForm] = useState<ConnectionFormData>(() => ({
|
||||
name: connection.name,
|
||||
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
|
||||
folder_id: connection.folder_id,
|
||||
tag_ids: [...connection.tag_ids],
|
||||
connection_string: "",
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
password: null,
|
||||
database: connection.database ?? null,
|
||||
use_keychain: false,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: ConnectionInput = {
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
if (form.password) {
|
||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
||||
}
|
||||
notify("Connection updated", "success");
|
||||
onSaved(updated);
|
||||
onClose();
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// Fetch password from keychain if not provided in form
|
||||
let password = form.password;
|
||||
if (!password) {
|
||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||
}
|
||||
|
||||
const result = await testConnection({
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
});
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
} else {
|
||||
notify(result.error ?? "Connection failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [form, notify, connection.id]);
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
|
||||
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={handleTest} disabled={testing}>
|
||||
{testing ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { QueryResult } from "../../lib/types";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
interface FkPreviewPopoverProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FkPreviewPopover({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
value,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: FkPreviewPopoverProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
|
||||
|
||||
const handleOpen = () => {
|
||||
openTab(schema, table);
|
||||
// Find the newly created tab and apply the column filter
|
||||
const newTab = useDbViewerStore.getState().tabs.find(
|
||||
(t) => t.schema === schema && t.table === table,
|
||||
);
|
||||
if (newTab) {
|
||||
setColumnFilter(newTab.id, column, value);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Fetch the referenced row
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getFkPreview(connectionId, schema, table, column, value)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (!cancelled) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, schema, table, column, value]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Delay to avoid closing immediately from the same click that opened it
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
// Compute position to keep popover within viewport
|
||||
const popoverWidth = 360;
|
||||
const popoverMaxHeight = 320;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
// Flip horizontally if off-screen
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
// Flip vertically if not enough space below
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Key size={12} className="text-amber-400 shrink-0" />
|
||||
<span className="text-xs font-heading text-text truncate">
|
||||
{schema}.{table}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
|
||||
title="Open table in new tab"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length === 0 && !loading && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
|
||||
No matching row found
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{data.columns.map((col, ci) => {
|
||||
const cell = data.rows[0][ci];
|
||||
const isNull = cell === null || cell === undefined;
|
||||
return (
|
||||
<tr
|
||||
key={col.name}
|
||||
className="border-b border-border last:border-0 hover:bg-surface/30"
|
||||
>
|
||||
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
|
||||
<div className="flex items-center gap-1">
|
||||
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
|
||||
<span className="truncate">{col.name}</span>
|
||||
<span
|
||||
className="text-[10px] text-text-muted/50 shrink-0"
|
||||
title={col.data_type}
|
||||
>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-text">
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : (
|
||||
<span className="break-all">{String(cell)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Braces, Copy, Check, X } from "lucide-react";
|
||||
|
||||
interface JsonCellPopoverProps {
|
||||
value: unknown;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function safeJsonParse(value: unknown): object | null {
|
||||
if (typeof value === "object" && value !== null) return value as object;
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(obj: object): string {
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
|
||||
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const parsed = safeJsonParse(value);
|
||||
const rawText = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const formattedText = parsed ? formatJson(parsed) : rawText;
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
const popoverWidth = 420;
|
||||
const popoverMaxHeight = 360;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = tab === "formatted" ? formattedText : rawText;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Braces size={12} className="text-accent shrink-0" />
|
||||
<span className="text-xs font-heading text-text">JSON</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Tabs */}
|
||||
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
|
||||
<button
|
||||
onClick={() => setTab("formatted")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Formatted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("raw")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className="overflow-auto p-3"
|
||||
style={{ maxHeight: popoverMaxHeight - 41 }}
|
||||
>
|
||||
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
|
||||
{tab === "formatted" ? formattedText : rawText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
|
||||
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
|
||||
const parsed = safeJsonParse(value);
|
||||
if (!parsed) return { label: "", isJson: false };
|
||||
if (Array.isArray(parsed)) {
|
||||
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
|
||||
}
|
||||
const keys = Object.keys(parsed);
|
||||
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
describe("TabBar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("shows empty state when no tabs", () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open tab names", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("posts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets active tab when clicked", async () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
store.openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
await user.click(screen.getByText("users"));
|
||||
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
|
||||
});
|
||||
|
||||
it("closes tab when close button clicked", async () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: /close users/i,
|
||||
});
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
|
||||
expect(
|
||||
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
|
||||
No tables open
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={[
|
||||
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-canvas text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="flex-1 text-left outline-none cursor-pointer"
|
||||
>
|
||||
{tab.table}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
aria-label={`Close ${tab.table}`}
|
||||
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
|
||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const AUTO_REFRESH_OPTIONS = [
|
||||
{ label: "Off", value: 0 },
|
||||
{ label: "5s", value: 5000 },
|
||||
{ label: "10s", value: 10_000 },
|
||||
{ label: "30s", value: 30_000 },
|
||||
{ label: "1m", value: 60_000 },
|
||||
{ label: "5m", value: 300_000 },
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZES = [50, 100, 200] as const;
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ label: "JSON", ext: "json" },
|
||||
{ label: "CSV", ext: "csv" },
|
||||
{ label: "SQL", ext: "sql" },
|
||||
{ label: "Markdown", ext: "md" },
|
||||
] as const;
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
order: "asc" | "desc";
|
||||
};
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
|
||||
function exportData(
|
||||
rows: unknown[][],
|
||||
columns: ColumnInfo[],
|
||||
format: string,
|
||||
tableName: string,
|
||||
) {
|
||||
const headers = columns.map((c) => c.name);
|
||||
let content: string;
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
content = JSON.stringify(jsonRows, null, 2);
|
||||
mime = "application/json";
|
||||
break;
|
||||
}
|
||||
case "csv": {
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
content = csvRows.join("\n");
|
||||
mime = "text/csv";
|
||||
break;
|
||||
}
|
||||
case "sql": {
|
||||
const lines = [`-- ${tableName}`];
|
||||
for (const row of rows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
content = lines.join("\n");
|
||||
mime = "application/sql";
|
||||
break;
|
||||
}
|
||||
case "md": {
|
||||
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
|
||||
for (const row of rows) {
|
||||
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
|
||||
}
|
||||
content = mdRows.join("\n");
|
||||
mime = "text/markdown";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.${format === "md" ? "md" : format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─── sub-components ─────────────────────────────────────
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
setOpen,
|
||||
align,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
align?: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.operator}
|
||||
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
|
||||
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="eq">=</option>
|
||||
<option value="neq">≠</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="starts">starts with</option>
|
||||
<option value="ends">ends with</option>
|
||||
<option value="gt">></option>
|
||||
<option value="lt"><</option>
|
||||
<option value="null">is null</option>
|
||||
<option value="notnull">not null</option>
|
||||
</select>
|
||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add filter
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: SortRule[];
|
||||
onChange: (rules: SortRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<SortRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.order}
|
||||
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
|
||||
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="asc">ASC</option>
|
||||
<option value="desc">DESC</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add sort
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── bulk actions dropdown ──────────────────────────────
|
||||
|
||||
function BulkActionsDropdown({
|
||||
columns,
|
||||
selectedRows,
|
||||
schema,
|
||||
table,
|
||||
onClearSelection,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
selectedRows: unknown[][];
|
||||
schema: string;
|
||||
table: string;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyJSON = () => {
|
||||
const json = selectedRows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(json, null, 2));
|
||||
};
|
||||
|
||||
const handleCopyCSV = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of selectedRows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
copyToClipboard(csvRows.join("\n"));
|
||||
};
|
||||
|
||||
const handleCopySQL = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const lines: string[] = [];
|
||||
for (const row of selectedRows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
copyToClipboard(lines.join("\n"));
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const pkCol = columns.find((c) => c.is_pk);
|
||||
for (const row of selectedRows) {
|
||||
const pk: Record<string, unknown> = {};
|
||||
if (pkCol) {
|
||||
const ci = columns.findIndex((c) => c.name === pkCol.name);
|
||||
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
|
||||
}
|
||||
addChange({
|
||||
type: "delete",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: pk,
|
||||
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
|
||||
description: `Delete row from ${table}`,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
onClearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-medium">Actions</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJSON}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileJson size={13} className="text-text-muted" />
|
||||
Copy as JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCSV}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText size={13} className="text-text-muted" />
|
||||
Copy as CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySQL}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Terminal size={13} className="text-text-muted" />
|
||||
Copy as SQL INSERT
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete selected rows
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main component ─────────────────────────────────────
|
||||
|
||||
interface TableControlsProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
rows: unknown[][];
|
||||
hiddenColumns: Set<string>;
|
||||
onToggleColumn: (col: string) => void;
|
||||
onRefresh: () => void;
|
||||
filterRules: FilterRule[];
|
||||
onFilterChange: (rules: FilterRule[]) => void;
|
||||
sortRules: SortRule[];
|
||||
onSortChange: (rules: SortRule[]) => void;
|
||||
selectedCount: number;
|
||||
selectedRows: unknown[][];
|
||||
onClearSelection: () => void;
|
||||
defaultRefreshRate?: number;
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
connectionId: _connectionId,
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
hiddenColumns,
|
||||
onToggleColumn,
|
||||
onRefresh,
|
||||
filterRules,
|
||||
onFilterChange,
|
||||
sortRules,
|
||||
onSortChange,
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
onClearSelection,
|
||||
defaultRefreshRate = 0,
|
||||
}: TableControlsProps) {
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const setPage = useDbViewerStore((s) => s.setPage);
|
||||
const setPageSize = useDbViewerStore((s) => s.setPageSize);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const cancelChange = useDbViewerStore((s) => s.cancelChange);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// local state
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||
|
||||
// auto-refresh timer
|
||||
useEffect(() => {
|
||||
if (autoRefresh === 0) return;
|
||||
const id = setInterval(onRefresh, autoRefresh);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, onRefresh]);
|
||||
|
||||
// pagination
|
||||
const totalRows = activeTab?.data?.total_rows ?? rows.length;
|
||||
const pageSize = activeTab?.pageSize ?? 50;
|
||||
const currentPage = activeTab?.page ?? 1;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
|
||||
const startRow = (clampedPage - 1) * pageSize + 1;
|
||||
const endRow = Math.min(clampedPage * pageSize, totalRows);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
|
||||
};
|
||||
|
||||
const handleInsertRow = () => {
|
||||
const newData: Record<string, unknown> = {};
|
||||
columns.forEach((c) => { newData[c.name] = null; });
|
||||
addChange({
|
||||
type: "insert",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: {},
|
||||
newData,
|
||||
description: `Insert row into ${table}`,
|
||||
});
|
||||
openTab(schema, table);
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
exportData(rows, columns, format, table);
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 bg-surface/50 text-xs text-text-muted">
|
||||
{/* ── left side ──────────────────────────────── */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Insert Row */}
|
||||
<Tooltip content="Insert row" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsertRow}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Insert row"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Refresh */}
|
||||
<Tooltip content="Refresh" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Auto-refresh */}
|
||||
<div className="relative">
|
||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefreshOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Auto-refresh"
|
||||
>
|
||||
<Clock size={14} />
|
||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
|
||||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh === opt.value ? "text-accent" : "text-text"
|
||||
}`}
|
||||
>
|
||||
{autoRefresh === opt.value && <Check size={12} />}
|
||||
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Column filters" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
filterRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Column filters"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{filterRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{filterRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<FilterModal
|
||||
columns={columns}
|
||||
rules={filterRules}
|
||||
onChange={onFilterChange}
|
||||
open={filterOpen}
|
||||
setOpen={setFilterOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Sort rules" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
sortRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Sort rules"
|
||||
>
|
||||
<ArrowUpDown size={14} />
|
||||
{sortRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{sortRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<SortModal
|
||||
columns={columns}
|
||||
rules={sortRules}
|
||||
onChange={onSortChange}
|
||||
open={sortOpen}
|
||||
setOpen={setSortOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Export" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Export"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.ext}
|
||||
type="button"
|
||||
onClick={() => handleExport(fmt.ext)}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── spacer ──────────────────────────────────── */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* ── right side ─────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Action queue button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQueueOpen((v) => !v)}
|
||||
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
|
||||
changesQueue.some((c) => c.status === "pending")
|
||||
? "text-amber-400 hover:bg-surface-raised"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
aria-label="Action queue"
|
||||
>
|
||||
<span className="text-xs font-medium">Queue</span>
|
||||
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
|
||||
{changesQueue.filter((c) => c.status === "pending").length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{changesQueue.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
|
||||
)}
|
||||
{changesQueue.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
|
||||
item.status === "pending" ? "text-text" : "text-text-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate flex-1">
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
|
||||
item.status === "pending" ? "bg-amber-500"
|
||||
: item.status === "committed" ? "bg-emerald-500"
|
||||
: "bg-red-500"
|
||||
}`} />
|
||||
{item.type.toUpperCase()} {item.table}
|
||||
{item.description && <span className="ml-1 text-text-muted/50">— {item.description}</span>}
|
||||
</span>
|
||||
{item.status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelChange(item.id)}
|
||||
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* Selected count + bulk actions */}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-accent font-medium tabular-nums">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<BulkActionsDropdown
|
||||
columns={columns}
|
||||
selectedRows={selectedRows}
|
||||
schema={schema}
|
||||
table={table}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
className="text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Columns toggle */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Show/hide columns" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColumnMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Toggle columns"
|
||||
>
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={columnMenuOpen} setOpen={setColumnMenuOpen} align="right">
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">Visible columns</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{columns.map((col) => (
|
||||
<button
|
||||
key={col.name}
|
||||
type="button"
|
||||
onClick={() => onToggleColumn(col.name)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
|
||||
hiddenColumns.has(col.name) ? "border-border bg-transparent" : "border-accent bg-accent"
|
||||
}`}>
|
||||
{!hiddenColumns.has(col.name) && <Check size={10} className="text-white" />}
|
||||
</span>
|
||||
<span className="truncate">{col.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border" />
|
||||
|
||||
{/* Row count */}
|
||||
<span className="tabular-nums">
|
||||
{startRow}-{endRow} of {totalRows}
|
||||
</span>
|
||||
|
||||
{/* Page size */}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
disabled={clampedPage <= 1}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="tabular-nums min-w-[3rem] text-center">
|
||||
{clampedPage}/{totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={clampedPage >= totalPages}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
|
||||
describe("TableOverflowMenu", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders menu trigger button", () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows menu options on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onOpenTab when menu item clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenTab = vi.fn().mockReturnValue("tab-1");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
await user.click(screen.getByText("Open in new tab"));
|
||||
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
table: string;
|
||||
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
stub?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleAction = (id: string) => {
|
||||
switch (id) {
|
||||
case "open":
|
||||
onOpenTab(schema, table, true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "copy-schema": {
|
||||
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
|
||||
if (navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(sql);
|
||||
}
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
break;
|
||||
case "delete":
|
||||
setConfirmAction("delete");
|
||||
setOpen(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ id: "open", label: "Open in new tab" },
|
||||
{ id: "copy-schema", label: "Copy table schema" },
|
||||
{ id: "export-csv", label: "Export data (CSV)", stub: true },
|
||||
{ id: "export-json", label: "Export data (JSON)", stub: true },
|
||||
{ id: "export-sql", label: "Export data (SQL)", stub: true },
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
aria-label="Table options"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
disabled={item.stub}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
item.stub ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.stub && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction === "empty" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Empty Table: ${table}`}
|
||||
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
|
||||
confirmLabel="Empty Table"
|
||||
onConfirm={() => {
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmAction === "delete" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Delete Table: ${table}`}
|
||||
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
|
||||
confirmLabel="Delete Table"
|
||||
onConfirm={() => {
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("TableTree", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders table names from store", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
|
||||
});
|
||||
render(<TableTree />);
|
||||
await user.click(screen.getByText("users"));
|
||||
const state = useDbViewerStore.getState();
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
|
||||
});
|
||||
});
|
||||