diff --git a/.gitignore b/.gitignore index 036fd6d..3234e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,10 @@ dist-ssr # AI .pi/ .superpowers/ +docs/superpowers/ # Env .env.* .env !.env.example +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index af7027f..8bb1ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``; 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/) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1a80624 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/index.html b/index.html index ff93803..979e31b 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,11 @@ - - Tauri + React + Typescript + Gridline + diff --git a/package.json b/package.json index b089c37..f58dc04 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..f48e261 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,6718 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c6349ddff23194f8fdce2ea8849380f5a4868c1648965b70e801e104cba9b3" +dependencies = [ + "base64 0.22.1", + "jni", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.19", + "tracing", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gridline" +version = "0.1.0" +dependencies = [ + "chrono", + "deadpool-postgres", + "indexmap 2.14.0", + "redis", + "rusqlite", + "serde", + "serde_json", + "sqlx", + "ssh2", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-keyring-store", + "tauri-plugin-opener", + "tokio", + "tokio-postgres", + "urlencoding", + "uuid", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" +dependencies = [ + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.14.5", + "hashlink", + "hex", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", +] + +[[package]] +name = "ssh2" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95eb3c09e378543395a3fa9796f897861862466ee331d59140ade4ea0dcfdfc" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-keyring-store" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bcea08aef26f1378f8b82004b434698c203f757e86b21346f7a38f8ae558dc" +dependencies = [ + "android-native-keyring-store", + "apple-native-keyring-store", + "argon2", + "base64 0.22.1", + "chacha20poly1305", + "dbus-secret-service-keyring-store", + "generic-array", + "getrandom 0.4.3", + "hex", + "keyring-core", + "log", + "serde", + "serde_json", + "sha2 0.11.0", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "windows-native-keyring-store", + "zeroize", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2 0.6.5", + "tokio", + "tokio-util", + "whoami 2.1.2", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5a67b2f..e35e1ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..bdd679b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..5fd9c3b 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..535bc6d 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..07ecddb 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..7a525e3 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..dbc52a6 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..42f85b3 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..150d770 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..14c6e96 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..3331f16 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..ef47b52 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..055e6f3 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..d9477e5 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..7b93eac 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..86dd4e3 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..bace5fc 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..7dbc9af 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..f6b09f4 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg new file mode 100644 index 0000000..c3586e2 --- /dev/null +++ b/src-tauri/icons/icon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs new file mode 100644 index 0000000..bc05bd4 --- /dev/null +++ b/src-tauri/src/commands/connections.rs @@ -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) -> Result, String> { + let store = state.lock().map_err(|e| e.to_string())?; + store.get_connections() +} + +pub fn create_connection_inner( + state: &Mutex, + input: ConnectionInput, +) -> Result { + validate(&input)?; + let store = state.lock().map_err(|e| e.to_string())?; + store.create_connection(input) +} + +pub fn update_connection_inner( + state: &Mutex, + id: String, + input: ConnectionInput, +) -> Result { + validate(&input)?; + let store = state.lock().map_err(|e| e.to_string())?; + store.update_connection(&id, input) +} + +pub fn delete_connection_inner(state: &Mutex, 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, + connection_id: String, + tag_ids: Vec, +) -> 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) -> Result, String> { + get_connections_inner(&state.db_store) +} + +#[tauri::command] +pub fn create_connection( + state: tauri::State, + input: ConnectionInput, +) -> Result { + create_connection_inner(&state.db_store, input) +} + +#[tauri::command] +pub fn update_connection( + state: tauri::State, + id: String, + input: ConnectionInput, +) -> Result { + update_connection_inner(&state.db_store, id, input) +} + +#[tauri::command] +pub fn delete_connection(state: tauri::State, id: String) -> Result<(), String> { + delete_connection_inner(&state.db_store, &id) +} + +#[tauri::command] +pub fn add_connection_tags( + state: tauri::State, + connection_id: String, + tag_ids: Vec, +) -> 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 { + 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); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs new file mode 100644 index 0000000..4ad7b3c --- /dev/null +++ b/src-tauri/src/commands/db_viewer.rs @@ -0,0 +1,1250 @@ +//! DB Viewer helper functions and Tauri commands. +//! +//! This module provides pure SQL builder functions, pagination helpers, +//! and Tauri commands for the database viewer. + +use crate::db::pool::DbConfig; +use crate::models::db_viewer::{Change, ColumnInfo, QueryResult, TableInfo}; +use std::collections::HashMap; +use tauri::State; +use tokio_postgres::types::ToSql; + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Format a tokio-postgres connection error with full detail (severity, +/// message, SQLSTATE code) while redacting any embedded connection URL or +/// credentials so nothing sensitive reaches the frontend. +/// +/// tokio-postgres's `Display` only prints "db error", so we walk the +/// `std::error::Error::source()` chain and also use `Debug` to surface the +/// real message (e.g. "password authentication failed for user \"x\""). +fn pg_error_message(err: &tokio_postgres::Error) -> String { + // Prefer the Debug representation, which includes severity + message + code. + let raw = format!("{:?}", err); + // Redact postgres URL fragments and password=... sequences. + let redacted = redact_secrets(&raw); + truncate(&redacted, 400) +} + +/// Redact credential-bearing substrings from an error/debug string so we +/// never leak usernames/passwords/connection strings to the frontend. +fn redact_secrets(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + // Replace `postgresql://user:password@host` style URLs with safely redacted text. + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if s[i..].to_lowercase().starts_with("postgres://") + || s[i..].to_lowercase().starts_with("postgresql://") + { + // Skip the scheme. + let scheme_end = i + + s[i..] + .find("://") + .unwrap_or(0) + + 3; + out.push_str("[redacted-url://"); + // Find end of authority (next '/'. '/', or end). + let rest = &s[scheme_end..]; + let end = match rest.find(['/', '?']) { + Some(pos) => scheme_end + pos, + None => s.len(), + }; + i = end; + } else if s[i..].to_lowercase().starts_with("password=") { + out.push_str("[redacted]"); + // Skip to next whitespace or end. + let rest = &s[i + "password=".len()..]; + let skip = rest.find(char::is_whitespace).unwrap_or(rest.len()); + i += "password=".len() + skip; + } else { + // Copy one char. + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + } + out +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max.saturating_sub(3)]) + } +} + +/// Calculate the database offset for a given page and page size. +/// +/// Uses 1-based page indexing: +/// - page 1, page_size 50 => offset 0 +/// - page 2, page_size 50 => offset 50 +/// - page 5, page_size 25 => offset 100 +pub fn offset(page: i64, page_size: i64) -> i64 { + (page - 1) * page_size +} + +/// Build a parameterized UPDATE SQL statement. +/// +/// The returned SQL uses `?` placeholders for both the SET values and the +/// WHERE primary-key conditions. +/// +/// Example output: +/// ```sql +/// UPDATE "public"."users" SET "name" = ?, "email" = ? WHERE "id" = ? +/// ``` +pub fn build_update_sql( + schema: &str, + table: &str, + primary_key: &[(String, serde_json::Value)], + new_data: &[(String, serde_json::Value)], +) -> String { + let set_clause: Vec = new_data + .iter() + .map(|(col, _)| format!("\"{}\" = ?", col)) + .collect(); + let where_clause: Vec = primary_key + .iter() + .map(|(col, _)| format!("\"{}\" = ?", col)) + .collect(); + format!( + "UPDATE \"{}\".\"{}\" SET {} WHERE {}", + schema, + table, + set_clause.join(", "), + where_clause.join(" AND ") + ) +} + +/// Build a parameterized DELETE SQL statement. +/// +/// Example output: +/// ```sql +/// DELETE FROM "public"."users" WHERE "id" = ? +/// ``` +pub fn build_delete_sql( + schema: &str, + table: &str, + primary_key: &[(String, serde_json::Value)], +) -> String { + let where_clause: Vec = primary_key + .iter() + .map(|(col, _)| format!("\"{}\" = ?", col)) + .collect(); + format!( + "DELETE FROM \"{}\".\"{}\" WHERE {}", + schema, + table, + where_clause.join(" AND ") + ) +} + +/// Build a parameterized INSERT SQL statement. +/// +/// Example output: +/// ```sql +/// INSERT INTO "public"."users" ("id", "name") VALUES (?, ?) +/// ``` +pub fn build_insert_sql(schema: &str, table: &str, columns: &[String]) -> String { + let cols: Vec = columns.iter().map(|c| format!("\"{}\"", c)).collect(); + let placeholders: Vec<&str> = vec!["?"; columns.len()]; + format!( + "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", + schema, + table, + cols.join(", "), + placeholders.join(", ") + ) +} + +// --------------------------------------------------------------------------- +// Change SQL builders (PostgreSQL `$N` placeholders) +// --------------------------------------------------------------------------- +// +// tokio-postgres uses `$1, $2, ...` positional placeholders (not `?`), so the +// `?`-based builders above cannot be used directly for PG execution. These +// helpers emit `$N` placeholders and return the bound values in the order they +// appear in the statement, so callers can bind them positionally. + +/// Build a PostgreSQL UPDATE statement. +/// +/// Returns `(sql, params)` where `params` is ordered SET values first, then +/// primary-key (WHERE) values. Placeholders are `$1, $2, ...` in the same +/// order. +pub fn build_pg_update_sql( + schema: &str, + table: &str, + primary_key: &[(String, serde_json::Value)], + new_data: &[(String, serde_json::Value)], +) -> (String, Vec) { + let mut params: Vec = Vec::new(); + let set_clause: Vec = new_data + .iter() + .map(|(col, val)| { + params.push(val.clone()); + format!("\"{}\" = ${}", col, params.len()) + }) + .collect(); + let where_clause: Vec = primary_key + .iter() + .map(|(col, val)| { + params.push(val.clone()); + format!("\"{}\" = ${}", col, params.len()) + }) + .collect(); + ( + format!( + "UPDATE \"{}\".\"{}\" SET {} WHERE {}", + schema, + table, + set_clause.join(", "), + where_clause.join(" AND ") + ), + params, + ) +} + +/// Build a PostgreSQL DELETE statement. +pub fn build_pg_delete_sql( + schema: &str, + table: &str, + primary_key: &[(String, serde_json::Value)], +) -> (String, Vec) { + let mut params: Vec = Vec::new(); + let where_clause: Vec = primary_key + .iter() + .map(|(col, val)| { + params.push(val.clone()); + format!("\"{}\" = ${}", col, params.len()) + }) + .collect(); + ( + format!( + "DELETE FROM \"{}\".\"{}\" WHERE {}", + schema, + table, + where_clause.join(" AND ") + ), + params, + ) +} + +/// Build a PostgreSQL INSERT statement. +pub fn build_pg_insert_sql( + schema: &str, + table: &str, + columns: &[(String, serde_json::Value)], +) -> (String, Vec) { + let cols: Vec = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect(); + let mut params: Vec = Vec::new(); + let placeholders: Vec = columns + .iter() + .map(|(_, val)| { + params.push(val.clone()); + format!("${}", params.len()) + }) + .collect(); + ( + format!( + "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", + schema, + table, + cols.join(", "), + placeholders.join(", ") + ), + params, + ) +} + +/// Convert a JSON value into a boxed PostgreSQL-bindable value. +/// +/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors. +/// Unknown/complex types are stringified as a fallback. +fn pg_box_value(v: &serde_json::Value) -> Box { + match v { + serde_json::Value::Null => Box::new(Option::::None), + serde_json::Value::Bool(b) => Box::new(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Box::new(i) + } else if let Some(f) = n.as_f64() { + Box::new(f) + } else { + Box::new(n.to_string()) + } + } + serde_json::Value::String(s) => Box::new(s.clone()), + other => Box::new(other.to_string()), + } +} + +/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding. +fn json_to_sqlite_value(v: &serde_json::Value) -> rusqlite::types::Value { + use rusqlite::types::Value; + match v { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Integer(*b as i64), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Integer(i) + } else if let Some(f) = n.as_f64() { + Value::Real(f) + } else { + Value::Text(n.to_string()) + } + } + serde_json::Value::String(s) => Value::Text(s.clone()), + other => Value::Text(other.to_string()), + } +} + +/// Parse a JSON object string (e.g. `{"id": 1}`) into ordered (column, value) +/// pairs. Insertion order of the JSON object is preserved by `serde_json`. +fn parse_json_pairs(json: &str) -> Result, String> { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("invalid change JSON: {}", e))?; + let obj = v + .as_object() + .ok_or_else(|| "change JSON must be an object".to_string())?; + Ok(obj.iter().map(|(k, val)| (k.clone(), val.clone())).collect()) +} +/// +/// Each inner `Vec` represents one row, where the values +/// are expected in the order: `[name, schema, table_type]`. +pub fn parse_table_info_rows(rows: &[Vec]) -> Vec { + rows.iter() + .map(|row| { + let name = row + .first() + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let schema = row + .get(1) + .and_then(|v| v.as_str()) + .unwrap_or("public") + .to_string(); + let table_type = row + .get(2) + .and_then(|v| v.as_str()) + .unwrap_or("TABLE") + .to_string(); + TableInfo { + name, + schema, + table_type, + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +/// Convert a PostgreSQL row value at column index `i` to a JSON value. +/// +/// Tries numeric/boolean types first (which need exact Rust type matching), +/// then UUID (with-uuid-1 feature), then chrono types (with-chrono-0_4), +/// then JSON/JSONB, then falls back to String. +fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value { + // Integer types + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + // Float types + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + // Boolean + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::json!(v); + } + // UUID + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::Value::String(v.to_string()); + } + // Timestamp / date types + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::Value::String(v.to_string()); + } + if let Ok(Some(v)) = row.try_get::<_, Option>>(i) { + return serde_json::Value::String(v.to_rfc3339()); + } + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::Value::String(v.to_string()); + } + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::Value::String(v.to_string()); + } + // JSON/JSONB + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return v; + } + // Text fallback + if let Ok(Some(v)) = row.try_get::<_, Option>(i) { + return serde_json::Value::String(v); + } + serde_json::Value::Null +} + +#[tauri::command] +pub async fn db_connect( + connection_id: String, + config: DbConfig, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + if config.db_type == "postgresql" { + 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(""); + + // Build a postgres URL connection string rather than the fragile + // libpq key=value format. tokio-postgres parses URLs reliably and + // urlencoding handles special characters in user/password/dbname. + 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)) => { + let handle = tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("PostgreSQL connection error: {}", e); + } + }); + + let mut pm = state.pool_manager.lock().await; + pm.register( + &connection_id, + crate::db::pool::DbHandle::Postgresql(client, handle), + ); + Ok(()) + } + Err(e) => Err(format!("Connection failed: {}", pg_error_message(&e))), + } + } else if config.db_type == "sqlite" { + match rusqlite::Connection::open(&config.host) { + Ok(conn) => { + let mut pm = state.pool_manager.lock().await; + pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn)); + Ok(()) + } + Err(e) => Err(format!("Connection failed: {}", e)), + } + } else { + Err(format!( + "Database type '{}' not yet supported for DB viewer", + config.db_type + )) + } +} + +#[tauri::command] +pub async fn db_disconnect( + connection_id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + let mut pm = state.pool_manager.lock().await; + pm.remove(&connection_id); + Ok(()) +} + +#[tauri::command] +pub async fn get_databases( + connection_id: String, + state: State<'_, crate::AppState>, +) -> Result, String> { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + let rows = client + .query( + "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname", + &[], + ) + .await + .map_err(|e| e.to_string())?; + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) + } + Some(crate::db::pool::DbHandle::Sqlite(_)) => { + // SQLite has a single database per file; expose the catalog name. + Ok(vec!["main".to_string()]) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn get_schemas( + connection_id: String, + state: State<'_, crate::AppState>, +) -> Result, String> { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + let rows = client + .query( + "SELECT nspname FROM pg_namespace WHERE nspname NOT IN ('information_schema', 'pg_catalog') AND nspname NOT LIKE 'pg_toast%' AND nspname NOT LIKE 'pg_temp%' ORDER BY nspname", + &[], + ) + .await + .map_err(|e| e.to_string())?; + Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + let mut stmt = conn + .prepare("SELECT DISTINCT 'main' AS schema") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn get_tables( + connection_id: String, + schema: Option, + state: State<'_, crate::AppState>, +) -> Result, String> { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + let schema_filter = schema.unwrap_or_else(|| "public".to_string()); + let rows = client + .query( + "SELECT table_name, table_schema, table_type FROM information_schema.tables WHERE table_schema = $1 AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_name", + &[&schema_filter], + ) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| TableInfo { + name: r.get(0), + schema: r.get(1), + table_type: r.get(2), + }) + .collect()) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + let mut stmt = conn + .prepare( + "SELECT name, 'main', type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + Ok(TableInfo { + name: row.get::<_, String>(0)?, + schema: row.get::<_, String>(1)?, + table_type: row.get::<_, String>(2)?.to_uppercase(), + }) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn get_table_data( + connection_id: String, + schema: String, + table: String, + page: Option, + page_size: Option, + state: State<'_, crate::AppState>, +) -> Result { + let p = page.unwrap_or(1); + let ps = page_size.unwrap_or(50); + let off = (p - 1) * ps; + + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + // Get total count + let count_query = + format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table); + let count_row = client + .query_one(&count_query, &[]) + .await + .map_err(|e| e.to_string())?; + let total_rows: i64 = count_row.get(0); + + // Get column info with FK detection and enum type names + let col_query = r#"SELECT + c.column_name, + CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, + c.is_nullable, + COALESCE(pk.is_pk, false) AS is_pk, + COALESCE(fk.is_fk, false) AS is_fk, + fk.foreign_table_name, + fk.foreign_column_name, + c.column_default +FROM information_schema.columns c +LEFT JOIN ( + SELECT ku.column_name, true AS is_pk + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage ku + ON tc.constraint_catalog = ku.constraint_catalog + AND tc.constraint_schema = ku.constraint_schema + AND tc.constraint_name = ku.constraint_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 +) pk ON c.column_name = pk.column_name +LEFT JOIN ( + SELECT + ku.column_name, + true AS is_fk, + 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 ku + ON tc.constraint_catalog = ku.constraint_catalog + AND tc.constraint_schema = ku.constraint_schema + AND tc.constraint_name = ku.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' + AND tc.table_schema = $1 + AND tc.table_name = $2 +) fk ON c.column_name = fk.column_name +WHERE c.table_schema = $1 AND c.table_name = $2 +ORDER BY c.ordinal_position"#; + let col_rows = client + .query(col_query, &[&schema, &table]) + .await + .map_err(|e| e.to_string())?; + let columns: Vec = col_rows + .iter() + .map(|r| { + let is_fk: bool = r.get(4); + let fk_table: Option = r.get(5); + let fk_column: Option = r.get(6); + ColumnInfo { + name: r.get(0), + data_type: r.get(1), + is_nullable: r.get::<_, String>(2) == "YES", + is_pk: r.get(3), + is_fk, + fk_ref: if is_fk { + Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default())) + } else { + None + }, + default_value: r.get::<_, Option>(7), + } + }) + .collect(); + + // Get data + let data_query = format!( + "SELECT * FROM \"{}\".\"{}\" LIMIT {} OFFSET {}", + schema, table, ps, off + ); + let data_rows = client + .query(&data_query, &[]) + .await + .map_err(|e| e.to_string())?; + let rows: Vec> = data_rows + .iter() + .map(|row| (0..row.len()).map(|i| pg_value_to_json(row, i)).collect()) + .collect(); + + Ok(QueryResult { + columns, + rows, + total_rows, + page: p, + page_size: ps, + }) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + let count_query = + format!("SELECT COUNT(*) FROM \"{}\".\"{}\"", schema, table); + let total_rows: i64 = conn + .query_row(&count_query, [], |r| r.get(0)) + .map_err(|e| e.to_string())?; + + // Get column metadata via PRAGMA table_info + let pragma_query = format!("PRAGMA table_info('{}')", table); + let mut pragma_stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?; + let col_meta: Vec<(String, String, bool, bool, Option)> = pragma_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, // name + row.get::<_, String>(2)?, // type + row.get::<_, bool>(3)?, // notnull + row.get::<_, bool>(5)?, // pk + row.get::<_, Option>(4)?, // dflt_value + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + // Get FK metadata via PRAGMA foreign_key_list + let fk_query = format!("PRAGMA foreign_key_list('{}')", table); + let fk_map: HashMap = + if let Ok(mut fk_stmt) = conn.prepare(&fk_query) { + fk_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(3)?, // from (column) + row.get::<_, String>(2)?, // table + row.get::<_, String>(4)?, // to (column) + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|(from, ref_table, ref_col)| (from, (ref_table, ref_col))) + .collect() + } else { + HashMap::new() + }; + + let columns: Vec = col_meta + .iter() + .map(|(name, dtype, notnull, is_pk, default_val)| { + let fk = fk_map.get(name); + ColumnInfo { + name: name.clone(), + data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() }, + is_nullable: !notnull, + is_pk: *is_pk, + is_fk: fk.is_some(), + fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())), + default_value: default_val.clone(), + } + }) + .collect(); + + // Get data + let data_query = format!( + "SELECT * FROM \"{}\".\"{}\" LIMIT {} OFFSET {}", + schema, table, ps, off + ); + let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?; + let col_count = stmt.column_count(); + + let rows: Vec> = stmt + .query_map([], |row| { + let mut vals = Vec::new(); + for i in 0..col_count { + let val: Option = row.get(i).unwrap_or(None); + vals.push( + val.map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + ); + } + Ok(vals) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(QueryResult { + columns, + rows, + total_rows, + page: p, + page_size: ps, + }) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn get_fk_preview( + connection_id: String, + schema: String, + table: String, + column: String, + value: String, + state: State<'_, crate::AppState>, +) -> Result { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + // Get column info with FK detection + let col_query = r#"SELECT + c.column_name, + CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, + c.is_nullable, + COALESCE(pk.is_pk, false) AS is_pk, + COALESCE(fk.is_fk, false) AS is_fk, + fk.foreign_table_name, + fk.foreign_column_name, + c.column_default +FROM information_schema.columns c +LEFT JOIN ( + SELECT ku.column_name, true AS is_pk + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage ku + ON tc.constraint_catalog = ku.constraint_catalog + AND tc.constraint_schema = ku.constraint_schema + AND tc.constraint_name = ku.constraint_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 +) pk ON c.column_name = pk.column_name +LEFT JOIN ( + SELECT + ku.column_name, + true AS is_fk, + 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 ku + ON tc.constraint_catalog = ku.constraint_catalog + AND tc.constraint_schema = ku.constraint_schema + AND tc.constraint_name = ku.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' + AND tc.table_schema = $1 + AND tc.table_name = $2 +) fk ON c.column_name = fk.column_name +WHERE c.table_schema = $1 AND c.table_name = $2 +ORDER BY c.ordinal_position"#; + let col_rows = client + .query(col_query, &[&schema, &table]) + .await + .map_err(|e| e.to_string())?; + let columns: Vec = col_rows + .iter() + .map(|r| { + let is_fk: bool = r.get(4); + let fk_table: Option = r.get(5); + let fk_column: Option = r.get(6); + ColumnInfo { + name: r.get(0), + data_type: r.get(1), + is_nullable: r.get::<_, String>(2) == "YES", + is_pk: r.get(3), + is_fk, + fk_ref: if is_fk { + Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default())) + } else { + None + }, + default_value: r.get::<_, Option>(7), + } + }) + .collect(); + + // Fetch the referenced row + let data_query = format!( + "SELECT * FROM \"{}\".\"{}\" WHERE \"{}\"::text = $1 LIMIT 1", + schema, table, column + ); + let data_rows = client + .query(&data_query, &[&value]) + .await + .map_err(|e| e.to_string())?; + let rows: Vec> = data_rows + .iter() + .map(|row| (0..row.len()).map(|i| pg_value_to_json(row, i)).collect()) + .collect(); + + Ok(QueryResult { + columns, + rows, + total_rows: 1, + page: 1, + page_size: 1, + }) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + // Get column metadata via PRAGMA table_info + let pragma_query = format!("PRAGMA table_info('{}')", table); + let mut pragma_stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?; + let col_meta: Vec<(String, String, bool, bool, Option)> = pragma_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, bool>(3)?, + row.get::<_, bool>(5)?, + row.get::<_, Option>(4)?, + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + // Get FK metadata + let fk_query = format!("PRAGMA foreign_key_list('{}')", table); + let fk_map: HashMap = + if let Ok(mut fk_stmt) = conn.prepare(&fk_query) { + fk_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(3)?, + row.get::<_, String>(2)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|(from, ref_table, ref_col)| (from, (ref_table, ref_col))) + .collect() + } else { + HashMap::new() + }; + + let columns: Vec = col_meta + .iter() + .map(|(name, dtype, notnull, is_pk, default_val)| { + let fk = fk_map.get(name); + ColumnInfo { + name: name.clone(), + data_type: if dtype.is_empty() { "TEXT".to_string() } else { dtype.clone() }, + is_nullable: !notnull, + is_pk: *is_pk, + is_fk: fk.is_some(), + fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())), + default_value: default_val.clone(), + } + }) + .collect(); + + // Fetch the referenced row + let data_query = format!( + "SELECT * FROM \"{}\".\"{}\" WHERE \"{}\" = ?1 LIMIT 1", + schema, table, column + ); + let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?; + let col_count = stmt.column_count(); + let rows: Vec> = stmt + .query_map([&value], |row| { + let mut vals = Vec::new(); + for i in 0..col_count { + let val: Option = row.get(i).unwrap_or(None); + vals.push( + val.map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + ); + } + Ok(vals) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(QueryResult { + columns, + rows, + total_rows: 1, + page: 1, + page_size: 1, + }) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn execute_change( + connection_id: String, + change: Change, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + // Build the parameterized SQL + bound values from the change. + let (sql, params): (String, Vec) = match &change { + Change::Update { + schema, + table, + primary_key, + new_data, + .. + } => { + let pk = parse_json_pairs(primary_key)?; + let data = parse_json_pairs(new_data)?; + build_pg_update_sql(schema, table, &pk, &data) + } + Change::Insert { + schema, + table, + data, + .. + } => { + let pairs = parse_json_pairs(data)?; + build_pg_insert_sql(schema, table, &pairs) + } + Change::Delete { + schema, + table, + primary_key, + .. + } => { + let pk = parse_json_pairs(primary_key)?; + build_pg_delete_sql(schema, table, &pk) + } + Change::AlterTable { sql, .. } => { + // Execute the raw DDL directly; no bound parameters. + client.execute(sql, &[]).await.map_err(|e| e.to_string())?; + return Ok(()); + } + }; + + // Box each value for trait-object binding (`$N` placeholders). The + // boxed values must be `Send` so the async command future stays + // `Send` across the `.await`. + let boxed: Vec> = + params.iter().map(pg_box_value).collect(); + // Coerce each `&(dyn ToSql + Send + Sync)` reference down to + // `&(dyn ToSql + Sync)` (dropping the `Send` auto-trait) to match + // `tokio_postgres::Client::execute`'s expected slice type. + let refs: Vec<&(dyn ToSql + Sync)> = boxed + .iter() + .map(|b| { + let r: &(dyn ToSql + Sync) = &**b; + r + }) + .collect(); + client.execute(&sql, &refs).await.map_err(|e| e.to_string())?; + Ok(()) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + let (sql, params): (String, Vec) = match &change { + Change::Update { + schema, + table, + primary_key, + new_data, + .. + } => { + let pk = parse_json_pairs(primary_key)?; + let data = parse_json_pairs(new_data)?; + ( + build_update_sql(schema, table, &pk, &data), + pk.iter() + .chain(data.iter()) + .map(|(_, v)| v.clone()) + .collect(), + ) + } + Change::Insert { + schema, + table, + data, + .. + } => { + let pairs = parse_json_pairs(data)?; + let columns: Vec = + pairs.iter().map(|(c, _)| c.clone()).collect(); + ( + build_insert_sql(schema, table, &columns), + pairs.iter().map(|(_, v)| v.clone()).collect(), + ) + } + Change::Delete { + schema, + table, + primary_key, + .. + } => { + let pk = parse_json_pairs(primary_key)?; + ( + build_delete_sql(schema, table, &pk), + pk.iter().map(|(_, v)| v.clone()).collect(), + ) + } + Change::AlterTable { sql, .. } => { + conn.execute(sql, []).map_err(|e| e.to_string())?; + return Ok(()); + } + }; + + let sqlite_params: Vec = + params.iter().map(json_to_sqlite_value).collect(); + conn.execute(&sql, rusqlite::params_from_iter(sqlite_params)) + .map_err(|e| e.to_string())?; + Ok(()) + } + None => Err("Connection not found".to_string()), + } +} + +#[tauri::command] +pub async fn refresh_connection( + connection_id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + // Verify the connection is still alive by running a trivial query. The + // frontend re-issues getDatabases/getSchemas/getTables separately after + // this returns, so we only need to confirm reachability here. + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { + client.query_one("SELECT 1", &[]).await.map_err(|e| e.to_string())?; + Ok(()) + } + Some(crate::db::pool::DbHandle::Sqlite(conn)) => { + conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) + .map_err(|e| e.to_string())?; + Ok(()) + } + None => Err("Connection not found".to_string()), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::db_viewer::Change; + + /// Verify the `offset` helper produces correct pagination offsets. + #[test] + fn pagination_offset_is_correct() { + assert_eq!(offset(1, 50), 0, "page 1, size 50 => offset 0"); + assert_eq!(offset(2, 50), 50, "page 2, size 50 => offset 50"); + assert_eq!(offset(5, 25), 100, "page 5, size 25 => offset 100"); + } + + /// Verify that a `Change::Update` serializes with the correct `type` tag. + #[test] + fn execute_change_serialization() { + let change = Change::Update { + id: "chg-1".to_string(), + schema: "public".to_string(), + table: "users".to_string(), + primary_key: r#"{"id": 1}"#.to_string(), + old_data: r#"{"name": "alice"}"#.to_string(), + new_data: r#"{"name": "bob"}"#.to_string(), + }; + let json = serde_json::to_string(&change).unwrap(); + assert!( + json.contains(r#""type":"update""#), + "serialized Change::Update should use snake_case tag 'update'; got: {}", + json + ); + } + + /// Verify that `Change::id()` returns the correct identifier. + #[test] + fn change_id_is_accessible() { + let change = Change::Insert { + id: "chg-42".to_string(), + schema: "public".to_string(), + table: "logs".to_string(), + data: r#"{"event": "login"}"#.to_string(), + }; + assert_eq!( + change.id(), + "chg-42", + "id() should return the 'id' field of the Insert variant" + ); + } + + /// Verify that `build_update_sql` produces valid SQL with all required + /// clauses. + #[test] + fn build_change_update_sql_is_valid() { + let pk = vec![("id".to_string(), serde_json::json!(1))]; + let data = vec![ + ("name".to_string(), serde_json::json!("bob")), + ("email".to_string(), serde_json::json!("bob@example.com")), + ]; + + let sql = build_update_sql("public", "users", &pk, &data); + + assert!( + sql.to_uppercase().contains("UPDATE"), + "UPDATE SQL must contain 'UPDATE'; got: {}", + sql + ); + assert!( + sql.to_uppercase().contains("SET"), + "UPDATE SQL must contain 'SET'; got: {}", + sql + ); + assert!( + sql.to_uppercase().contains("WHERE"), + "UPDATE SQL must contain 'WHERE'; got: {}", + sql + ); + } + + /// Verify that `build_delete_sql` produces valid SQL with all required + /// clauses. + #[test] + fn build_change_delete_sql_is_valid() { + let pk = vec![("id".to_string(), serde_json::json!(1))]; + + let sql = build_delete_sql("public", "users", &pk); + + assert!( + sql.to_uppercase().contains("DELETE FROM"), + "DELETE SQL must contain 'DELETE FROM'; got: {}", + sql + ); + assert!( + sql.to_uppercase().contains("WHERE"), + "DELETE SQL must contain 'WHERE'; got: {}", + sql + ); + } + + /// Verify that `build_insert_sql` produces valid SQL with all required + /// clauses. + #[test] + fn build_change_insert_sql_is_valid() { + let columns = vec!["id".to_string(), "name".to_string(), "email".to_string()]; + + let sql = build_insert_sql("public", "users", &columns); + + assert!( + sql.to_uppercase().contains("INSERT INTO"), + "INSERT SQL must contain 'INSERT INTO'; got: {}", + sql + ); + assert!( + sql.to_uppercase().contains("VALUES"), + "INSERT SQL must contain 'VALUES'; got: {}", + sql + ); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/demo.rs b/src-tauri/src/commands/demo.rs new file mode 100644 index 0000000..1cc2256 --- /dev/null +++ b/src-tauri/src/commands/demo.rs @@ -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) -> 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) -> Result { + 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) -> 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() +} \ No newline at end of file diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs new file mode 100644 index 0000000..53bb934 --- /dev/null +++ b/src-tauri/src/commands/folders.rs @@ -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) -> Result, String> { + let store = state.lock().map_err(|e| e.to_string())?; + store.get_folders() +} + +pub fn create_folder_inner( + state: &Mutex, + input: FolderInput, +) -> Result { + validate(&input)?; + let store = state.lock().map_err(|e| e.to_string())?; + store.create_folder(input) +} + +pub fn delete_folder_inner(state: &Mutex, 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, + folder_id: String, + tag_ids: Vec, +) -> 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, + id: String, + input: FolderInput, +) -> Result { + 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) -> Result, String> { + get_folders_inner(&state.db_store) +} + +#[tauri::command] +pub fn create_folder( + state: tauri::State, + input: FolderInput, +) -> Result { + create_folder_inner(&state.db_store, input) +} + +#[tauri::command] +pub fn delete_folder(state: tauri::State, id: String) -> Result<(), String> { + delete_folder_inner(&state.db_store, &id) +} + +#[tauri::command] +pub fn add_folder_tags( + state: tauri::State, + folder_id: String, + tag_ids: Vec, +) -> Result<(), String> { + add_folder_tags_inner(&state.db_store, folder_id, tag_ids) +} + +#[tauri::command] +pub fn update_folder( + state: tauri::State, + id: String, + input: FolderInput, +) -> Result { + 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 { + 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); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs new file mode 100644 index 0000000..1e28246 --- /dev/null +++ b/src-tauri/src/commands/import_export.rs @@ -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, + db_type: String, + host: String, + port: Option, + username: Option, + folder_id: Option, + tag_ids: Option>, +} + +#[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, +} + +#[allow(dead_code)] +pub fn parse_import(json: &str) -> Result, String> { + let records: Vec = 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, json: String) -> Result { + let records: Vec = 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) -> Result { + 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, json: String) -> Result { + import_connections_inner(&state.db_store, json) +} + +#[tauri::command] +pub fn export_connections(state: tauri::State) -> Result { + 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 { + 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\"")); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs new file mode 100644 index 0000000..cf2d110 --- /dev/null +++ b/src-tauri/src/commands/keychain.rs @@ -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, 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()) +} \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..b8b8dca --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -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; \ No newline at end of file diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..d3d109e --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,50 @@ +use crate::models::Settings; +use crate::store::Store; +use std::sync::Mutex; + +pub fn get_settings_inner(state: &Mutex) -> Result { + let store = state.lock().map_err(|e| e.to_string())?; + store.get_settings() +} + +pub fn update_setting_inner(state: &Mutex, 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) -> Result { + get_settings_inner(&state.db_store) +} + +#[tauri::command] +pub fn update_setting(state: tauri::State, 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 { + 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"); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs new file mode 100644 index 0000000..484ad9b --- /dev/null +++ b/src-tauri/src/commands/ssh.rs @@ -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, + pub private_key_path: Option, + pub passphrase: Option, +} + +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, +} + +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 { + 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 { + 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"); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs new file mode 100644 index 0000000..18bae4b --- /dev/null +++ b/src-tauri/src/commands/tags.rs @@ -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) -> Result, String> { + let store = state.lock().map_err(|e| e.to_string())?; + store.get_tags() +} + +pub fn create_tag_inner(state: &Mutex, input: TagInput) -> Result { + validate(&input)?; + let store = state.lock().map_err(|e| e.to_string())?; + store.create_tag(input) +} + +pub fn delete_tag_inner(state: &Mutex, 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, + id: String, + input: TagInput, +) -> Result { + 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) -> Result, String> { + get_tags_inner(&state.db_store) +} + +#[tauri::command] +pub fn create_tag(state: tauri::State, input: TagInput) -> Result { + create_tag_inner(&state.db_store, input) +} + +#[tauri::command] +pub fn delete_tag(state: tauri::State, id: String) -> Result<(), String> { + delete_tag_inner(&state.db_store, &id) +} + +#[tauri::command] +pub fn update_tag( + state: tauri::State, + id: String, + input: TagInput, +) -> Result { + 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 { + 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()); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/test_connection.rs b/src-tauri/src/commands/test_connection.rs new file mode 100644 index 0000000..5894de5 --- /dev/null +++ b/src-tauri/src/commands/test_connection.rs @@ -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, +} + +/// 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 { + 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 { + 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" + ); + } +} \ No newline at end of file diff --git a/src-tauri/src/db/introspection.rs b/src-tauri/src/db/introspection.rs new file mode 100644 index 0000000..19f5f69 --- /dev/null +++ b/src-tauri/src/db/introspection.rs @@ -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) { + 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"); + } +} \ No newline at end of file diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..d067c15 --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,5 @@ +pub mod pool; +pub mod introspection; + +#[allow(unused_imports)] +pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; \ No newline at end of file diff --git a/src-tauri/src/db/pool.rs b/src-tauri/src/db/pool.rs new file mode 100644 index 0000000..1bf535d --- /dev/null +++ b/src-tauri/src/db/pool.rs @@ -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, + pub username: Option, + pub password: Option, + pub database: Option, + pub ssl_mode: Option, + pub ssl_ca_path: Option, + pub ssl_cert_path: Option, + pub ssl_key_path: Option, +} + +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, + 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 { + &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")); + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..611e339 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, + pub pool_manager: tokio::sync::Mutex, + pub ssh_manager: StdMutex, +} + +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::(); + 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"); } diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs new file mode 100644 index 0000000..79c0920 --- /dev/null +++ b/src-tauri/src/models/connection.rs @@ -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, + pub username: Option, + pub database: Option, + pub folder_id: Option, + pub keychain_ref: Option, + pub environment: Option, + pub ssh_host: Option, + pub ssh_port: Option, + pub ssh_user: Option, + pub ssh_auth_method: Option, + pub ssh_private_key_path: Option, + pub ssl_mode: Option, + pub ssl_ca_path: Option, + pub ssl_cert_path: Option, + pub ssl_key_path: Option, + pub tag_ids: Vec, + 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, + pub username: Option, + pub folder_id: Option, + pub tag_ids: Vec, + pub password: Option, + pub database: Option, + pub environment: Option, + pub ssh_host: Option, + pub ssh_port: Option, + pub ssh_user: Option, + pub ssh_auth_method: Option, + pub ssh_private_key_path: Option, + pub ssh_passphrase: Option, + pub ssl_mode: Option, + pub ssl_ca_path: Option, + pub ssl_cert_path: Option, + pub ssl_key_path: Option, +} + +#[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"); + } +} \ No newline at end of file diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs new file mode 100644 index 0000000..771cd66 --- /dev/null +++ b/src-tauri/src/models/db_viewer.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResult { + pub columns: Vec, + pub rows: Vec>, + 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"#)); + } +} \ No newline at end of file diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs new file mode 100644 index 0000000..aa4cf73 --- /dev/null +++ b/src-tauri/src/models/folder.rs @@ -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, + pub tag_ids: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderInput { + pub name: String, + pub parent_id: Option, + pub tag_ids: Option>, +} \ No newline at end of file diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs new file mode 100644 index 0000000..9b71c55 --- /dev/null +++ b/src-tauri/src/models/mod.rs @@ -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}; \ No newline at end of file diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs new file mode 100644 index 0000000..394007b --- /dev/null +++ b/src-tauri/src/models/settings.rs @@ -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, + pub theme: String, + pub font_size: String, + pub default_ports: HashMap>, + pub tag_order: Option, + pub table_refresh_rate: i64, + pub table_page_size: i64, + pub shortcuts: HashMap, +} \ No newline at end of file diff --git a/src-tauri/src/models/tag.rs b/src-tauri/src/models/tag.rs new file mode 100644 index 0000000..35654d6 --- /dev/null +++ b/src-tauri/src/models/tag.rs @@ -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, +} \ No newline at end of file diff --git a/src-tauri/src/store/migrations.rs b/src-tauri/src/store/migrations.rs new file mode 100644 index 0000000..f3d837e --- /dev/null +++ b/src-tauri/src/store/migrations.rs @@ -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 = { + 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 = { + 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 = 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); + } +} \ No newline at end of file diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs new file mode 100644 index 0000000..4daf19f --- /dev/null +++ b/src-tauri/src/store/mod.rs @@ -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, +} + +impl Store { + pub fn from_connection(conn: SqliteConnection) -> Self { + Self { + conn: Mutex::new(conn), + } + } + + pub fn open(path: &str) -> Result { + 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, 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 = 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 { + 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 { + 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 = 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, 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 { + 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 { + 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, 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 = 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 { + 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 { + 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 { + let conn = self.conn.lock().map_err(|e| e.to_string())?; + let mut map: HashMap = 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::>>(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") + ); + } +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7aaa188..a0fae33 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": {} } diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 85f7a4a..0000000 --- a/src/App.css +++ /dev/null @@ -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; - } -} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..2257c2a --- /dev/null +++ b/src/App.test.tsx @@ -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(); + expect(await screen.findByPlaceholderText(/search connections/i)).toBeInTheDocument(); + }); + + it("shows empty state when no connections", async () => { + render(); + expect(await screen.findByText(/no connections/i)).toBeInTheDocument(); + }); + + it("loads data on mount", async () => { + render(); + 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(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("renders new connection form when activeView is new-connection", async () => { + useUiStore.setState({ activeView: "new-connection" }); + render(); + 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(); + expect(await screen.findByText(/storage error/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 8286a76..5caa66a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { + 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 ( -
-

Welcome to Tauri + React

+ useEffect(() => { + loadConnections(); + loadSettings(); + }, [loadConnections, loadSettings]); - -

Click on the Tauri, Vite, and React logos to learn more.

+ 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]); -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - - -

{greetMsg}

-
- ); + return ( +
+ {connectionError && ( +
+ +
+ )} + {activeView === "settings" && } + {activeView === "new-connection" && ( + { + clearPrefilledConnectionString(); + setActiveView("home"); + }} + onCancel={() => { + clearPrefilledConnectionString(); + setActiveView("home"); + }} + /> + )} + {activeView === "home" && } + {activeView === "db-viewer" && ( + setActiveView("home")} + onSettings={() => setActiveView("settings")} + /> + )} + +
+ ); } - -export default App; diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/connections/ConnectionCard.test.tsx b/src/components/connections/ConnectionCard.test.tsx new file mode 100644 index 0000000..7f2d533 --- /dev/null +++ b/src/components/connections/ConnectionCard.test.tsx @@ -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(); + expect(screen.getByText("Prod DB")).toBeInTheDocument(); + expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument(); + }); + it("renders db type label", () => { + render(); + expect(screen.getByText(/postgresql/i)).toBeInTheDocument(); + }); + it("renders tag badges", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); \ No newline at end of file diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx new file mode 100644 index 0000000..d309ca0 --- /dev/null +++ b/src/components/connections/ConnectionCard.tsx @@ -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 ( +
+
+
+
+ {DB_ICONS[connection.db_type] ?? "❓"} +
+
+
+ {connection.name} +
+
+ {DB_LABELS[connection.db_type] ?? + connection.db_type} +
+
+ {connection.environment && ( + + {ENV_LABELS[connection.environment] ?? connection.environment} + + )} +
+
+ {hostLabel} +
+
+ {cardTags.map((t) => ( + + ))} +
+
+ +
+ ); +} + +export const ConnectionCard = memo(ConnectionCardBase); diff --git a/src/components/connections/ConnectionFormShell.tsx b/src/components/connections/ConnectionFormShell.tsx new file mode 100644 index 0000000..44e8d03 --- /dev/null +++ b/src/components/connections/ConnectionFormShell.tsx @@ -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 ( +
+
+ + +
{children}
+ +
+ + +
+ + +
+
+ ); +} diff --git a/src/components/connections/ConnectionGrid.test.tsx b/src/components/connections/ConnectionGrid.test.tsx new file mode 100644 index 0000000..7d039e7 --- /dev/null +++ b/src/components/connections/ConnectionGrid.test.tsx @@ -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(); + expect(screen.getByText(/no connections yet/i)).toBeInTheDocument(); + }); + + it("renders cards for each connection", () => { + const conns = [makeConn("1"), makeConn("2")]; + render(); + expect(screen.getByText("Conn 1")).toBeInTheDocument(); + expect(screen.getByText("Conn 2")).toBeInTheDocument(); + }); + + it("renders no-results state when filtered empty", () => { + render(); + expect(screen.getByText(/no connections match/i)).toBeInTheDocument(); + }); + + it("renders only top-level folders at root", () => { + render(); + 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(); + 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(); + await userEvent.click(screen.getByText("Work")); + expect(fn).toHaveBeenCalledWith("f1"); + }); + + it("breadcrumb navigates to root", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText(/all connections/i)); + expect(fn).toHaveBeenCalledWith(null); + }); + + it("shows folder cards", () => { + render(); + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.getByText("Personal")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/ConnectionGrid.tsx b/src/components/connections/ConnectionGrid.tsx new file mode 100644 index 0000000..48b586f --- /dev/null +++ b/src/components/connections/ConnectionGrid.tsx @@ -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 ( +
+
+ + {activeFolder && ( +
+ + +
+ )} +
+ + {!hasItems ? ( +
+ {hasSearch + ? "No connections match your search." + : activeFolderId + ? "This folder is empty. Add a connection or subfolder." + : "No connections yet. Create one to get started."} +
+ ) : ( +
+ {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 ( +
+ + +
+ ); + })} + {directConnections.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/connections/ConnectionStringInput.tsx b/src/components/connections/ConnectionStringInput.tsx new file mode 100644 index 0000000..29a09c5 --- /dev/null +++ b/src/components/connections/ConnectionStringInput.tsx @@ -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) => void; +} + +export const ConnectionStringInput = forwardRef( + function ConnectionStringInput({ onChange, onKeyDown, className = "", ...rest }, ref) { + return ( + onChange?.(e.target.value)} + onKeyDown={(e) => onKeyDown?.(e)} + {...rest} + /> + ); + } +); \ No newline at end of file diff --git a/src/components/connections/DetailedConnectionForm.test.tsx b/src/components/connections/DetailedConnectionForm.test.tsx new file mode 100644 index 0000000..54ad892 --- /dev/null +++ b/src/components/connections/DetailedConnectionForm.test.tsx @@ -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 & { + onChange?: (updates: Partial) => void; + }, +) { + const [form, setForm] = useState(BASE_FORM); + return ( + { + setForm((prev) => ({ ...prev, ...updates })); + props.onChange?.(updates); + }} + /> + ); +} + +describe("DetailedConnectionForm", () => { + it("updates host and port", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + 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 })); + }); +}); \ No newline at end of file diff --git a/src/components/connections/DetailedConnectionForm.tsx b/src/components/connections/DetailedConnectionForm.tsx new file mode 100644 index 0000000..6232e4a --- /dev/null +++ b/src/components/connections/DetailedConnectionForm.tsx @@ -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) => void; +} + +export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) { + const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general"); + + return ( +
+
+ + + +
+ + {activeTab === "general" ? ( + + ) : activeTab === "ssh" ? ( + } onChange={onChange as (updates: Record) => void} /> + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/EnvironmentSelect.tsx b/src/components/connections/EnvironmentSelect.tsx new file mode 100644 index 0000000..c7784b4 --- /dev/null +++ b/src/components/connections/EnvironmentSelect.tsx @@ -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 ( + + onChange(next === "" ? null : (next as Environment)) + } + options={OPTIONS.map((opt) => ({ + value: opt.value ?? "", + label: opt.label, + }))} + placeholder="None" + /> + ); +} \ No newline at end of file diff --git a/src/components/connections/FolderSelect.tsx b/src/components/connections/FolderSelect.tsx new file mode 100644 index 0000000..1613db0 --- /dev/null +++ b/src/components/connections/FolderSelect.tsx @@ -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 ( + onChange(next === "" ? null : next)} + options={options} + placeholder="None" + /> + ); +} diff --git a/src/components/connections/GeneralTab.test.tsx b/src/components/connections/GeneralTab.test.tsx new file mode 100644 index 0000000..c6a7a2e --- /dev/null +++ b/src/components/connections/GeneralTab.test.tsx @@ -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( {}} />); + + 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( {}} />); + + expect(screen.queryByLabelText("Host")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Port")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Database")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/GeneralTab.tsx b/src/components/connections/GeneralTab.tsx new file mode 100644 index 0000000..21f4806 --- /dev/null +++ b/src/components/connections/GeneralTab.tsx @@ -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) => void; +} + +const AUTH_OPTIONS = ["User & Password"]; + +export function GeneralTab({ form, onChange }: GeneralTabProps) { + const isSqlite = form.db_type === "sqlite"; + + return ( +
+ {!isSqlite && ( +
+
+ + onChange({ host: value })} + placeholder="localhost" + aria-label="Host" + /> +
+
+ + onChange({ port: value === "" ? null : Number(value) })} + placeholder="5432" + aria-label="Port" + /> +
+
+ )} + +
+ + +
+ +
+ + onChange({ username: value || null })} + placeholder="postgres" + aria-label="User" + /> +
+ +
+ + onChange({ password: value || null })} + placeholder="••••••••" + aria-label="Password" + /> +
+ +
+ + onChange({ database: value || null })} + placeholder="database" + aria-label="Database" + /> +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/NewConnectionScreen.test.tsx b/src/components/connections/NewConnectionScreen.test.tsx new file mode 100644 index 0000000..bb7eaf5 --- /dev/null +++ b/src/components/connections/NewConnectionScreen.test.tsx @@ -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(); + 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( + , + ); + + 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(); + 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(); + + 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(); + + 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(); + + await user.click(screen.getByRole("button", { name: "Back" })); + expect(onCancel).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/NewConnectionScreen.tsx b/src/components/connections/NewConnectionScreen.tsx new file mode 100644 index 0000000..45527ac --- /dev/null +++ b/src/components/connections/NewConnectionScreen.tsx @@ -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("simple"); + const [form, setForm] = useState(() => + 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) => { + 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) => { + 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 ( + onCancel?.()} + onTest={handleTest} + onSave={handleSave} + onToggleMode={onToggleMode} + testLoading={testLoading} + saveLoading={saveLoading} + > + {mode === "simple" ? ( + + ) : ( + + )} + + ); +} diff --git a/src/components/connections/PasswordInput.tsx b/src/components/connections/PasswordInput.tsx new file mode 100644 index 0000000..3d5294c --- /dev/null +++ b/src/components/connections/PasswordInput.tsx @@ -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) => void; +} + +export const PasswordInput = forwardRef( + function PasswordInput({ onChange, onKeyDown, className = "", ...rest }, ref) { + const [visible, setVisible] = useState(false); + return ( +
+ onChange?.(e.target.value)} + onKeyDown={(e) => onKeyDown?.(e)} + {...rest} + /> + +
+ ); + } +); \ No newline at end of file diff --git a/src/components/connections/SimpleConnectionForm.test.tsx b/src/components/connections/SimpleConnectionForm.test.tsx new file mode 100644 index 0000000..591a707 --- /dev/null +++ b/src/components/connections/SimpleConnectionForm.test.tsx @@ -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 & { + onChange?: (updates: Partial) => void; + }, +) { + const [form, setForm] = useState(BASE_FORM); + return ( + { + setForm((prev) => ({ ...prev, ...updates })); + props.onChange?.(updates); + }} + /> + ); +} + +describe("SimpleConnectionForm", () => { + it("updates the connection string", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + 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(); + + 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: [] }); + }); +}); diff --git a/src/components/connections/SimpleConnectionForm.tsx b/src/components/connections/SimpleConnectionForm.tsx new file mode 100644 index 0000000..4a74ce7 --- /dev/null +++ b/src/components/connections/SimpleConnectionForm.tsx @@ -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) => void; +} + +export function SimpleConnectionForm({ + form, + folders, + tags, + onChange, +}: SimpleConnectionFormProps) { + return ( +
+
+ + onChange({ name: value })} + placeholder="My Production Database" + aria-label="Connection Label" + /> +

+ A friendly name to identify this connection. +

+
+ +
+ + onChange({ environment: value })} + /> +
+ +
+ + onChange({ folder_id: value })} + /> +
+ + { + const current = form.tag_ids ?? []; + const next = current.includes(tagId) + ? current.filter((id) => id !== tagId) + : [...current, tagId]; + onChange({ tag_ids: next }); + }} + /> + +
+ + onChange({ connection_string: value })} + placeholder="postgresql://user:password@host:5432/database" + aria-label="Connection String" + /> +

+ Paste your connection string to auto-detect database type. +

+
+
+ ); +} diff --git a/src/components/connections/SshFields.test.tsx b/src/components/connections/SshFields.test.tsx new file mode 100644 index 0000000..a475040 --- /dev/null +++ b/src/components/connections/SshFields.test.tsx @@ -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( {}} />); + + expect(screen.getByLabelText("SSH Host")).toBeInTheDocument(); + expect(screen.getByLabelText("SSH Port")).toBeInTheDocument(); + expect(screen.getByLabelText("SSH User")).toBeInTheDocument(); + }); + + it("renders auth method dropdown", () => { + render( {}} />); + + expect(screen.getByRole("button", { name: "Auth Method" })).toBeInTheDocument(); + }); + + it("shows private key and passphrase fields when auth method is key", () => { + render( {}} />); + + 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( {}} />); + + expect(screen.getByLabelText("SSH Password")).toBeInTheDocument(); + expect(screen.queryByLabelText("Private Key")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Passphrase")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/SshFields.tsx b/src/components/connections/SshFields.tsx new file mode 100644 index 0000000..3acf246 --- /dev/null +++ b/src/components/connections/SshFields.tsx @@ -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; + onChange: (updates: Record) => 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 ( +
+
+ + onChange({ ssh_host: value })} + placeholder="bastion.example.com" + aria-label="SSH Host" + /> +
+ +
+ + onChange({ ssh_port: value === "" ? null : Number(value) })} + placeholder="22" + aria-label="SSH Port" + /> +
+ +
+ + onChange({ ssh_user: value })} + placeholder="ssh-user" + aria-label="SSH User" + /> +
+ +
+ + onChange({ ssh_auth_method: value })} + options={AUTH_METHOD_OPTIONS} + aria-label="Auth Method" + /> +
+ + {authMethod === "key" ? ( + <> +
+ +
+ onChange({ ssh_private_key: value })} + placeholder="/path/to/key" + aria-label="Private Key" + /> + +
+
+ +
+ + onChange({ ssh_passphrase: value })} + placeholder="••••••••" + aria-label="Passphrase" + /> +
+ + ) : ( +
+ + onChange({ ssh_password: value })} + placeholder="••••••••" + aria-label="SSH Password" + /> +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/SshSslTab.test.tsx b/src/components/connections/SshSslTab.test.tsx new file mode 100644 index 0000000..e8d4597 --- /dev/null +++ b/src/components/connections/SshSslTab.test.tsx @@ -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( {}} />); + + 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( {}} />); + + 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(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/SshSslTab.tsx b/src/components/connections/SshSslTab.tsx new file mode 100644 index 0000000..7d50724 --- /dev/null +++ b/src/components/connections/SshSslTab.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { SshFields } from "./SshFields"; +import { SslFields } from "./SslFields"; + +export interface SshSslTabProps { + form: Record; + onChange: (updates: Record) => void; +} + +export function SshSslTab({ form, onChange }: SshSslTabProps) { + const [activeSubTab, setActiveSubTab] = useState<"ssh" | "ssl">("ssh"); + + return ( +
+
+ + +
+ + {activeSubTab === "ssh" ? : } +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/SslFields.test.tsx b/src/components/connections/SslFields.test.tsx new file mode 100644 index 0000000..5183846 --- /dev/null +++ b/src/components/connections/SslFields.test.tsx @@ -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( {}} />); + + expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument(); + }); + + it("shows file pickers for verify-full mode", () => { + render( {}} />); + + 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( {}} />); + + expect(screen.queryByLabelText("CA Certificate")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/connections/SslFields.tsx b/src/components/connections/SslFields.tsx new file mode 100644 index 0000000..08ab3b0 --- /dev/null +++ b/src/components/connections/SslFields.tsx @@ -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; + onChange: (updates: Record) => 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 ( +
+
+ + onChange({ ssl_mode: value })} + options={SSL_MODE_OPTIONS} + aria-label="SSL Mode" + /> +
+ + {mode === "require" && ( +

+ Require mode is vulnerable to man-in-the-middle attacks because it does not verify the server certificate. +

+ )} + + {showCertFields && ( + <> +
+ +
+ onChange({ ssl_ca_cert: value })} + placeholder="/path/to/ca-cert.pem" + aria-label="CA Certificate" + /> + +
+
+ +
+ +
+ onChange({ ssl_client_cert: value })} + placeholder="/path/to/client-cert.pem" + aria-label="Client Certificate" + /> + +
+
+ +
+ +
+ onChange({ ssl_client_key: value })} + placeholder="/path/to/client-key.pem" + aria-label="Client Key" + /> + +
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/connections/TagsEnvTab.tsx b/src/components/connections/TagsEnvTab.tsx new file mode 100644 index 0000000..a3d04cb --- /dev/null +++ b/src/components/connections/TagsEnvTab.tsx @@ -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) => void; +} + +export function TagsEnvTab({ form, onChange }: TagsEnvTabProps) { + const folders = useConnectionStore((s) => s.folders); + const tags = useConnectionStore((s) => s.tags); + + return ( +
+
+ + onChange({ environment: value })} + /> +
+ +
+ + onChange({ folder_id: value })} + /> +
+ +
+ + { + const current = form.tag_ids ?? []; + const next = current.includes(tagId) + ? current.filter((id) => id !== tagId) + : [...current, tagId]; + onChange({ tag_ids: next }); + }} + /> +
+
+ ); +} \ No newline at end of file diff --git a/src/components/connections/connectionFormData.ts b/src/components/connections/connectionFormData.ts new file mode 100644 index 0000000..79719d5 --- /dev/null +++ b/src/components/connections/connectionFormData.ts @@ -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; +} diff --git a/src/components/db-viewer/ChangesQueuePanel.test.tsx b/src/components/db-viewer/ChangesQueuePanel.test.tsx new file mode 100644 index 0000000..d9d4ba6 --- /dev/null +++ b/src/components/db-viewer/ChangesQueuePanel.test.tsx @@ -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(); + 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(); + 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(); + const cancelBtn = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelBtn); + expect(screen.getByText(/cancelled/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/ChangesQueuePanel.tsx b/src/components/db-viewer/ChangesQueuePanel.tsx new file mode 100644 index 0000000..ddb0df9 --- /dev/null +++ b/src/components/db-viewer/ChangesQueuePanel.tsx @@ -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 = { + 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 ( +
+ + Pending +
+ ); + case "committed": + return ( +
+ + Committed +
+ ); + case "failed": + return ( +
+ + Failed +
+ ); + case "cancelled": + return ( +
+ Cancelled +
+ ); + 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 ( +
+ + + + {expanded && ( +
+ {changesQueue.map((change) => ( + cancelChange(change.id)} + /> + ))} +
+ )} +
+ ); +} + +function ChangeRow({ + change, + onCancel, +}: { + change: QueueItem; + onCancel: () => void; +}) { + return ( +
+
+ + {capitalizeType(change.type)} + + + {change.table ? change.table : "-"} + +
+ +
+ + {change.status === "pending" && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/ConnectionDropBanner.test.tsx b/src/components/db-viewer/ConnectionDropBanner.test.tsx new file mode 100644 index 0000000..bea4a1d --- /dev/null +++ b/src/components/db-viewer/ConnectionDropBanner.test.tsx @@ -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( + {}} + onDismiss={() => {}} + />, + ); + expect(screen.getByText("Connection lost")).toBeInTheDocument(); + }); + + it("shows reconnect button", () => { + render( + {}} + 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( + {}} + />, + ); + 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( + {}} + onDismiss={onDismiss} + />, + ); + await user.click(screen.getByRole("button", { name: /dismiss/i })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/ConnectionDropBanner.tsx b/src/components/db-viewer/ConnectionDropBanner.tsx new file mode 100644 index 0000000..ac5152b --- /dev/null +++ b/src/components/db-viewer/ConnectionDropBanner.tsx @@ -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 ( +
+
+ + {error} +
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/DataGrid.test.tsx b/src/components/db-viewer/DataGrid.test.tsx new file mode 100644 index 0000000..6488f65 --- /dev/null +++ b/src/components/db-viewer/DataGrid.test.tsx @@ -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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + const nullCell = screen.getByText("NULL"); + expect(nullCell).toHaveClass("italic"); + expect(nullCell).toHaveClass("text-text-muted"); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/DataGrid.tsx b/src/components/db-viewer/DataGrid.tsx new file mode 100644 index 0000000..636763c --- /dev/null +++ b/src/components/db-viewer/DataGrid.tsx @@ -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; +type TabColumnWidths = Record; + +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; + selectedRows: Set; + onSelectionChange: (selected: Set) => 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({}); + + // 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(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 ( +
+ Select a table to view data +
+ ); + } + + if (!activeTab) { + return ( +
+ Select a table to view data +
+ ); + } + + if (activeTab.loading && !activeTab.data) { + return ( +
+ Loading... +
+ ); + } + + if (activeTab.error) { + return ( +
+ {activeTab.error} +
+ ); + } + + if (!activeTab.data) { + return ( +
+ Loading table data... +
+ ); + } + + const { columns } = activeTab.data; + + // Filter visible columns + const visibleColumns = hiddenColumns + ? columns.filter((c) => !hiddenColumns.has(c.name)) + : columns; + + return ( +
+ {/* Loading indicator bar when refreshing with existing data */} + {activeTab.loading && ( +
+ )} +
+ + {/* Checkbox column */} + + {visibleColumns.map((col) => ( + + ))} + + + + {/* Header checkbox */} + + {visibleColumns.map((col) => ( + + ))} + + + + {rows.map((row, rowIndex) => { + const isSelected = selectedRows.has(rowIndex); + return ( + + {/* Row checkbox */} + + {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 ( + + ); + })} + + ); + })} + +
+
+ +
+
+
+ {col.is_pk && } + {col.is_fk && } + {col.name} + + {abbreviateType(col.data_type)} + +
+ {/* resize handle */} +
startResize(col.name, e)} + onDoubleClick={() => { + setColWidths((prev) => ({ + ...prev, + [activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH }, + })); + }} + /> +
+
+ toggleRow(rowIndex)} + className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" + /> +
+
+
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 ? ( + NULL + ) : isJson ? ( + + + {jp.label} + + ) : ( + String(cell) + )} +
+
+ {/* FK preview popover */} + {fkPreview && ( + setFkPreview(null)} + /> + )} + {/* JSON cell popover */} + {jsonPopover && ( + setJsonPopover(null)} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx new file mode 100644 index 0000000..1d3d1b0 --- /dev/null +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -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( {}} onSettings={() => {}} />); + expect(screen.getByLabelText(/home/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx new file mode 100644 index 0000000..37fc2fa --- /dev/null +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -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(null); + const [tablePanelWidth, setTablePanelWidth] = useState(280); + const [hiddenColumns, setHiddenColumns] = useState>(new Set()); + const [filterRules, setFilterRules] = useState([]); + const [sortRules, setSortRules] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const smartSortApplied = useRef>(new Set()); + const [selectedRows, setSelectedRows] = useState>(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>(new Set()); + + const fetchData = useCallback(async (tab: NonNullable) => { + 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 ( + +
+ +
+ {connectionError && connectionError !== dismissedError && ( + { + setDismissedError(null); + connect(); + }} + onDismiss={() => setDismissedError(connectionError)} + /> + )} +
+
+ setEditModalOpen(true)} + connectionId={connectionId} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> +
+ +
+
+ {/* panel resize handle */} +
setTablePanelWidth(280)} + /> +
+ + {activeTab?.data && ( + + 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())} + /> + )} +
+ +
+
+
+ +
+ {currentConnection && ( + setEditModalOpen(false)} + onSaved={() => {}} + /> + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerSidebar.test.tsx b/src/components/db-viewer/DbViewerSidebar.test.tsx new file mode 100644 index 0000000..3c2af31 --- /dev/null +++ b/src/components/db-viewer/DbViewerSidebar.test.tsx @@ -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( + + {}} /> + + ); + 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( + + + + ); + 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( + + + + ); + await user.click(screen.getByLabelText(/settings/i)); + expect(onNavigate).toHaveBeenCalledWith("settings"); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx new file mode 100644 index 0000000..ecfbfcc --- /dev/null +++ b/src/components/db-viewer/DbViewerSidebar.tsx @@ -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: }, + { id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: , stub: true }, + { id: "functions", label: "Functions coming soon", icon: , stub: true }, + { id: "triggers", label: "Triggers coming soon", icon: , stub: true }, + ]; + + const bottomItems: NavItem[] = [ + { id: "home", label: "Home", icon: }, + { id: "settings", label: "Settings", icon: }, + ]; + + 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 ( + + + + ); + } + + return ( +
+
{topItems.map(renderItem)}
+
{bottomItems.map(renderItem)}
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerToolbar.test.tsx b/src/components/db-viewer/DbViewerToolbar.test.tsx new file mode 100644 index 0000000..8c24814 --- /dev/null +++ b/src/components/db-viewer/DbViewerToolbar.test.tsx @@ -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( + + + , + ); + expect(screen.getByText("Tables")).toBeInTheDocument(); + }); + + it("renders database dropdown when multiple databases", () => { + render( + + + , + ); + expect(screen.getByText("mydb")).toBeInTheDocument(); + }); + + it("renders refresh and create table buttons", () => { + render( + + + , + ); + expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/create table/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerToolbar.tsx b/src/components/db-viewer/DbViewerToolbar.tsx new file mode 100644 index 0000000..14454a2 --- /dev/null +++ b/src/components/db-viewer/DbViewerToolbar.tsx @@ -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 | null>(null); + const searchInputRef = useRef(null); + const searchContainerRef = useRef(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 ( +
+
+ Tables +
+ {onEdit && ( + + + + )} + + + + + + + + + +
+
+ {/* Search input */} +
+
+ + 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 && ( + + )} +
+
+ {(databases.length > 1 || schemas.length > 1) && ( +
+ {databases.length > 1 && ( + ({ value: d, label: d }))} + placeholder="Select database" + aria-label="Select database" + variant="ghost" + /> + )} + {databases.length > 1 && schemas.length > 1 && ( + | + )} + {schemas.length > 1 && ( + ({ value: s, label: s }))} + placeholder="Select schema" + aria-label="Select schema" + variant="ghost" + /> + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/EditConnectionModal.tsx b/src/components/db-viewer/EditConnectionModal.tsx new file mode 100644 index 0000000..c991e35 --- /dev/null +++ b/src/components/db-viewer/EditConnectionModal.tsx @@ -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(() => ({ + 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 ( + +
+

Edit Connection

+ setForm((prev) => ({ ...prev, ...updates }))} /> +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/FkPreviewPopover.tsx b/src/components/db-viewer/FkPreviewPopover.tsx new file mode 100644 index 0000000..37df6a1 --- /dev/null +++ b/src/components/db-viewer/FkPreviewPopover.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const popoverRef = useRef(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( +
+ {/* Header */} +
+
+ + + {schema}.{table} + +
+
+ + +
+
+ + {/* Body */} +
+ {loading && ( +
+ + Loading... +
+ )} + {error && ( +
+ {error} +
+ )} + {data && data.rows.length === 0 && !loading && ( +
+ No matching row found +
+ )} + {data && data.rows.length > 0 && ( + + + {data.columns.map((col, ci) => { + const cell = data.rows[0][ci]; + const isNull = cell === null || cell === undefined; + return ( + + + + + ); + })} + +
+
+ {col.is_pk && } + {col.is_fk && } + {col.name} + + {abbreviateType(col.data_type)} + +
+
+ {isNull ? ( + NULL + ) : ( + {String(cell)} + )} +
+ )} +
+
, + document.body, + ); +} \ No newline at end of file diff --git a/src/components/db-viewer/JsonCellPopover.tsx b/src/components/db-viewer/JsonCellPopover.tsx new file mode 100644 index 0000000..2e6de40 --- /dev/null +++ b/src/components/db-viewer/JsonCellPopover.tsx @@ -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(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( +
+ {/* Header */} +
+
+ + JSON +
+
+ {/* Tabs */} +
+ + +
+ {/* Copy */} + + {/* Close */} + +
+
+ + {/* Body */} +
+
+          {tab === "formatted" ? formattedText : rawText}
+        
+
+
, + 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 }; +} \ No newline at end of file diff --git a/src/components/db-viewer/TabBar.test.tsx b/src/components/db-viewer/TabBar.test.tsx new file mode 100644 index 0000000..4dab48a --- /dev/null +++ b/src/components/db-viewer/TabBar.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/TabBar.tsx b/src/components/db-viewer/TabBar.tsx new file mode 100644 index 0000000..d1a7902 --- /dev/null +++ b/src/components/db-viewer/TabBar.tsx @@ -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 ( +
+ No tables open +
+ ); + } + + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ + +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/TableControls.tsx b/src/components/db-viewer/TableControls.tsx new file mode 100644 index 0000000..db27223 --- /dev/null +++ b/src/components/db-viewer/TableControls.tsx @@ -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 = {}; + 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(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 ( +
+ {children} +
+ ); +} + +function FilterModal({ + columns, + rules, + onChange, + open, + setOpen, +}: { + columns: ColumnInfo[]; + rules: FilterRule[]; + onChange: (rules: FilterRule[]) => void; + open: boolean; + setOpen: (v: boolean) => void; +}) { + const ref = useRef(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) => + onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + return ( +
+
+ Column Filters + +
+ {rules.map((rule) => ( +
+ + + {rule.operator !== "null" && rule.operator !== "notnull" && ( + 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" + /> + )} + +
+ ))} + +
+ ); +} + +function SortModal({ + columns, + rules, + onChange, + open, + setOpen, +}: { + columns: ColumnInfo[]; + rules: SortRule[]; + onChange: (rules: SortRule[]) => void; + open: boolean; + setOpen: (v: boolean) => void; +}) { + const ref = useRef(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) => + onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + return ( +
+
+ Sort Rules + +
+ {rules.map((rule) => ( +
+ + + +
+ ))} + +
+ ); +} + +// ─── 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 = {}; + 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 = {}; + 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 ( +
+ + + + + +
+ + +
+ ); +} + +// ─── main component ───────────────────────────────────── + +interface TableControlsProps { + connectionId: string; + schema: string; + table: string; + columns: ColumnInfo[]; + rows: unknown[][]; + hiddenColumns: Set; + 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) => { + if (activeTabId) setPageSize(activeTabId, Number(e.target.value)); + }; + + const handleInsertRow = () => { + const newData: Record = {}; + 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 ( +
+ {/* ── left side ──────────────────────────────── */} +
+ {/* Insert Row */} + + + + + {/* Refresh */} + + + + + {/* Auto-refresh */} +
+ 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom"> + + + + {AUTO_REFRESH_OPTIONS.map((opt) => ( + + ))} + +
+ +
+ + {/* Filter */} +
+ + + + +
+ + {/* Sort */} +
+ + + + +
+ + {/* Export */} +
+ + + + + {EXPORT_FORMATS.map((fmt) => ( + + ))} + +
+
+ + {/* ── spacer ──────────────────────────────────── */} +
+ + {/* ── right side ─────────────────────────────── */} +
+ {/* Action queue button */} +
+ + +
+ Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending) +
+
+ {changesQueue.length === 0 && ( +
No changes queued
+ )} + {changesQueue.map((item) => ( +
+ + + {item.type.toUpperCase()} {item.table} + {item.description && — {item.description}} + + {item.status === "pending" && ( + + )} +
+ ))} +
+
+
+ {/* Selected count + bulk actions */} + {selectedCount > 0 && ( + <> + + {selectedCount} selected + + + +
+ + )} + + {/* Columns toggle */} +
+ + + + +
Visible columns
+
+ {columns.map((col) => ( + + ))} +
+
+
+ +
+ + {/* Row count */} + + {startRow}-{endRow} of {totalRows} + + + {/* Page size */} + + + {/* Pagination */} +
+ + + {clampedPage}/{totalPages} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/TableOverflowMenu.test.tsx b/src/components/db-viewer/TableOverflowMenu.test.tsx new file mode 100644 index 0000000..94ab6b2 --- /dev/null +++ b/src/components/db-viewer/TableOverflowMenu.test.tsx @@ -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( "tab-1"} />); + expect(screen.getByLabelText(/table options/i)).toBeInTheDocument(); + }); + + it("shows menu options on click", async () => { + const user = userEvent.setup(); + render( "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(); + await user.click(screen.getByLabelText(/table options/i)); + await user.click(screen.getByText("Open in new tab")); + expect(onOpenTab).toHaveBeenCalledWith("public", "users", true); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/TableOverflowMenu.tsx b/src/components/db-viewer/TableOverflowMenu.tsx new file mode 100644 index 0000000..087fe21 --- /dev/null +++ b/src/components/db-viewer/TableOverflowMenu.tsx @@ -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(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 ( +
+ + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} + + {confirmAction === "empty" && ( + { + setConfirmAction(null); + }} + onCancel={() => setConfirmAction(null)} + /> + )} + {confirmAction === "delete" && ( + { + setConfirmAction(null); + }} + onCancel={() => setConfirmAction(null)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/TableTree.test.tsx b/src/components/db-viewer/TableTree.test.tsx new file mode 100644 index 0000000..7e99e03 --- /dev/null +++ b/src/components/db-viewer/TableTree.test.tsx @@ -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(); + 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(); + await user.click(screen.getByText("users")); + const state = useDbViewerStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" }); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/TableTree.tsx b/src/components/db-viewer/TableTree.tsx new file mode 100644 index 0000000..70c76b1 --- /dev/null +++ b/src/components/db-viewer/TableTree.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { useUiStore } from "../../stores/uiStore"; +import { TableOverflowMenu } from "./TableOverflowMenu"; +import { abbreviateType } from "../../lib/utils"; +import type { ColumnInfo } from "../../lib/types"; +import * as cmd from "../../lib/commands"; + +export function TableTree({ searchQuery }: { searchQuery?: string }) { + const tables = useDbViewerStore((s) => s.tables); + const currentSchema = useDbViewerStore((s) => s.currentSchema); + const openTab = useDbViewerStore((s) => s.openTab); + const connectionId = useUiStore((s) => s.activeConnectionId); + const [expanded, setExpanded] = useState>(new Set()); + const [columnCache, setColumnCache] = useState>({}); + + const q = (searchQuery ?? "").toLowerCase().trim(); + + const filteredTables = (currentSchema + ? tables.filter((t) => t.schema === currentSchema) + : tables).filter((t) => !q || t.name.toLowerCase().includes(q)); + + const toggle = async (key: string, schema: string, tableName: string) => { + const isExpanded = expanded.has(key); + setExpanded((prev) => { + const next = new Set(prev); + if (isExpanded) next.delete(key); + else next.add(key); + return next; + }); + // Fetch columns if not cached + if (!isExpanded && !columnCache[key] && connectionId) { + try { + const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0); + setColumnCache((prev) => ({ ...prev, [key]: result.columns })); + } catch { /* ignore, columns will remain unknowns */ } + } + }; + + const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => { + openTab(schema, table, forceNew); + return "tab"; + }; + + return ( +
+ {filteredTables.length === 0 && ( +
No tables
+ )} + {filteredTables.map((table) => { + const key = `${table.schema}.${table.name}`; + const isExpanded = expanded.has(key); + const cols = columnCache[key] ?? table.columns ?? []; + return ( +
+
openTab(table.schema, table.name)} + > + + + + {table.name} + +
e.stopPropagation()}> + +
+
+ {isExpanded && ( +
+ {cols.length === 0 && ( +
No columns
+ )} + {cols.map((col) => ( +
+ {col.is_pk ? ( + + ) : col.is_fk ? ( + + ) : ( + + )} + {col.name} + {abbreviateType(col.data_type)} +
+ ))} +
+ )} +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/folders/CreateFolderDialog.test.tsx b/src/components/folders/CreateFolderDialog.test.tsx new file mode 100644 index 0000000..ed9c743 --- /dev/null +++ b/src/components/folders/CreateFolderDialog.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { CreateFolderDialog } from "./CreateFolderDialog"; + +const folders = [ + { id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, +]; + +const sampleTags = [ + { id: "t1", name: "Production", color: "#ef4444", created_at: "", updated_at: "" }, + { id: "t2", name: "Staging", color: "#3b82f6", created_at: "", updated_at: "" }, +]; + +describe("CreateFolderDialog", () => { + it("calls onCreate with name, parent, and tags", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + await user.type(screen.getByPlaceholderText(/folder name/i), "New Folder"); + await user.click(screen.getByText(/create/i)); + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "New Folder", parent_id: null, tag_ids: [] })); + }); + + it("auto-sets parent to current folder", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + expect(screen.getByText("Work")).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText(/folder name/i), "Sub Folder"); + await user.click(screen.getByText(/create/i)); + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "Sub Folder", parent_id: "f1", tag_ids: [] })); + }); + + it("does not call onCreate when name empty", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + await user.click(screen.getByText(/create/i)); + expect(fn).not.toHaveBeenCalled(); + }); + + it("removes content from DOM after exit animation", async () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByText("New Folder")).toBeInTheDocument(); + rerender( + {}} />, + ); + await waitForElementToBeRemoved(() => screen.queryByText("New Folder")); + expect(screen.queryByText("New Folder")).not.toBeInTheDocument(); + }); + + it("calls onCreate when Escape is pressed", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("shows tags and includes selected tags in onCreate", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + expect(screen.getByPlaceholderText(/search tags/i)).toBeInTheDocument(); + expect(screen.getByText("Production")).toBeInTheDocument(); + expect(screen.getByText("Staging")).toBeInTheDocument(); + + await user.click(screen.getByText("Production")); + await user.type(screen.getByPlaceholderText(/folder name/i), "Tagged Folder"); + await user.click(screen.getByText(/create/i)); + + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Tagged Folder", + parent_id: null, + tag_ids: ["t1"], + }), + ); + }); +}); \ No newline at end of file diff --git a/src/components/folders/CreateFolderDialog.tsx b/src/components/folders/CreateFolderDialog.tsx new file mode 100644 index 0000000..06cd316 --- /dev/null +++ b/src/components/folders/CreateFolderDialog.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; +import type { Folder, Tag } from "../../lib/types"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Folder as FolderIcon } from "lucide-react"; +import { useNotificationStore } from "../../stores/notificationStore"; +import { SearchableTagPicker } from "../tags/SearchableTagPicker"; + +interface CreateFolderDialogProps { + open: boolean; + parentOptions: Folder[]; + currentFolderId?: string | null; + tags: Tag[]; + onCreate: (input: { name: string; parent_id: string | null; tag_ids: string[] }) => void; + onClose: () => void; +} + +export function CreateFolderDialog({ open, parentOptions, currentFolderId = null, tags, onCreate, onClose }: CreateFolderDialogProps) { + const [name, setName] = useState(""); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const inputRef = useRef(null); + const notify = useNotificationStore((s) => s.notify); + + const parentName = currentFolderId + ? parentOptions.find((f) => f.id === currentFolderId)?.name ?? null + : null; + + useEffect(() => { + if (open) { + setName(""); + setSelectedTagIds([]); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + const handleCreate = () => { + const trimmed = name.trim(); + if (!trimmed) { + notify("Name must not be empty", "error"); + return; + } + onCreate({ name: trimmed, parent_id: currentFolderId ?? null, tag_ids: selectedTagIds }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCreate(); + } + }; + + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId], + ); + }; + + return ( + +
+

New Folder

+ {parentName && ( +
+ + {parentName} +
+ )} + + {tags.length > 0 && ( + + )} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/folders/EditFolderDialog.test.tsx b/src/components/folders/EditFolderDialog.test.tsx new file mode 100644 index 0000000..abd8091 --- /dev/null +++ b/src/components/folders/EditFolderDialog.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { EditFolderDialog } from "./EditFolderDialog"; + +const folder = { + id: "f1", + name: "Work", + parent_id: null, + tag_ids: ["t1"], + created_at: "", + updated_at: "", +}; + +const tags = [{ id: "t1", name: "red", color: "#ff0000", created_at: "", updated_at: "" }]; + +describe("EditFolderDialog", () => { + it("calls onSave with updated name and tags", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + await user.clear(screen.getByPlaceholderText(/folder name/i)); + await user.type(screen.getByPlaceholderText(/folder name/i), "Work Updated"); + await user.click(screen.getByText(/save/i)); + expect(fn).toHaveBeenCalledWith("f1", expect.objectContaining({ name: "Work Updated", tag_ids: ["t1"] })); + }); + + it("does not call onSave when name is empty", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render( {}} />); + await user.clear(screen.getByPlaceholderText(/folder name/i)); + await user.click(screen.getByText(/save/i)); + expect(fn).not.toHaveBeenCalled(); + }); + + it("removes content from DOM after exit animation", async () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByText("Edit Folder")).toBeInTheDocument(); + rerender( {}} />); + await waitForElementToBeRemoved(() => screen.queryByText("Edit Folder")); + expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument(); + }); + + it("renders nothing when folder is null", () => { + render( {}} />); + expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument(); + }); + + it("calls onClose when Escape is pressed", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/folders/EditFolderDialog.tsx b/src/components/folders/EditFolderDialog.tsx new file mode 100644 index 0000000..655e99c --- /dev/null +++ b/src/components/folders/EditFolderDialog.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; +import type { Folder, Tag } from "../../lib/types"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { useNotificationStore } from "../../stores/notificationStore"; +import { SearchableTagPicker } from "../tags/SearchableTagPicker"; + +interface EditFolderDialogProps { + open: boolean; + folder: Folder | null; + tags: Tag[]; + onSave: (id: string, input: { name: string; tag_ids: string[] }) => void; + onClose: () => void; +} + +export function EditFolderDialog({ open, folder, tags, onSave, onClose }: EditFolderDialogProps) { + const [name, setName] = useState(""); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const inputRef = useRef(null); + const notify = useNotificationStore((s) => s.notify); + + useEffect(() => { + if (open && folder) { + setName(folder.name); + setSelectedTagIds(folder.tag_ids); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open, folder]); + + const handleSave = () => { + if (!folder) return; + const trimmed = name.trim(); + if (!trimmed) { + notify("Name must not be empty", "error"); + return; + } + onSave(folder.id, { name: trimmed, tag_ids: selectedTagIds }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + }; + + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId], + ); + }; + + return ( + + {folder ? ( +
+

Edit Folder

+ + {tags.length > 0 && ( + + )} +
+ + +
+
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/src/components/folders/FolderBreadcrumb.test.tsx b/src/components/folders/FolderBreadcrumb.test.tsx new file mode 100644 index 0000000..e44d75d --- /dev/null +++ b/src/components/folders/FolderBreadcrumb.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FolderBreadcrumb } from "./FolderBreadcrumb"; +import type { Folder } from "../../lib/types"; + +const folders: Folder[] = [ + { id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" }, +]; + +describe("FolderBreadcrumb", () => { + it("shows root when no folder active", () => { + render( {}} />); + expect(screen.getByText("All Connections")).toBeInTheDocument(); + }); + + it("shows path to active folder", () => { + render( {}} />); + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.getByText("Client A")).toBeInTheDocument(); + }); + + it("navigates when clicking a breadcrumb item", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("Work")); + expect(fn).toHaveBeenCalledWith("f1"); + }); + + it("navigates to root", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("All Connections")); + expect(fn).toHaveBeenCalledWith(null); + }); +}); \ No newline at end of file diff --git a/src/components/folders/FolderBreadcrumb.tsx b/src/components/folders/FolderBreadcrumb.tsx new file mode 100644 index 0000000..0048cac --- /dev/null +++ b/src/components/folders/FolderBreadcrumb.tsx @@ -0,0 +1,40 @@ +import type { Folder } from "../../lib/types"; +import { ChevronRight, Home } from "lucide-react"; +import { getFolderPath } from "../../lib/utils"; + +interface FolderBreadcrumbProps { + folders: Folder[]; + activeFolderId: string | null; + onNavigate: (folderId: string | null) => void; +} + +export function FolderBreadcrumb({ folders, activeFolderId, onNavigate }: FolderBreadcrumbProps) { + const path = getFolderPath(folders, activeFolderId); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/folders/FolderTree.test.tsx b/src/components/folders/FolderTree.test.tsx new file mode 100644 index 0000000..3f0b46e --- /dev/null +++ b/src/components/folders/FolderTree.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FolderTree } from "./FolderTree"; +import type { Folder } from "../../lib/types"; + +const folders: Folder[] = [ + { id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "ClientA", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" }, +]; + +describe("FolderTree", () => { + it("renders all folders", () => { + render( {}} />); + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.getByText("ClientA")).toBeInTheDocument(); + }); + + it("renders All Connections option that clears filter", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render(); + await user.click(screen.getByText(/all connections/i)); + expect(fn).toHaveBeenCalledWith(null); + }); + + it("selecting a folder calls onSelect with id", async () => { + const user = userEvent.setup(); + const fn = vi.fn(); + render(); + await user.click(screen.getByText("Work")); + expect(fn).toHaveBeenCalledWith("f1"); + }); +}); \ No newline at end of file diff --git a/src/components/folders/FolderTree.tsx b/src/components/folders/FolderTree.tsx new file mode 100644 index 0000000..dad8c63 --- /dev/null +++ b/src/components/folders/FolderTree.tsx @@ -0,0 +1,41 @@ +import type { Folder } from "../../lib/types"; +import { ChevronRight, Folder as FolderIcon } from "lucide-react"; + +interface FolderTreeProps { + folders: Folder[]; + activeFolderId: string | null; + onSelect: (id: string | null) => void; +} + +export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProps) { + const roots = folders.filter((f) => f.parent_id === null); + const childrenOf = (id: string) => folders.filter((f) => f.parent_id === id); + + const renderFolder = (folder: Folder, depth: number) => { + const isActive = activeFolderId === folder.id; + return ( +
+ + {childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))} +
+ ); + }; + + return ( +
+ + {roots.map((r) => renderFolder(r, 0))} +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/ActionRow.test.tsx b/src/components/layout/ActionRow.test.tsx new file mode 100644 index 0000000..cda3d46 --- /dev/null +++ b/src/components/layout/ActionRow.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ActionRow } from "./ActionRow"; +import { useUiStore } from "../../stores/uiStore"; + +beforeEach(() => useUiStore.setState({ activeView: "home" })); + +describe("ActionRow", () => { + it("renders Saved Connections title", () => { + render(); + expect(screen.getByText("Saved Connections")).toBeInTheDocument(); + }); + it("New Connection button switches view", async () => { + render(); + await userEvent.click(screen.getByText(/new connection/i)); + expect(useUiStore.getState().activeView).toBe("new-connection"); + }); + it("Settings button switches view", async () => { + render(); + await userEvent.click(screen.getByText(/settings/i)); + expect(useUiStore.getState().activeView).toBe("settings"); + }); + it("Tags button switches to settings view", async () => { + render(); + await userEvent.click(screen.getByText(/^tags$/i)); + expect(useUiStore.getState().activeView).toBe("settings"); + }); +}); \ No newline at end of file diff --git a/src/components/layout/ActionRow.tsx b/src/components/layout/ActionRow.tsx new file mode 100644 index 0000000..351737f --- /dev/null +++ b/src/components/layout/ActionRow.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, Settings as SettingsIcon, Tag, Filter, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react"; +import { Button } from "../ui/Button"; +import { useUiStore } from "../../stores/uiStore"; +import { ImportExportMenu } from "./ImportExportMenu"; + +interface ActionRowProps { + onImport?: () => void; + onExport?: () => void; + onNewFolder?: () => void; + onFilters?: () => void; + onDeleteSelected?: () => void; + visibleItemIds?: string[]; +} + +export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) { + const setActiveView = useUiStore((s) => s.setActiveView); + const selectedItemIds = useUiStore((s) => s.selectedItemIds); + const selectAllItems = useUiStore((s) => s.selectAllItems); + const clearSelection = useUiStore((s) => s.clearSelection); + const hasSelection = selectedItemIds.length > 0; + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [menuOpen]); + + return ( +
+
+

Saved Connections

+
+ +
+
+
+ + + + {hasSelection && ( +
+ + {menuOpen && ( +
+ + +
+ +
+ )} +
+ )} +
+
+ {})} onExport={onExport ?? (() => {})} /> + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/HomeScreen.test.tsx b/src/components/layout/HomeScreen.test.tsx new file mode 100644 index 0000000..ef44c5e --- /dev/null +++ b/src/components/layout/HomeScreen.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HomeScreen } from "./HomeScreen"; +import { useConnectionStore } from "../../stores/connectionStore"; +import { useUiStore } from "../../stores/uiStore"; + +vi.mock("../../lib/commands", () => ({ + getConnections: vi.fn().mockResolvedValue([]), + getFolders: vi.fn().mockResolvedValue([]), + getTags: vi.fn().mockResolvedValue([]), + getSettings: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() })); +vi.mock("@tauri-apps/plugin-fs", () => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), +})); + +describe("HomeScreen", () => { + beforeEach(() => { + useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null }); + useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home" }); + }); + + it("renders SearchBar and ActionRow", () => { + render(); + expect(screen.getByPlaceholderText(/search connections/i)).toBeInTheDocument(); + expect(screen.getByText("New Connection")).toBeInTheDocument(); + }); + + it("renders empty state when no connections", () => { + useConnectionStore.setState({ connections: [] }); + render(); + expect(screen.getByText(/no connections yet/i)).toBeInTheDocument(); + }); + + it("opens new connection screen when a connection string is typed in search", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText(/search connections/i); + await user.type(input, "postgresql://user:pass@localhost:5432/mydb"); + expect(useUiStore.getState().activeView).toBe("new-connection"); + expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb"); + expect(useUiStore.getState().searchQuery).toBe(""); + }); +}); \ No newline at end of file diff --git a/src/components/layout/HomeScreen.tsx b/src/components/layout/HomeScreen.tsx new file mode 100644 index 0000000..d641d64 --- /dev/null +++ b/src/components/layout/HomeScreen.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useConnectionStore } from "../../stores/connectionStore"; +import { useUiStore } from "../../stores/uiStore"; +import { useFilteredConnections } from "../../hooks/useConnections"; +import { useSortedTags } from "../../hooks/useSortedTags"; +import { SearchBar } from "../search/SearchBar"; +import type { SearchBarHandle } from "../search/SearchBar"; +import { ActionRow } from "./ActionRow"; +import { ConnectionGrid } from "../connections/ConnectionGrid"; +import { CreateFolderDialog } from "../folders/CreateFolderDialog"; +import { EditFolderDialog } from "../folders/EditFolderDialog"; +import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { handleImport, handleExport } from "../../lib/importExport"; +import { getChildFolders } from "../../lib/utils"; +import { useShortcut } from "../../hooks/useShortcut"; +import type { Folder } from "../../lib/types"; + +export function HomeScreen() { + const connections = useFilteredConnections(); + const tags = useSortedTags(); + const folders = useConnectionStore((s) => s.folders); + const activeFolderId = useUiStore((s) => s.activeFolderId); + const setActiveFolderId = useUiStore((s) => s.setActiveFolderId); + const searchQuery = useUiStore((s) => s.searchQuery); + const toggleTag = useUiStore((s) => s.toggleTag); + const createFolder = useConnectionStore((s) => s.createFolder); + const updateFolder = useConnectionStore((s) => s.updateFolder); + const deleteFolder = useConnectionStore((s) => s.deleteFolder); + const deleteConnection = useConnectionStore((s) => s.deleteConnection); + const loadAll = useConnectionStore((s) => s.loadAll); + const selectedItemIds = useUiStore((s) => s.selectedItemIds); + const clearSelection = useUiStore((s) => s.clearSelection); + const [folderDialogOpen, setFolderDialogOpen] = useState(false); + const [editFolder, setEditFolder] = useState(null); + const [confirmDelete, setConfirmDelete] = useState<{ + type: "folder" | "selected"; + folder?: Folder; + } | null>(null); + const searchRef = useRef(null); + const setSearchQuery = useUiStore((s) => s.setSearchQuery); + const setPrefilledConnectionString = useUiStore( + (s) => s.setPrefilledConnectionString, + ); + const setActiveView = useUiStore((s) => s.setActiveView); + const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId); + + const handleOpenDbViewer = (connectionId: string) => { + setActiveConnectionId(connectionId); + setActiveView("db-viewer"); + }; + + const handleSearchUrl = (url: string) => { + setSearchQuery(""); + setPrefilledConnectionString(url); + setActiveView("new-connection"); + }; + + // Cmd+K to focus search (configurable in Settings → Shortcuts) + useShortcut("command_palette", () => { + searchRef.current?.focus(); + }); + + const currentFolderId = + activeFolderId !== null && folders.some((f) => f.id === activeFolderId) + ? activeFolderId + : null; + const visibleFolderIds = useMemo( + () => getChildFolders(folders, currentFolderId).map((f) => f.id), + [folders, currentFolderId], + ); + const visibleConnectionIds = useMemo( + () => + connections + .filter((c) => c.folder_id === currentFolderId) + .map((c) => c.id), + [connections, currentFolderId], + ); + const visibleItemIds = useMemo( + () => [...visibleFolderIds, ...visibleConnectionIds], + [visibleFolderIds, visibleConnectionIds], + ); + + // Reset to root if the active folder no longer exists + useEffect(() => { + if ( + activeFolderId !== null && + !folders.some((f) => f.id === activeFolderId) + ) { + setActiveFolderId(null); + } + }, [folders, activeFolderId, setActiveFolderId]); + + const executeDeleteSelected = async () => { + const folderIds = new Set(folders.map((f) => f.id)); + for (const id of selectedItemIds) { + try { + if (folderIds.has(id)) { + await deleteFolder(id); + } else { + await deleteConnection(id); + } + } catch (e) { + console.error("Failed to delete item:", e); + } + } + clearSelection(); + setConfirmDelete(null); + }; + + const executeDeleteFolder = async (folder: Folder) => { + try { + await deleteFolder(folder.id); + if (activeFolderId === folder.id) { + setActiveFolderId(null); + } + } catch (e) { + console.error("Failed to delete folder:", e); + } + setConfirmDelete(null); + }; + + return ( +
+
+ +
+
+ setFolderDialogOpen(true)} + onImport={async () => { + const r = await handleImport(); + if (r) await loadAll(); + }} + onExport={async () => { + await handleExport(); + }} + onDeleteSelected={() => + setConfirmDelete({ type: "selected" }) + } + visibleItemIds={visibleItemIds} + /> +
+ 0} + onTagToggle={toggleTag} + onOpenDbViewer={handleOpenDbViewer} + onEditFolder={(f) => setEditFolder(f)} + onDeleteFolder={(f) => + setConfirmDelete({ type: "folder", folder: f }) + } + /> + { + try { + await createFolder(input); + } catch (e) { + console.error("Failed to create folder:", e); + } + setFolderDialogOpen(false); + }} + onClose={() => setFolderDialogOpen(false)} + /> + { + try { + const folder = folders.find((f) => f.id === id); + await updateFolder(id, { + name: input.name, + parent_id: folder?.parent_id ?? null, + tag_ids: input.tag_ids, + }); + } catch (e) { + console.error("Failed to update folder:", e); + } + setEditFolder(null); + }} + onClose={() => setEditFolder(null)} + /> + {confirmDelete?.type === "selected" && ( + setConfirmDelete(null)} + /> + )} + {confirmDelete?.type === "folder" && confirmDelete.folder && ( + executeDeleteFolder(confirmDelete.folder!)} + onCancel={() => setConfirmDelete(null)} + /> + )} +
+ ); +} diff --git a/src/components/layout/ImportExportMenu.tsx b/src/components/layout/ImportExportMenu.tsx new file mode 100644 index 0000000..a6241f1 --- /dev/null +++ b/src/components/layout/ImportExportMenu.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "react"; +import { Download, Upload, ChevronDown } from "lucide-react"; +import { Button } from "../ui/Button"; + +interface ImportExportMenuProps { + onImport: () => void; + onExport: () => void; +} + +export function ImportExportMenu({ onImport, onExport }: ImportExportMenuProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( +
+ + {open && ( +
+ + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/search/SearchBar.test.tsx b/src/components/search/SearchBar.test.tsx new file mode 100644 index 0000000..bfd3c5b --- /dev/null +++ b/src/components/search/SearchBar.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { SearchBar } from "./SearchBar"; +import { useUiStore } from "../../stores/uiStore"; + +beforeEach(() => { useUiStore.setState({ searchQuery: "" }); vi.useFakeTimers(); }); +afterEach(() => vi.useRealTimers()); + +describe("SearchBar", () => { + it("renders a search input", () => { + render(); + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument(); + }); + it("updates store after 150ms debounce", () => { + render(); + const input = screen.getByPlaceholderText(/search/i); + fireEvent.change(input, { target: { value: "prod" } }); + act(() => vi.advanceTimersByTime(149)); + expect(useUiStore.getState().searchQuery).toBe(""); + act(() => vi.advanceTimersByTime(1)); + expect(useUiStore.getState().searchQuery).toBe("prod"); + }); + + it("calls onDetectUrl when a connection string is typed", () => { + const onDetectUrl = vi.fn(); + render(); + const input = screen.getByPlaceholderText(/search/i); + fireEvent.change(input, { target: { value: "postgresql://user@host/db" } }); + expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db"); + }); +}); \ No newline at end of file diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..cb59bd3 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,50 @@ +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { Search, Command } from "lucide-react"; +import { Input } from "../ui/Input"; +import { useUiStore } from "../../stores/uiStore"; +import { looksLikeConnectionString } from "../../lib/connectionString"; + +export interface SearchBarHandle { + focus: () => void; +} + +interface SearchBarProps { + onDetectUrl?: (url: string) => void; +} + +export const SearchBar = forwardRef(function SearchBar({ onDetectUrl }, ref) { + const [value, setValue] = useState(""); + const setSearchQuery = useUiStore((s) => s.setSearchQuery); + const inputRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + })); + + return ( +
+
+ + { + setValue(v); + if (looksLikeConnectionString(v)) { + onDetectUrl?.(v); + return; + } + window.clearTimeout((window as any).__sb); + (window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150); + }} + className="pl-10 pr-14" + /> +
+ + K +
+
+
+ ); +}); \ No newline at end of file diff --git a/src/components/settings/AdvancedSettingsTab.tsx b/src/components/settings/AdvancedSettingsTab.tsx new file mode 100644 index 0000000..34724ed --- /dev/null +++ b/src/components/settings/AdvancedSettingsTab.tsx @@ -0,0 +1,69 @@ +import { useSettingsStore } from "../../stores/settingsStore"; +import { Input } from "../ui/Input"; +import { Toggle } from "../ui/Toggle"; +import { SettingsRow } from "../ui/SettingsRow"; +import { SettingsSection } from "../ui/SettingsSection"; +import type { DbType } from "../../lib/types"; + +const DB_TYPES: { id: DbType; label: string }[] = [ + { id: "postgresql", label: "PostgreSQL" }, + { id: "mysql", label: "MySQL" }, + { id: "sqlite", label: "SQLite" }, + { id: "redis", label: "Redis" }, +]; + +export function AdvancedSettingsTab() { + const { settings, updateSetting } = useSettingsStore(); + + if (!settings) return null; + + const defaultPorts = settings.default_ports ?? { + postgresql: 5432, + mysql: 3306, + sqlite: null, + redis: 6379, + }; + + const updatePort = (key: string, value: string) => { + const port = value === "" ? null : Number(value); + const next = { ...defaultPorts, [key]: port }; + updateSetting("default_ports", JSON.stringify(next)); + }; + + return ( + <> + + + + updateSetting("confirm_before_delete", checked ? "true" : "false") + } + label="Confirm before delete" + /> + + + + + {DB_TYPES.map((db) => ( + + updatePort(db.id, value)} + className="w-24" + aria-label={`Default port for ${db.label}`} + /> + + ))} + + + ); +} \ No newline at end of file diff --git a/src/components/settings/GeneralSettingsTab.tsx b/src/components/settings/GeneralSettingsTab.tsx new file mode 100644 index 0000000..cc8ba79 --- /dev/null +++ b/src/components/settings/GeneralSettingsTab.tsx @@ -0,0 +1,129 @@ +import { useSettingsStore } from "../../stores/settingsStore"; +import { useConnectionStore } from "../../stores/connectionStore"; +import { Select } from "../ui/Select"; +import { ThemePicker } from "../ui/ThemePicker"; +import { SettingsRow } from "../ui/SettingsRow"; +import { SettingsSection } from "../ui/SettingsSection"; +import * as cmd from "../../lib/commands"; +import type { FontSize } from "../../lib/types"; + +const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [ + { value: "small", label: "Small" }, + { value: "medium", label: "Medium" }, + { value: "large", label: "Large" }, +]; + +const REFRESH_RATE_OPTIONS = [ + { value: "0", label: "Off" }, + { value: "5000", label: "5 seconds" }, + { value: "10000", label: "10 seconds" }, + { value: "30000", label: "30 seconds" }, + { value: "60000", label: "1 minute" }, + { value: "300000", label: "5 minutes" }, +]; + +const PAGE_SIZE_OPTIONS = [ + { value: "50", label: "50 rows" }, + { value: "100", label: "100 rows" }, + { value: "200", label: "200 rows" }, + { value: "500", label: "500 rows" }, +]; + +export function GeneralSettingsTab() { + const { settings, updateSetting, load } = useSettingsStore(); + const folders = useConnectionStore((s) => s.folders); + + if (!settings) return null; + + const folderOptions = [ + { value: "", label: "None" }, + ...folders.map((f) => ({ value: f.id, label: f.name })), + ]; + + const handleReAddDemo = async () => { + try { + await cmd.recreateDemoDb(); + await load(); + } catch (e) { + // ignore + } + }; + + return ( + <> + + + updateSetting("theme", theme)} + /> + + + + + + updateSetting("default_folder_id", value)} + options={folderOptions} + label="Default folder" + /> + + + + + + updateSetting("table_page_size", value)} + options={PAGE_SIZE_OPTIONS} + label="Rows per page" + /> + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx new file mode 100644 index 0000000..46ff80f --- /dev/null +++ b/src/components/settings/SettingsPage.test.tsx @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SettingsPage } from "./SettingsPage"; +import { GeneralSettingsTab } from "./GeneralSettingsTab"; +import { TagsSettingsTab } from "./TagsSettingsTab"; +import { AdvancedSettingsTab } from "./AdvancedSettingsTab"; +import { useSettingsStore } from "../../stores/settingsStore"; +import { useConnectionStore } from "../../stores/connectionStore"; +import * as commands from "../../lib/commands"; + +vi.mock("motion/react", () => ({ + motion: { + div: React.forwardRef((props: any, ref: any) => ( +
+ )), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +vi.mock("../../lib/commands", () => ({ + getSettings: vi.fn().mockResolvedValue({ + theme: "system", + font_size: "medium", + default_folder_id: null, + confirm_before_delete: true, + default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 }, + tag_order: null, + }), + updateSetting: vi.fn().mockResolvedValue(undefined), + getConnections: vi.fn().mockResolvedValue([]), + getFolders: vi.fn().mockResolvedValue([]), + getTags: vi.fn().mockResolvedValue([]), + createConnection: vi.fn().mockResolvedValue({}), + deleteConnection: vi.fn().mockResolvedValue(undefined), + addConnectionTags: vi.fn().mockResolvedValue(undefined), + createFolder: vi.fn().mockResolvedValue({}), + updateFolder: vi.fn().mockResolvedValue({}), + deleteFolder: vi.fn().mockResolvedValue(undefined), + addFolderTags: vi.fn().mockResolvedValue(undefined), + createTag: vi.fn().mockResolvedValue({}), + updateTag: vi.fn().mockResolvedValue({}), + deleteTag: vi.fn().mockResolvedValue(undefined), + importConnections: vi.fn().mockResolvedValue({ imported: 0, skipped: 0, skippedRecords: [] }), + exportConnections: vi.fn().mockResolvedValue(""), +})); + +const mockFolders = [ + { id: "folder-1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "folder-2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, +]; + +const baseSettings = { + theme: "system" as const, + font_size: "medium" as const, + default_folder_id: null, + confirm_before_delete: true, + default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 }, + tag_order: null, + table_refresh_rate: 0, + table_page_size: 50, + shortcuts: {} as Record, +}; + +describe("SettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ + settings: baseSettings, + loading: false, + error: null, + }); + useConnectionStore.setState({ + connections: [], + folders: mockFolders, + tags: [], + tagOrder: [], + loading: false, + error: null, + }); + }); + + it("renders settings header with back button and title", async () => { + render(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument(); + }); + expect(screen.getByText(/back/i)).toBeInTheDocument(); + }); + + it("renders all five sidebar tabs with proper ARIA roles", async () => { + render(); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /general/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + }); + + it("shows the General tab by default and marks it selected", async () => { + render(); + await waitFor(() => { + expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general"); + }); + + it("switches to the Editor tab and shows placeholder", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("tab", { name: /editor/i })); + expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true"); + }); + + it("switches to the Tags tab and shows accessible tag management", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("tab", { name: /tags/i })); + expect(screen.getByText(/create tag/i)).toBeInTheDocument(); + expect(screen.getByText(/manage tags/i)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags"); + }); + + it("switches to the Shortcuts tab and shows keyboard shortcuts", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("tab", { name: /shortcuts/i })); + expect(screen.getByText(/command palette/i)).toBeInTheDocument(); + }); + + it("switches to the Advanced tab and shows safety and ports settings", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("tab", { name: /advanced/i })); + expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument(); + expect(screen.getByText(/default ports/i)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /advanced/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-advanced"); + }); + + it("calls updateSetting with the correct key and value when a setting changes", async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("radio", { name: /dark/i })); + await waitFor(() => { + expect(commands.updateSetting).toHaveBeenCalledWith("theme", "dark"); + }); + }); +}); + +describe("GeneralSettingsTab", () => { + beforeEach(() => { + useSettingsStore.setState({ + settings: baseSettings, + loading: false, + error: null, + }); + useConnectionStore.setState({ + connections: [], + folders: mockFolders, + tags: [], + tagOrder: [], + loading: false, + error: null, + }); + }); + + it("renders appearance, interface, and workspace sections", () => { + render(); + expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/font size/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument(); + }); +}); + +describe("TagsSettingsTab", () => { + beforeEach(() => { + useConnectionStore.setState({ + connections: [], + folders: [], + tags: [ + { id: "tag-1", name: "Production", color: "#ef4444", created_at: "" }, + { id: "tag-2", name: "Staging", color: "#3b82f6", created_at: "" }, + ], + tagOrder: ["tag-1", "tag-2"], + loading: false, + error: null, + }); + }); + + it("renders tag creation and management controls with accessible labels", () => { + render(); + expect(screen.getByLabelText(/new tag name/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument(); + expect(screen.getByText(/production/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete tag production/i })).toBeInTheDocument(); + const moveUpButtons = screen.getAllByRole("button", { name: /move tag up/i }); + expect(moveUpButtons.length).toBeGreaterThanOrEqual(2); + const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i }); + expect(moveDownButtons.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("AdvancedSettingsTab", () => { + beforeEach(() => { + useSettingsStore.setState({ + settings: baseSettings, + loading: false, + error: null, + }); + }); + + it("renders safety toggle and labeled default port inputs", () => { + render(); + expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/default port for postgresql/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/default port for mysql/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/default port for sqlite/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/default port for redis/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..c568536 --- /dev/null +++ b/src/components/settings/SettingsPage.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { useSettingsStore } from "../../stores/settingsStore"; +import { useUiStore } from "../../stores/uiStore"; +import { Button } from "../ui/Button"; +import { SettingsSection } from "../ui/SettingsSection"; +import { GeneralSettingsTab } from "./GeneralSettingsTab"; +import { TagsSettingsTab } from "./TagsSettingsTab"; +import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab"; +import { AdvancedSettingsTab } from "./AdvancedSettingsTab"; +import { + ChevronLeft, + Cog, + Keyboard, + Paintbrush, + Settings, + Tag as TagIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; + +type SettingsTab = "general" | "editor" | "tags" | "shortcuts" | "advanced"; + +interface TabDefinition { + id: SettingsTab; + label: string; + icon: ComponentType<{ size?: number }>; +} + +const TABS: TabDefinition[] = [ + { id: "general", label: "General", icon: Settings }, + { id: "editor", label: "Editor", icon: Paintbrush }, + { id: "tags", label: "Tags", icon: TagIcon }, + { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "advanced", label: "Advanced", icon: Cog }, +]; + +export function SettingsPage() { + const setActiveView = useUiStore((s) => s.setActiveView); + const { load } = useSettingsStore(); + + const [activeTab, setActiveTab] = useState("general"); + + useEffect(() => { + load(); + }, [load]); + + const renderEditor = () => ( + +
+ Editor settings are coming soon. +
+
+ ); + + const renderTabContent = () => { + switch (activeTab) { + case "general": + return ; + case "editor": + return renderEditor(); + case "tags": + return ; + case "shortcuts": + return ; + case "advanced": + return ; + } + }; + + return ( +
+
+ {/* Sidebar */} + + + {/* Main content */} +
+

+ Settings +

+ + + {renderTabContent()} + + +
+
+
+ ); +} diff --git a/src/components/settings/ShortcutsSettingsTab.tsx b/src/components/settings/ShortcutsSettingsTab.tsx new file mode 100644 index 0000000..6c9d88c --- /dev/null +++ b/src/components/settings/ShortcutsSettingsTab.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect, useRef } from "react"; +import { Pencil } from "lucide-react"; +import { SettingsSection } from "../ui/SettingsSection"; +import { useSettingsStore } from "../../stores/settingsStore"; + +type ShortcutDef = { + id: string; + description: string; + defaultKeys: string; + defaultWin: string; +}; + +const SHORTCUTS: ShortcutDef[] = [ + { + id: "command_palette", + description: "Open command palette / search", + defaultKeys: "⌘K", + defaultWin: "Ctrl+K", + }, + { + id: "close_tab", + description: "Close current tab (or return home if none)", + defaultKeys: "⌘W", + defaultWin: "Ctrl+W", + }, +]; + +const STATIC_SHORTCUTS = [ + { description: "Close modal, dropdown, or popover", keys: ["Esc"] }, + { description: "Submit / confirm in dialogs", keys: ["↵ Enter"] }, + { description: "Follow foreign-key link on focused cell", keys: ["↵ Enter", "Space"] }, + { description: "Toggle row selection", keys: ["Click"] }, + { description: "Select / deselect all visible rows", keys: ["Header ☐"] }, +]; + +function displayCombo(combo: string): string { + return combo + .replace(/meta/gi, "⌘") + .replace(/ctrl/gi, "⌃") + .replace(/shift/gi, "⇧") + .replace(/alt/gi, "⌥") + .replace(/\+/g, "") + .replace(/\b(\w)\b/g, (_, c) => c.toUpperCase()); +} + +function Kbd({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function ShortcutsSettingsTab() { + const settings = useSettingsStore((s) => s.settings); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const customShortcuts = settings?.shortcuts ?? {}; + const [recording, setRecording] = useState(null); + const recordingRef = useRef(null); + + // Listen for keypress when recording + useEffect(() => { + if (!recording) return; + recordingRef.current = recording; + + const handler = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + const parts: string[] = []; + if (e.metaKey) parts.push("Meta"); + if (e.ctrlKey) parts.push("Ctrl"); + if (e.altKey) parts.push("Alt"); + if (e.shiftKey) parts.push("Shift"); + // Ignore modifier-only presses + if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return; + parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key); + const combo = parts.join("+"); + + const action = recordingRef.current; + if (!action) return; + const next = { ...customShortcuts, [action]: combo }; + const existing = { ...(settings?.shortcuts ?? {}) }; + updateSetting("shortcuts", JSON.stringify({ ...existing, ...next })); + setRecording(null); + }; + + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [recording, customShortcuts, settings?.shortcuts, updateSetting]); + + const handleStartRecord = (action: string) => { + setRecording(action); + }; + + const handleReset = (action: string) => { + const next = { ...customShortcuts }; + delete next[action]; + const existing = { ...(settings?.shortcuts ?? {}) }; + updateSetting("shortcuts", JSON.stringify({ ...existing, ...next, [action]: undefined as any })); + }; + + return ( + <> + +

+ Click the pencil icon to record a new key combination. Click the shortcut to reset to default. +

+
+ {SHORTCUTS.map((s) => { + const custom = customShortcuts[s.id]; + const isRecording = recording === s.id; + return ( +
+ {s.description} +
+ {isRecording ? ( + Listening… + ) : custom ? ( + + ) : ( + {s.defaultKeys} + )} + +
+
+ ); + })} +
+
+ + +

+ These shortcuts are standard across all applications and cannot be changed. +

+
+ {STATIC_SHORTCUTS.map((s) => ( +
+ {s.description} +
+ {s.keys.map((key, i) => ( + {key} + ))} +
+
+ ))} +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/settings/TagsSettingsTab.tsx b/src/components/settings/TagsSettingsTab.tsx new file mode 100644 index 0000000..fbcf267 --- /dev/null +++ b/src/components/settings/TagsSettingsTab.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { useConnectionStore } from "../../stores/connectionStore"; +import { useNotificationStore } from "../../stores/notificationStore"; +import { useSortedTags } from "../../hooks/useSortedTags"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { SettingsSection } from "../ui/SettingsSection"; +import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react"; +import type { Tag } from "../../lib/types"; + +const TAG_COLORS = [ + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#06b6d4", + "#3b82f6", + "#8b5cf6", + "#d946ef", + "#ec4899", + "#78716c", +]; + +export function TagsSettingsTab() { + const tags = useSortedTags(); + const tagOrder = useConnectionStore((s) => s.tagOrder); + const setTagOrder = useConnectionStore((s) => s.setTagOrder); + const createTag = useConnectionStore((s) => s.createTag); + const updateTag = useConnectionStore((s) => s.updateTag); + const deleteTag = useConnectionStore((s) => s.deleteTag); + const notify = useNotificationStore((s) => s.notify); + + const [newName, setNewName] = useState(""); + const [newColor, setNewColor] = useState(TAG_COLORS[0]); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editColor, setEditColor] = useState(""); + + const handleCreateTag = async () => { + const trimmed = newName.trim(); + if (!trimmed) { + notify("Tag name must not be empty", "error"); + return; + } + try { + await createTag({ name: trimmed, color: newColor }); + setNewName(""); + setNewColor(TAG_COLORS[0]); + } catch (e) { + notify(`Failed to create tag: ${e}`, "error"); + } + }; + + const handleUpdateTag = async (id: string) => { + const trimmed = editName.trim(); + if (!trimmed) { + notify("Tag name must not be empty", "error"); + return; + } + try { + await updateTag(id, { name: trimmed, color: editColor }); + setEditingId(null); + } catch (e) { + notify(`Failed to update tag: ${e}`, "error"); + } + }; + + const handleDeleteTag = async (id: string, name: string) => { + try { + await deleteTag(id); + notify(`Deleted tag "${name}"`, "info"); + } catch (e) { + notify(`Failed to delete tag: ${e}`, "error"); + } + }; + + const handleMoveTag = async (index: number, direction: "up" | "down") => { + const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id); + const newOrder = [...currentOrder]; + const swapIndex = direction === "up" ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= newOrder.length) return; + [newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]]; + try { + await setTagOrder(newOrder); + } catch (e) { + notify(`Failed to reorder tags: ${e}`, "error"); + } + }; + + const startEdit = (tag: Tag) => { + setEditingId(tag.id); + setEditName(tag.name); + setEditColor(tag.color); + }; + + return ( + <> + +
+ +
+ {TAG_COLORS.map((color) => ( +
+ +
+
+ + + {tags.length === 0 ? ( +
+ No tags yet. Create one above. +
+ ) : ( +
+ {tags.map((tag, index) => { + const isEditing = editingId === tag.id; + return ( +
+
+ + +
+ + {isEditing ? ( + <> + +
+ {TAG_COLORS.map((color) => ( +
+ + + + ) : ( + <> +
+ {tag.name} + + + + )} +
+ ); + })} +
+ )} + + + ); +} \ No newline at end of file diff --git a/src/components/tags/SearchableTagPicker.tsx b/src/components/tags/SearchableTagPicker.tsx new file mode 100644 index 0000000..98cdbee --- /dev/null +++ b/src/components/tags/SearchableTagPicker.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import type { Tag } from "../../lib/types"; +import { Search } from "lucide-react"; + +interface SearchableTagPickerProps { + tags: Tag[]; + selectedTagIds: string[]; + onToggle: (tagId: string) => void; +} + +export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: SearchableTagPickerProps) { + const [search, setSearch] = useState(""); + const filtered = search.trim() + ? tags.filter((t) => t.name.toLowerCase().includes(search.trim().toLowerCase())) + : tags; + + return ( +
+
Tags
+
+ + setSearch(e.target.value)} + placeholder="Search tags..." + className="w-full rounded-full bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors" + /> +
+
+ {filtered.length === 0 && ( +
No tags found
+ )} + {filtered.map((tag) => { + const active = selectedTagIds.includes(tag.id); + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/tags/TagBadge.test.tsx b/src/components/tags/TagBadge.test.tsx new file mode 100644 index 0000000..ab6342e --- /dev/null +++ b/src/components/tags/TagBadge.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TagBadge } from "./TagBadge"; +import type { Tag } from "../../lib/types"; + +const tag: Tag = { id: "t1", name: "production", color: "#ef4444", created_at: "" }; + +describe("TagBadge", () => { + it("renders tag name", () => { + render(); + expect(screen.getByText("production")).toBeInTheDocument(); + }); + it("toggles active state on click", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("production")); + expect(fn).toHaveBeenCalledWith("t1"); + }); + it("shows active styling when active", () => { + render( {}} />); + expect(screen.getByText("production").className).toContain("brightness"); + }); +}); \ No newline at end of file diff --git a/src/components/tags/TagBadge.tsx b/src/components/tags/TagBadge.tsx new file mode 100644 index 0000000..b12a39b --- /dev/null +++ b/src/components/tags/TagBadge.tsx @@ -0,0 +1,20 @@ +import type { Tag } from "../../lib/types"; + +interface TagBadgeProps { + tag: Tag; + active?: boolean; + onToggle?: (id: string) => void; +} + +export function TagBadge({ tag, active = false, onToggle }: TagBadgeProps) { + const Comp = onToggle ? "button" : "span"; + return ( + onToggle(tag.id) : undefined} + className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${onToggle ? "cursor-pointer hover:brightness-125" : ""} ${active ? "brightness-150" : ""}`} + style={{ borderColor: tag.color, color: tag.color, backgroundColor: `${tag.color}15` }} + > + {tag.name} + + ); +} \ No newline at end of file diff --git a/src/components/ui/AnimatedModal.test.tsx b/src/components/ui/AnimatedModal.test.tsx new file mode 100644 index 0000000..64895b1 --- /dev/null +++ b/src/components/ui/AnimatedModal.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AnimatedModal } from "./AnimatedModal"; + +describe("AnimatedModal", () => { + it("renders content when open", () => { + render( + +

Modal body

+
+ ); + expect(screen.getByText("Modal body")).toBeInTheDocument(); + }); + + it("calls onClose when backdrop is clicked", async () => { + const onClose = vi.fn(); + render( + +
Panel
+
+ ); + await userEvent.click(screen.getByTestId("animated-backdrop")); + expect(onClose).toHaveBeenCalled(); + }); + + it("removes content from DOM after exit animation", async () => { + const { rerender } = render( + +

Modal body

+
+ ); + rerender( + +

Modal body

+
+ ); + await waitForElementToBeRemoved(() => screen.queryByText("Modal body")); + expect(screen.queryByText("Modal body")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/AnimatedModal.tsx b/src/components/ui/AnimatedModal.tsx new file mode 100644 index 0000000..aa3e5e9 --- /dev/null +++ b/src/components/ui/AnimatedModal.tsx @@ -0,0 +1,53 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect } from "react"; +import type { ReactNode } from "react"; + +interface AnimatedModalProps { + open: boolean; + onClose: () => void; + children: ReactNode; +} + +export function AnimatedModal({ open, onClose, children }: AnimatedModalProps) { + useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + + {open && ( + + e.stopPropagation()} + > + {children} + + + )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/Badge.test.tsx b/src/components/ui/Badge.test.tsx new file mode 100644 index 0000000..e785a99 --- /dev/null +++ b/src/components/ui/Badge.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Badge } from "./Badge"; + +describe("Badge", () => { + it("renders label and color", () => { + render(); + const el = screen.getByText("production"); + expect(el).toBeInTheDocument(); + expect(el).toHaveStyle({ color: "#ef4444", backgroundColor: "#ef444433" }); + }); + it("fires onClick when provided", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("x")); + expect(fn).toHaveBeenCalledOnce(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..60553ef --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,18 @@ +interface BadgeProps { + label: string; + color: string; + onClick?: () => void; +} + +export function Badge({ label, color, onClick }: BadgeProps) { + const Comp = onClick ? "button" : "span"; + return ( + + {label} + + ); +} \ No newline at end of file diff --git a/src/components/ui/Button.test.tsx b/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..8bd5a95 --- /dev/null +++ b/src/components/ui/Button.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button } from "./Button"; + +describe("Button", () => { + it("renders children", () => { + render(); + expect(screen.getByText("Save")).toBeInTheDocument(); + }); + it("fires onClick", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("Click")); + expect(fn).toHaveBeenCalledOnce(); + }); + it("does not fire onClick when disabled", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText("Click")); + expect(fn).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..85c72de --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,20 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost"; + children: ReactNode; +} + +export function Button({ variant = "primary", className = "", children, ...rest }: ButtonProps) { + const base = "inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border border-border hover:border-border-hover"; + const styles = { + primary: "bg-accent text-white hover:bg-accent-hover shadow-none", + secondary: "bg-surface-raised text-text-muted hover:text-text", + ghost: "bg-transparent text-text-muted hover:text-text", + }[variant]; + return ( + + ); +} \ No newline at end of file diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..bb9e609 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,13 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +interface CardProps extends HTMLAttributes { + children: ReactNode; +} + +export function Card({ children, className = "", ...rest }: CardProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/ConfirmDialog.test.tsx b/src/components/ui/ConfirmDialog.test.tsx new file mode 100644 index 0000000..ffb3040 --- /dev/null +++ b/src/components/ui/ConfirmDialog.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { ConfirmDialog } from "./ConfirmDialog"; + +describe("ConfirmDialog", () => { + it("renders title and message", () => { + render( + , + ); + expect(screen.getByText("Delete?")).toBeInTheDocument(); + expect(screen.getByText("Are you sure?")).toBeInTheDocument(); + }); + + it("calls onConfirm when confirm button is clicked", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render( + , + ); + await user.click(screen.getByText(/confirm/i)); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("calls onCancel when cancel button is clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render( + , + ); + await user.click(screen.getByText(/cancel/i)); + expect(onCancel).toHaveBeenCalled(); + }); + + it("removes content from DOM after exit animation", async () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Delete?")).toBeInTheDocument(); + rerender( + , + ); + await waitForElementToBeRemoved(() => screen.queryByText("Delete?")); + expect(screen.queryByText("Delete?")).not.toBeInTheDocument(); + }); + + it("calls onCancel when Escape is pressed", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render( + , + ); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..db6511b --- /dev/null +++ b/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,27 @@ +import { Button } from "../ui/Button"; +import { AnimatedModal } from "../ui/AnimatedModal"; + +interface ConfirmDialogProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + confirmVariant?: "primary" | "ghost"; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", confirmVariant = "primary", onConfirm, onCancel }: ConfirmDialogProps) { + return ( + +
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/ErrorBanner.test.tsx b/src/components/ui/ErrorBanner.test.tsx new file mode 100644 index 0000000..584481e --- /dev/null +++ b/src/components/ui/ErrorBanner.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorBanner } from "./ErrorBanner"; + +describe("ErrorBanner", () => { + it("renders nothing when no error", () => { + const { container } = render( {}} />); + expect(container.firstChild).toBeNull(); + }); + it("renders message and retry button when error", () => { + const fn = vi.fn(); + render(); + expect(screen.getByText(/storage error/i)).toBeInTheDocument(); + expect(screen.getByText(/retry/i)).toBeInTheDocument(); + }); + it("fires onRetry", async () => { + const fn = vi.fn(); + render(); + await userEvent.click(screen.getByText(/retry/i)); + expect(fn).toHaveBeenCalledOnce(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/ErrorBanner.tsx b/src/components/ui/ErrorBanner.tsx new file mode 100644 index 0000000..87cf1b9 --- /dev/null +++ b/src/components/ui/ErrorBanner.tsx @@ -0,0 +1,14 @@ +interface ErrorBannerProps { + error: string | null; + onRetry: () => void; +} + +export function ErrorBanner({ error, onRetry }: ErrorBannerProps) { + if (!error) return null; + return ( +
+ {error} + +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/Input.test.tsx b/src/components/ui/Input.test.tsx new file mode 100644 index 0000000..3532711 --- /dev/null +++ b/src/components/ui/Input.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Input } from "./Input"; + +describe("Input", () => { + it("renders placeholder", () => { + render(); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + }); + it("fires onChange with value", async () => { + const fn = vi.fn(); + render(); + await userEvent.type(screen.getByPlaceholderText("x"), "hi"); + expect(fn).toHaveBeenLastCalledWith("hi"); + }); +}); \ No newline at end of file diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..e43ef0f --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,27 @@ +import { forwardRef } from "react"; +import type { KeyboardEvent } from "react"; + +interface InputProps { + value?: string; + placeholder?: string; + className?: string; + type?: string; + disabled?: boolean; + "aria-label"?: string; + onChange?: (value: string) => void; + onKeyDown?: (e: KeyboardEvent) => void; +} + +export const Input = forwardRef( + function Input({ onChange, onKeyDown, className = "", ...rest }, ref) { + return ( + onChange?.(e.target.value)} + onKeyDown={(e) => onKeyDown?.(e)} + {...rest} + /> + ); + }, +); \ No newline at end of file diff --git a/src/components/ui/Select.test.tsx b/src/components/ui/Select.test.tsx new file mode 100644 index 0000000..8df8665 --- /dev/null +++ b/src/components/ui/Select.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Select } from "./Select"; + +describe("Select", () => { + it("renders options and calls onChange", async () => { + const onChange = vi.fn(); + render( + onChange(e.target.value)} + className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-50" + > + {options.map((opt) => ( + + ))} + +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/SelectDropdown.test.tsx b/src/components/ui/SelectDropdown.test.tsx new file mode 100644 index 0000000..0aaa93f --- /dev/null +++ b/src/components/ui/SelectDropdown.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SelectDropdown } from "./SelectDropdown"; + +describe("SelectDropdown", () => { + it("renders the selected label and opens a popover menu", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: /Staging/i }); + expect(trigger).toBeInTheDocument(); + + await user.click(trigger); + expect(screen.getByRole("button", { name: /Production/i })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Development/i })); + expect(onChange).toHaveBeenCalledWith("development"); + }); +}); \ No newline at end of file diff --git a/src/components/ui/SelectDropdown.tsx b/src/components/ui/SelectDropdown.tsx new file mode 100644 index 0000000..4ad1f4a --- /dev/null +++ b/src/components/ui/SelectDropdown.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +export interface SelectDropdownOption { + value: string; + label: string; +} + +interface SelectDropdownProps { + value: string; + onChange: (value: string) => void; + options: SelectDropdownOption[]; + placeholder?: string; + variant?: "pill" | "ghost"; + "aria-label"?: string; +} + +export function SelectDropdown({ + value, + onChange, + options, + placeholder = "Select…", + variant = "pill", + "aria-label": ariaLabel, +}: SelectDropdownProps) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const selectedLabel = + options.find((opt) => opt.value === value)?.label ?? placeholder; + + useEffect(() => { + if (!open) return; + const handleMouseDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + const handleSelect = (nextValue: string) => { + onChange(nextValue); + setOpen(false); + }; + + const buttonClass = variant === "ghost" + ? "flex items-center gap-1 text-sm text-text-muted hover:text-text transition-colors cursor-pointer" + : "w-full flex items-center justify-between rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"; + + return ( +
+ + {open && ( +
+ {options.map((opt) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/SettingsRow.test.tsx b/src/components/ui/SettingsRow.test.tsx new file mode 100644 index 0000000..d492a05 --- /dev/null +++ b/src/components/ui/SettingsRow.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SettingsRow } from "./SettingsRow"; + +describe("SettingsRow", () => { + it("renders title, description, and control", () => { + render( + + + + ); + expect(screen.getByText("Font size")).toBeInTheDocument(); + expect(screen.getByText("Adjust text size.")).toBeInTheDocument(); + expect(screen.getByText("Control")).toBeInTheDocument(); + }); + + it("has a bottom border by default", () => { + const { container } = render( + + + + ); + expect(container.firstChild).toHaveClass("border-b"); + }); + + it("removes the bottom border on the last row", () => { + const { container } = render( + <> + + + + + + + + ); + const rows = container.querySelectorAll(".border-b"); + expect(rows).toHaveLength(2); + const lastRow = rows[rows.length - 1]; + expect(lastRow).toHaveClass("last:border-b-0"); + }); +}); \ No newline at end of file diff --git a/src/components/ui/SettingsRow.tsx b/src/components/ui/SettingsRow.tsx new file mode 100644 index 0000000..65410bc --- /dev/null +++ b/src/components/ui/SettingsRow.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +interface SettingsRowProps { + title: string; + description?: string; + children: ReactNode; +} + +export function SettingsRow({ title, description, children }: SettingsRowProps) { + return ( +
+
+
{title}
+ {description && ( +
{description}
+ )} +
+
{children}
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/SettingsSection.test.tsx b/src/components/ui/SettingsSection.test.tsx new file mode 100644 index 0000000..3eb716f --- /dev/null +++ b/src/components/ui/SettingsSection.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SettingsSection } from "./SettingsSection"; + +describe("SettingsSection", () => { + it("renders the title and children", () => { + render( + +
Content
+
+ ); + expect(screen.getByText("Appearance")).toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + it("uses the surface background on the inner card", () => { + const { container } = render( + +
+ + ); + const card = container.querySelector(".bg-surface"); + expect(card).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/SettingsSection.tsx b/src/components/ui/SettingsSection.tsx new file mode 100644 index 0000000..2ce68ec --- /dev/null +++ b/src/components/ui/SettingsSection.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +interface SettingsSectionProps { + title: string; + children: ReactNode; +} + +export function SettingsSection({ title, children }: SettingsSectionProps) { + return ( +
+

{title}

+
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/ThemePicker.test.tsx b/src/components/ui/ThemePicker.test.tsx new file mode 100644 index 0000000..113c45f --- /dev/null +++ b/src/components/ui/ThemePicker.test.tsx @@ -0,0 +1,13 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemePicker } from "./ThemePicker"; + +describe("ThemePicker", () => { + it("renders three options and emits selected theme", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("radio", { name: "System" })); + expect(onChange).toHaveBeenCalledWith("system"); + }); +}); \ No newline at end of file diff --git a/src/components/ui/ThemePicker.tsx b/src/components/ui/ThemePicker.tsx new file mode 100644 index 0000000..fc2904d --- /dev/null +++ b/src/components/ui/ThemePicker.tsx @@ -0,0 +1,41 @@ +import type { Theme } from "../../lib/types"; + +interface ThemePickerProps { + value: Theme; + onChange: (theme: Theme) => void; +} + +const THEMES: { value: Theme; label: string; previewClass: string }[] = [ + { value: "light", label: "Light", previewClass: "bg-zinc-100" }, + { value: "dark", label: "Dark", previewClass: "bg-surface" }, + { value: "system", label: "System", previewClass: "bg-gradient-to-br from-zinc-100 to-surface" }, +]; + +export function ThemePicker({ value, onChange }: ThemePickerProps) { + return ( +
+ {THEMES.map((theme) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..62f26b9 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,41 @@ +import { useNotificationStore } from "../../stores/notificationStore"; +import { X, CheckCircle, AlertCircle, Info } from "lucide-react"; + +const ICONS = { + success: CheckCircle, + error: AlertCircle, + info: Info, +}; + +const STYLES = { + success: "border-green-500/40 bg-green-500/10 text-green-300", + error: "border-red-500/40 bg-red-500/10 text-red-300", + info: "border-accent/40 bg-accent/10 text-accent-muted", +}; + +export function ToastContainer() { + const notifications = useNotificationStore((s) => s.notifications); + const dismiss = useNotificationStore((s) => s.dismiss); + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((n) => { + const Icon = ICONS[n.type]; + return ( +
+ + {n.message} + +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/Toggle.test.tsx b/src/components/ui/Toggle.test.tsx new file mode 100644 index 0000000..61cc638 --- /dev/null +++ b/src/components/ui/Toggle.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Toggle } from "./Toggle"; + +describe("Toggle", () => { + it("renders checked state", () => { + render(); + expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "true"); + }); + + it("renders unchecked state", () => { + render(); + expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "false"); + }); + + it("calls onChange when clicked", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("switch", { name: "Enable" })); + expect(onChange).toHaveBeenCalledWith(true); + }); +}); \ No newline at end of file diff --git a/src/components/ui/Toggle.tsx b/src/components/ui/Toggle.tsx new file mode 100644 index 0000000..c2424c8 --- /dev/null +++ b/src/components/ui/Toggle.tsx @@ -0,0 +1,32 @@ +import { useId } from "react"; + +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; + disabled?: boolean; +} + +export function Toggle({ checked, onChange, label, disabled }: ToggleProps) { + const id = useId(); + return ( + + ); +} \ No newline at end of file diff --git a/src/components/ui/Tooltip.test.tsx b/src/components/ui/Tooltip.test.tsx new file mode 100644 index 0000000..d4a35cb --- /dev/null +++ b/src/components/ui/Tooltip.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Tooltip, TooltipProvider } from "./Tooltip"; + +describe("Tooltip", () => { + it("renders children", () => { + render( + + + + + + ); + expect(screen.getByText("Hover me")).toBeInTheDocument(); + }); + + it("shows tooltip on hover", async () => { + const user = userEvent.setup(); + render( + + + + + + ); + await user.hover(screen.getByText("Hover me")); + expect(await screen.findByText("Help text")).toBeInTheDocument(); + }); + + it("hides tooltip on unhover", async () => { + const user = userEvent.setup(); + render( + + + + + + ); + await user.hover(screen.getByText("Hover me")); + expect(await screen.findByText("Help text")).toBeInTheDocument(); + await user.unhover(screen.getByText("Hover me")); + expect(screen.queryByText("Help text")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..4ea3c26 --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,112 @@ +import { createContext, useContext, useId, useRef, useState, useCallback, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react"; + +interface TooltipContextValue { + activeId: string | null; + setActiveId: Dispatch>; +} + +const TooltipContext = createContext(null); + +function useTooltipContext() { + const ctx = useContext(TooltipContext); + if (!ctx) { + throw new Error("Tooltip must be used inside a TooltipProvider"); + } + return ctx; +} + +interface TooltipProviderProps { + children: ReactNode; +} + +export function TooltipProvider({ children }: TooltipProviderProps) { + const [activeId, setActiveId] = useState(null); + return ( + + {children} + + ); +} + +interface TooltipProps { + content: ReactNode; + children: ReactElement; + side?: "top" | "right" | "bottom" | "left"; +} + +function tooltipClasses(side: "top" | "right" | "bottom" | "left") { + switch (side) { + case "right": + return { + wrapper: "left-full ml-2 top-1/2 -translate-y-1/2", + arrow: "right-full top-1/2 -translate-y-1/2 border-r-surface-raised", + }; + case "bottom": + return { + wrapper: "top-full left-1/2 -translate-x-1/2 mt-2", + arrow: "bottom-full left-1/2 -translate-x-1/2 border-b-surface-raised", + }; + case "left": + return { + wrapper: "right-full mr-2 top-1/2 -translate-y-1/2", + arrow: "left-full top-1/2 -translate-y-1/2 border-l-surface-raised", + }; + case "top": + default: + return { + wrapper: "bottom-full left-1/2 -translate-x-1/2 mb-2", + arrow: "top-full left-1/2 -translate-x-1/2 border-t-surface-raised", + }; + } +} + +export function Tooltip({ content, children, side = "top" }: TooltipProps) { + const id = useId(); + const { activeId, setActiveId } = useTooltipContext(); + const showTimer = useRef | null>(null); + const isActive = activeId === id; + const tc = tooltipClasses(side); + + const clearTimer = useCallback(() => { + if (showTimer.current) { + clearTimeout(showTimer.current); + showTimer.current = null; + } + }, []); + + const show = useCallback(() => { + clearTimer(); + showTimer.current = setTimeout(() => { + setActiveId(id); + }, 300); + }, [clearTimer, id, setActiveId]); + + const hide = useCallback(() => { + clearTimer(); + setActiveId((prev) => (prev === id ? null : prev)); + }, [clearTimer, id, setActiveId]); + + return ( + + {children} + {isActive && ( + + {content} + + )} + + ); +} \ No newline at end of file diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts new file mode 100644 index 0000000..03b7678 --- /dev/null +++ b/src/hooks/useConnections.ts @@ -0,0 +1,29 @@ +import { useConnectionStore } from "../stores/connectionStore"; +import { useUiStore } from "../stores/uiStore"; +import { filterConnections, getDescendantFolderIds } from "../lib/utils"; +import type { Connection } from "../lib/types"; + +export function useFilteredConnections(): Connection[] { + const connections = useConnectionStore((s) => s.connections); + const tags = useConnectionStore((s) => s.tags); + const folders = useConnectionStore((s) => s.folders); + const searchQuery = useUiStore((s) => s.searchQuery); + const activeFolderId = useUiStore((s) => s.activeFolderId); + const activeTagIds = useUiStore((s) => s.activeTagIds); + const activeDbTypes = useUiStore((s) => s.activeDbTypes); + + let filtered = filterConnections(connections, tags, { + query: searchQuery, + activeTagIds, + activeDbTypes, + }); + + if (activeFolderId) { + const allowed = new Set(getDescendantFolderIds(folders, activeFolderId)); + filtered = filtered.filter( + (c) => c.folder_id !== null && allowed.has(c.folder_id), + ); + } + + return filtered; +} \ No newline at end of file diff --git a/src/hooks/useDbConnection.ts b/src/hooks/useDbConnection.ts new file mode 100644 index 0000000..72bc2e3 --- /dev/null +++ b/src/hooks/useDbConnection.ts @@ -0,0 +1,117 @@ +import { useEffect, useCallback, useRef, useState } from "react"; +import { useConnectionStore } from "../stores/connectionStore"; +import { useDbViewerStore } from "../stores/dbViewerStore"; +import { useNotificationStore } from "../stores/notificationStore"; +import * as cmd from "../lib/commands"; +import type { ConnectionInput } from "../lib/types"; + +export function useDbConnection(connectionId: string) { + const reset = useDbViewerStore((s) => s.reset); + const populate = useDbViewerStore((s) => s.populate); + const currentDatabase = useDbViewerStore((s) => s.currentDatabase); + const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase); + const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); + const notify = useNotificationStore((s) => s.notify); + const [connectionError, setConnectionError] = useState(null); + const inputRef = useRef(null); + const initialDbRef = useRef(null); + + const connect = useCallback(async () => { + const conn = useConnectionStore + .getState() + .connections.find((c) => c.id === connectionId); + if (!conn) { + setConnectionError("Connection not found"); + return; + } + try { + const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null); + const input: ConnectionInput = { + name: conn.name, + db_type: conn.db_type, + host: conn.host, + port: conn.port, + username: conn.username, + password, + database: conn.database, + folder_id: conn.folder_id, + ssh_host: conn.ssh_host, + ssh_port: conn.ssh_port, + ssh_user: conn.ssh_user, + ssh_auth_method: + conn.ssh_auth_method as "password" | "key" | null | undefined, + ssh_private_key_path: conn.ssh_private_key_path, + ssl_mode: + conn.ssl_mode as + | "disable" + | "require" + | "verify-ca" + | "verify-full" + | null + | undefined, + ssl_ca_path: conn.ssl_ca_path, + ssl_cert_path: conn.ssl_cert_path, + ssl_key_path: conn.ssl_key_path, + }; + await cmd.dbConnect(connectionId, input); + setConnectionError(null); + inputRef.current = input; + + // Load initial data + const databases = await cmd + .getDatabases(connectionId) + .catch(() => [] as string[]); + const schemas = await cmd + .getSchemas(connectionId) + .catch(() => [] as string[]); + const tables = await cmd.getTables(connectionId); + populate(databases, schemas, tables); + if (databases.length > 0) { + setCurrentDatabase(databases[0]); + initialDbRef.current = databases[0]; + } + if (schemas.length > 0) setCurrentSchema(schemas[0]); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setConnectionError(msg); + notify(`Failed to connect: ${msg}`, "error"); + } + }, [connectionId, populate, setCurrentDatabase, setCurrentSchema, notify]); + + useEffect(() => { + connect(); + return () => { + cmd.dbDisconnect(connectionId).catch(() => {}); + const hasPending = useDbViewerStore + .getState() + .changesQueue.some((c) => c.status === "pending"); + if (!hasPending) reset(); + }; + }, [connectionId, connect, reset]); + + // Database switch effect: reconnect when user changes database from dropdown + useEffect(() => { + if (!currentDatabase || !inputRef.current) return; + if (currentDatabase === initialDbRef.current) return; + + const reconnect = async () => { + const input = { ...inputRef.current!, database: currentDatabase }; + try { + await cmd.dbConnect(connectionId, input); + const schemas = await cmd.getSchemas(connectionId); + const tables = await cmd.getTables(connectionId); + populate( + useDbViewerStore.getState().databases, + schemas, + tables, + ); + if (schemas.length > 0) setCurrentSchema(schemas[0]); + } catch { + /* silent */ + } + }; + reconnect(); + }, [currentDatabase, connectionId, populate, setCurrentSchema]); + + return { connectionError, connect }; +} \ No newline at end of file diff --git a/src/hooks/useSearch.test.ts b/src/hooks/useSearch.test.ts new file mode 100644 index 0000000..2f8a0f6 --- /dev/null +++ b/src/hooks/useSearch.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useSearch } from "./useSearch"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("useSearch", () => { + it("returns initial empty value", () => { + const { result } = renderHook(() => useSearch("")); + expect(result.current.debounced).toBe(""); + }); + + it("debounces value updates by 150ms", () => { + const { result, rerender } = renderHook(({ q }) => useSearch(q), { + initialProps: { q: "" }, + }); + rerender({ q: "prod" }); + expect(result.current.debounced).toBe(""); + + act(() => vi.advanceTimersByTime(149)); + expect(result.current.debounced).toBe(""); + + act(() => vi.advanceTimersByTime(1)); + expect(result.current.debounced).toBe("prod"); + }); + + it("resets debounce when value returns to empty", () => { + const { result, rerender } = renderHook(({ q }) => useSearch(q), { + initialProps: { q: "" }, + }); + rerender({ q: "x" }); + rerender({ q: "" }); + act(() => vi.advanceTimersByTime(200)); + expect(result.current.debounced).toBe(""); + }); +}); \ No newline at end of file diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 0000000..60f012b --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useSearch(query: string): { debounced: string } { + const [debounced, setDebounced] = useState(query); + + useEffect(() => { + const t = setTimeout(() => setDebounced(query), 150); + return () => clearTimeout(t); + }, [query]); + + return { debounced }; +} \ No newline at end of file diff --git a/src/hooks/useShortcut.ts b/src/hooks/useShortcut.ts new file mode 100644 index 0000000..f79430f --- /dev/null +++ b/src/hooks/useShortcut.ts @@ -0,0 +1,45 @@ +import { useEffect } from "react"; +import { useSettingsStore } from "../stores/settingsStore"; + +// Default macOS shortcuts (used when no custom binding is set) +const DEFAULTS: Record = { + command_palette: "Meta+k", + close_tab: "Meta+w", +}; + +// Normalize key combo from settings (e.g. "Meta+K" → { metaKey: true, key: "k" }) +function parseCombo(combo: string): { metaKey: boolean; ctrlKey: boolean; key: string } | null { + if (!combo) return null; + const parts = combo.toLowerCase().split("+"); + const metaKey = parts.includes("meta") || parts.includes("cmd"); + const ctrlKey = parts.includes("ctrl"); + // The last part is the key + const key = parts.filter((p) => !["meta", "cmd", "ctrl", "shift", "alt"].includes(p)).join("+"); + if (!key) return null; + return { metaKey, ctrlKey, key }; +} + +export function useShortcut( + action: string, + callback: () => void, +) { + const settings = useSettingsStore((s) => s.settings); + + useEffect(() => { + // Get the combo from settings or use default + const raw = settings?.shortcuts?.[action] ?? DEFAULTS[action]; + const combo = parseCombo(raw); + if (!combo) return; + + const handler = (e: KeyboardEvent) => { + const metaMatch = combo.metaKey ? (e.metaKey || e.ctrlKey) : e.metaKey === combo.metaKey && e.ctrlKey === combo.ctrlKey; + if (metaMatch && e.key.toLowerCase() === combo.key) { + e.preventDefault(); + callback(); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [action, callback, settings?.shortcuts]); +} \ No newline at end of file diff --git a/src/hooks/useSortedTags.ts b/src/hooks/useSortedTags.ts new file mode 100644 index 0000000..07c932f --- /dev/null +++ b/src/hooks/useSortedTags.ts @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import type { Tag } from "../lib/types"; +import { useConnectionStore } from "../stores/connectionStore"; + +export function useSortedTags(): Tag[] { + const tags = useConnectionStore((s) => s.tags); + const tagOrder = useConnectionStore((s) => s.tagOrder); + + return useMemo(() => { + if (tagOrder.length === 0) return tags; + const orderMap = new Map(tagOrder.map((id, i) => [id, i])); + const sorted = [...tags].sort((a, b) => { + const ai = orderMap.get(a.id); + const bi = orderMap.get(b.id); + if (ai !== undefined && bi !== undefined) return ai - bi; + if (ai !== undefined) return -1; + if (bi !== undefined) return 1; + return a.name.localeCompare(b.name); + }); + return sorted; + }, [tags, tagOrder]); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..9c309da --- /dev/null +++ b/src/index.css @@ -0,0 +1,48 @@ +@import "tailwindcss"; +@import "@fontsource/space-mono"; +@import "@fontsource/outfit"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-canvas: #0A0A0B; + --color-surface: #18181B; + --color-surface-raised: #27272A; + --color-border: #27272A; + --color-border-hover: #3F3F46; + --color-text: #FAFAFA; + --color-text-muted: #A1A1AA; + --color-accent: #2563EB; + --color-accent-hover: #1D4ED8; + --color-accent-muted: #60A5FA; + --font-family-heading: "Space Mono", monospace; + --font-family-sans: "Outfit", sans-serif; +} + +:root { + color-scheme: dark; +} + +html, body { + background-color: var(--color-canvas); + color: white; + font-family: var(--font-family-sans); + -webkit-font-smoothing: antialiased; + overscroll-behavior: none; + overflow: hidden; +} + +html { + height: 100%; +} + +body { + min-height: 100%; +} + +@utility glass { + background-color: color-mix(in srgb, var(--color-surface) 90%, transparent); + backdrop-filter: blur(12px); + border: 1px solid var(--color-border); + border-radius: 0.75rem; +} \ No newline at end of file diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts new file mode 100644 index 0000000..b934018 --- /dev/null +++ b/src/lib/commands.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { + testConnection, + dbConnect, + dbDisconnect, + getDatabases, + getSchemas, + getTables, + getTableData, + executeChange, + refreshConnection, +} from "./commands"; + +describe("commands", () => { + it("testConnection has correct signature", () => { + expect(typeof testConnection).toBe("function"); + }); + + it("dbConnect returns void promise", () => { + expect(typeof dbConnect).toBe("function"); + }); + + it("dbDisconnect returns void promise", () => { + expect(typeof dbDisconnect).toBe("function"); + }); + + it("getDatabases returns string array promise", () => { + expect(typeof getDatabases).toBe("function"); + }); + + it("getSchemas returns string array promise", () => { + expect(typeof getSchemas).toBe("function"); + }); + + it("getTables returns TableInfo array promise", () => { + expect(typeof getTables).toBe("function"); + }); + + it("getTableData returns QueryResult-shaped promise", () => { + expect(typeof getTableData).toBe("function"); + }); + + it("executeChange returns void promise", () => { + expect(typeof executeChange).toBe("function"); + }); + + it("refreshConnection returns full tree promise", () => { + expect(typeof refreshConnection).toBe("function"); + }); +}); \ No newline at end of file diff --git a/src/lib/commands.ts b/src/lib/commands.ts new file mode 100644 index 0000000..c56cb1e --- /dev/null +++ b/src/lib/commands.ts @@ -0,0 +1,99 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem } from "./types"; + +// NOTE on argument key naming: +// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names +// to camelCase keys on the IPC boundary. So a Rust param `connection_id` must be +// sent as `connectionId` here, `page_size` as `pageSize`, `tag_ids` as `tagIds`, +// `folder_id` as `folderId`. Single-word params (id, input, config, json, schema, +// table, page, key, value, change) are unchanged. + +export async function getConnections(): Promise { return invoke("get_connections"); } +export async function createConnection(input: ConnectionInput): Promise { return invoke("create_connection", { input }); } +export async function updateConnection(id: string, input: ConnectionInput): Promise { return invoke("update_connection", { id, input }); } +export async function deleteConnection(id: string): Promise { return invoke("delete_connection", { id }); } +export async function addConnectionTags(connectionId: string, tagIds: string[]): Promise { return invoke("add_connection_tags", { connectionId, tagIds }); } +export async function getFolders(): Promise { return invoke("get_folders"); } +export async function createFolder(input: FolderInput): Promise { return invoke("create_folder", { input }); } +export async function updateFolder(id: string, input: FolderInput): Promise { return invoke("update_folder", { id, input }); } +export async function deleteFolder(id: string): Promise { return invoke("delete_folder", { id }); } +export async function addFolderTags(folderId: string, tagIds: string[]): Promise { return invoke("add_folder_tags", { folderId, tagIds }); } +export async function getTags(): Promise { return invoke("get_tags"); } +export async function createTag(input: TagInput): Promise { return invoke("create_tag", { input }); } +export async function deleteTag(id: string): Promise { return invoke("delete_tag", { id }); } +export async function updateTag(id: string, input: TagInput): Promise { return invoke("update_tag", { id, input }); } +export async function getSettings(): Promise { return invoke("get_settings"); } +export async function updateSetting(key: string, value: string): Promise { return invoke("update_setting", { key, value }); } +export async function importConnections(json: string): Promise { return invoke("import_connections", { json }); } +export async function exportConnections(): Promise { return invoke("export_connections"); } +export async function testConnection(input: ConnectionInput): Promise { + return invoke("test_connection", { config: input }); +} + +// ─── Keychain ────────────────────────────────────────────────── + +export async function saveConnectionPassword(connectionId: string, password: string): Promise { + return invoke("save_connection_password", { connectionId, password }); +} + +export async function getConnectionPassword(connectionId: string): Promise { + return invoke("get_connection_password", { connectionId }); +} + +export async function deleteConnectionPassword(connectionId: string): Promise { + return invoke("delete_connection_password", { connectionId }); +} + +export async function recreateDemoDb(): Promise { + return invoke("recreate_demo_db"); +} + +// ─── DB Viewer Lifecycle ──────────────────────────────────────── + +export async function dbConnect(connectionId: string, input: ConnectionInput): Promise { + return invoke("db_connect", { connectionId, config: input }); +} + +export async function dbDisconnect(connectionId: string): Promise { + return invoke("db_disconnect", { connectionId }); +} + +export async function getDatabases(connectionId: string): Promise { + return invoke("get_databases", { connectionId }); +} + +export async function getSchemas(connectionId: string): Promise { + return invoke("get_schemas", { connectionId }); +} + +export async function getTables(connectionId: string, schema?: string): Promise { + return invoke("get_tables", { connectionId, schema }); +} + +export async function getTableData( + connectionId: string, + schema: string, + table: string, + page?: number, + pageSize?: number, +): Promise { + return invoke("get_table_data", { connectionId, schema, table, page, pageSize }); +} + +export async function executeChange(connectionId: string, change: ChangeItem): Promise { + return invoke("execute_change", { connectionId, change }); +} + +export async function getFkPreview( + connectionId: string, + schema: string, + table: string, + column: string, + value: string, +): Promise { + return invoke("get_fk_preview", { connectionId, schema, table, column, value }); +} + +export async function refreshConnection(connectionId: string): Promise { + return invoke("refresh_connection", { connectionId }); +} \ No newline at end of file diff --git a/src/lib/connectionString.test.ts b/src/lib/connectionString.test.ts new file mode 100644 index 0000000..4531d60 --- /dev/null +++ b/src/lib/connectionString.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { parseConnectionString, looksLikeConnectionString } from "./connectionString"; + +describe("parseConnectionString", () => { + it("parses a PostgreSQL URL", () => { + const result = parseConnectionString("postgresql://user:pass@localhost:5432/mydb"); + expect(result).toEqual({ + db_type: "postgresql", + host: "localhost", + port: 5432, + username: "user", + password: "pass", + database: "mydb", + }); + }); + + it("parses a MySQL URL without password", () => { + const result = parseConnectionString("mysql://root@127.0.0.1:3306/app"); + expect(result).toEqual({ + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: null, + database: "app", + }); + }); + + it("parses a Redis URL", () => { + const result = parseConnectionString("redis://user:pass@localhost:6379/0"); + expect(result).toEqual({ + db_type: "redis", + host: "localhost", + port: 6379, + username: "user", + password: "pass", + database: "0", + }); + }); + + it("parses a SQLite file URL", () => { + const result = parseConnectionString("sqlite:///path/to/db.sqlite"); + expect(result).toEqual({ + db_type: "sqlite", + host: "localhost", + port: null, + username: null, + password: null, + database: "/path/to/db.sqlite", + }); + }); + + it("defaults PostgreSQL port to 5432 when omitted", () => { + const result = parseConnectionString("postgresql://user@host/db"); + expect(result).toEqual({ + db_type: "postgresql", + host: "host", + port: 5432, + username: "user", + password: null, + database: "db", + }); + }); + + it("preserves database name when query parameters are present", () => { + const result = parseConnectionString("postgresql://user@host/db?sslmode=require"); + expect(result).toEqual({ + db_type: "postgresql", + host: "host", + port: 5432, + username: "user", + password: null, + database: "db", + }); + }); + + it("returns null for an empty string", () => { + expect(parseConnectionString("")).toBeNull(); + }); + + it("returns null for a non-URL string", () => { + expect(parseConnectionString("hello world")).toBeNull(); + }); + + it("defaults MySQL port to 3306 when omitted", () => { + const result = parseConnectionString("mysql://user@host/db"); + expect(result).toEqual({ + db_type: "mysql", + host: "host", + port: 3306, + username: "user", + password: null, + database: "db", + }); + }); + + it("defaults Redis port to 6379 when omitted", () => { + const result = parseConnectionString("redis://localhost"); + expect(result).toEqual({ + db_type: "redis", + host: "localhost", + port: 6379, + username: null, + password: null, + database: null, + }); + }); +}); + +describe("looksLikeConnectionString", () => { + it("returns true for postgres URL", () => { + expect(looksLikeConnectionString("postgresql://a@b/c")).toBe(true); + }); + + it("returns true for Redis URL", () => { + expect(looksLikeConnectionString("redis://localhost")).toBe(true); + }); + + it("returns true for SQLite URL", () => { + expect(looksLikeConnectionString("sqlite:///path/to/db.sqlite")).toBe(true); + }); + + it("returns false for normal search text", () => { + expect(looksLikeConnectionString("production database")).toBe(false); + }); +}); diff --git a/src/lib/connectionString.ts b/src/lib/connectionString.ts new file mode 100644 index 0000000..6774bd8 --- /dev/null +++ b/src/lib/connectionString.ts @@ -0,0 +1,71 @@ +import type { DbType } from "./types"; + +export interface ParsedConnectionString { + db_type: DbType; + host: string; + port: number | null; + username: string | null; + password: string | null; + database: string | null; +} + +const DB_PROTOCOLS: Record = { + "postgresql:": "postgresql", + "postgres:": "postgresql", + "mysql:": "mysql", + "sqlite:": "sqlite", + "file:": "sqlite", + "redis:": "redis", + "rediss:": "redis", +}; + +const DEFAULT_PORTS: Record = { + postgresql: 5432, + mysql: 3306, + sqlite: null, + redis: 6379, +}; + +export function parseConnectionString(input: string): ParsedConnectionString | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + const db_type = DB_PROTOCOLS[url.protocol]; + if (!db_type) return null; + + const host = url.hostname || "localhost"; + const port = url.port ? Number(url.port) : (DEFAULT_PORTS[db_type] ?? null); + const username = url.username || null; + const password = url.password || null; + const pathname = url.pathname; + const database = db_type === "sqlite" + ? (pathname || null) + : (pathname.replace(/^\//, "") || null); + + return { + db_type, + host, + port, + username, + password, + database, + }; +} + +export function looksLikeConnectionString(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + try { + const url = new URL(trimmed); + return !!DB_PROTOCOLS[url.protocol]; + } catch { + return false; + } +} diff --git a/src/lib/dbIcons.ts b/src/lib/dbIcons.ts new file mode 100644 index 0000000..adbc224 --- /dev/null +++ b/src/lib/dbIcons.ts @@ -0,0 +1,15 @@ +import type { DbType } from "./types"; + +export const DB_ICONS: Record = { + postgresql: "🐘", + mysql: "🐬", + redis: "⚡", + sqlite: "🗄️", +}; + +export const DB_LABELS: Record = { + postgresql: "PostgreSQL", + mysql: "MySQL", + redis: "Redis", + sqlite: "SQLite", +}; \ No newline at end of file diff --git a/src/lib/environment.ts b/src/lib/environment.ts new file mode 100644 index 0000000..27e79b2 --- /dev/null +++ b/src/lib/environment.ts @@ -0,0 +1,13 @@ +export type Environment = "production" | "staging" | "development" | null; + +export const ENV_LABELS: Record = { + production: "Production", + staging: "Staging", + development: "Development", +}; + +export const ENV_COLORS: Record = { + production: "bg-red-500/20 text-red-400 border-red-500/30", + staging: "bg-amber-500/20 text-amber-400 border-amber-500/30", + development: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", +}; \ No newline at end of file diff --git a/src/lib/importExport.test.ts b/src/lib/importExport.test.ts new file mode 100644 index 0000000..5d5ead3 --- /dev/null +++ b/src/lib/importExport.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleImport, handleExport } from "./importExport"; +import * as commands from "./commands"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-fs", () => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), +})); + +describe("handleImport", () => { + it("returns null when user cancels (no file selected)", async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + (open as any).mockResolvedValue(null); + const result = await handleImport(); + expect(result).toBeNull(); + }); + it("imports and reloads when a file is selected", async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + (open as any).mockResolvedValue("/path/to/connections.json"); + (readTextFile as any).mockResolvedValue( + '[{"name":"A","db_type":"postgresql","host":"h","port":5432}]', + ); + const spy = vi + .spyOn(commands, "importConnections") + .mockResolvedValue({ imported: 1, skipped: 0, skippedRecords: [] }); + const result = await handleImport(); + expect(spy).toHaveBeenCalled(); + expect(result?.imported).toBe(1); + }); +}); + +describe("handleExport", () => { + it("returns null when user cancels", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + (save as any).mockResolvedValue(null); + const result = await handleExport(); + expect(result).toBeNull(); + }); + it("exports to selected path", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + (save as any).mockResolvedValue("/path/out.json"); + (writeTextFile as any).mockResolvedValue(undefined); + vi.spyOn(commands, "exportConnections").mockResolvedValue( + '{"version":1,"connections":[]}', + ); + const result = await handleExport(); + expect(result).toBe("/path/out.json"); + }); +}); \ No newline at end of file diff --git a/src/lib/importExport.ts b/src/lib/importExport.ts new file mode 100644 index 0000000..d01cab4 --- /dev/null +++ b/src/lib/importExport.ts @@ -0,0 +1,24 @@ +import { open, save } from "@tauri-apps/plugin-dialog"; +import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { importConnections, exportConnections } from "./commands"; +import type { ImportResult } from "./types"; + +export async function handleImport(): Promise { + const filePath = await open({ + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!filePath) return null; + const json = await readTextFile(filePath); + return importConnections(json); +} + +export async function handleExport(): Promise { + const filePath = await save({ + defaultPath: "connections.json", + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!filePath) return null; + const json = await exportConnections(); + await writeTextFile(filePath, json); + return filePath; +} \ No newline at end of file diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts new file mode 100644 index 0000000..be7e9d2 --- /dev/null +++ b/src/lib/types.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect } from "vitest"; +import type { + Connection, + ConnectionInput, + ActiveView, + TableInfo, + ColumnInfo, + QueryResult, + ChangeItem, + ChangeItemType, + ChangeStatus, + DbViewerTab, + ConnectionTestResult, +} from "./types"; + +describe("ActiveView", () => { + it("includes db-viewer", () => { + const view: ActiveView = "db-viewer"; + expect(view).toBe("db-viewer"); + }); +}); + +describe("Connection", () => { + it("accepts new SSH/SSL fields", () => { + const conn: Connection = { + id: "c1", + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + username: "admin", + folder_id: null, + keychain_ref: null, + tag_ids: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + // new SSH/SSL fields + database: "mydb", + ssh_host: "bastion.example.com", + ssh_port: 22, + ssh_user: "tunnel", + ssh_auth_method: "key", + ssh_private_key_path: "/home/user/.ssh/id_rsa", + ssl_mode: "require", + ssl_ca_path: "/etc/ssl/certs/ca.pem", + ssl_cert_path: "/etc/ssl/certs/client.crt", + ssl_key_path: "/etc/ssl/certs/client.key", + }; + expect(conn.ssh_host).toBe("bastion.example.com"); + expect(conn.database).toBe("mydb"); + expect(conn.ssl_mode).toBe("require"); + }); + + it("does not include sensitive SSH/SSL fields (password/ssh_passphrase)", () => { + // TypeScript compile check: these should not be assignable + const conn: Connection = { + id: "c2", + name: "Minimal", + db_type: "postgresql", + host: "localhost", + port: null, + username: null, + folder_id: null, + keychain_ref: null, + tag_ids: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + // @ts-expect-error - password must NOT exist on Connection + conn.password; + // @ts-expect-error - ssh_passphrase must NOT exist on Connection + conn.ssh_passphrase; + expect(conn.host).toBe("localhost"); + }); + + it("accepts SSH fields as optional (minimal connection)", () => { + const conn: Connection = { + id: "c3", + name: "Minimal", + db_type: "postgresql", + host: "localhost", + port: null, + username: null, + folder_id: null, + keychain_ref: null, + tag_ids: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + expect(conn.ssh_host).toBeUndefined(); + expect(conn.database).toBeUndefined(); + }); +}); + +describe("ConnectionInput", () => { + it("accepts all SSH/SSL fields", () => { + const input: ConnectionInput = { + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_port: 22, + ssh_user: "tunnel", + ssh_auth_method: "key", + ssh_private_key_path: "/home/user/.ssh/id_rsa", + ssh_passphrase: "s3cret", + ssl_mode: "verify-full", + ssl_ca_path: "/etc/ssl/certs/ca.pem", + ssl_cert_path: "/etc/ssl/certs/client.crt", + ssl_key_path: "/etc/ssl/certs/client.key", + password: "dbpass", + database: "mydb", + }; + expect(input.ssh_auth_method).toBe("key"); + expect(input.ssl_mode).toBe("verify-full"); + expect(input.ssh_passphrase).toBe("s3cret"); + expect(input.database).toBe("mydb"); + }); + + it("accepts password auth method", () => { + const input: ConnectionInput = { + name: "PW SSH", + db_type: "postgresql", + host: "db.example.com", + port: 5432, + ssh_host: "gateway.example.com", + ssh_port: 2222, + ssh_user: "proxy", + ssh_auth_method: "password", + }; + expect(input.ssh_auth_method).toBe("password"); + }); + + it("allows all SSH/SSL fields to be omitted", () => { + const input: ConnectionInput = { + name: "Simple", + db_type: "sqlite", + host: "localhost", + port: null, + }; + expect(input.ssh_host).toBeUndefined(); + expect(input.ssl_mode).toBeUndefined(); + }); +}); + +describe("TableInfo", () => { + it("has the correct shape", () => { + const table: TableInfo = { + name: "users", + schema: "public", + table_type: "TABLE", + }; + expect(table.name).toBe("users"); + expect(table.schema).toBe("public"); + expect(table.table_type).toBe("TABLE"); + }); + + it("accepts view type", () => { + const table: TableInfo = { name: "v1", schema: "public", table_type: "VIEW" }; + expect(table.table_type).toBe("VIEW"); + }); +}); + +describe("ColumnInfo", () => { + it("has the correct shape", () => { + const col: ColumnInfo = { + name: "id", + data_type: "integer", + is_nullable: false, + is_pk: true, + is_fk: false, + fk_ref: null, + default_value: null, + }; + expect(col.name).toBe("id"); + expect(col.is_pk).toBe(true); + }); + + it("supports foreign key references", () => { + const col: ColumnInfo = { + name: "user_id", + data_type: "integer", + is_nullable: true, + is_pk: false, + is_fk: true, + fk_ref: ["users", "id"], + default_value: null, + }; + expect(col.fk_ref?.[0]).toBe("users"); + }); +}); + +describe("QueryResult", () => { + it("is well-typed with columns and rows", () => { + const result: 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"], + [2, "Bob"], + ], + total_rows: 2, page: 1, page_size: 50, + execution_time_ms: 12.5, + }; + expect(result.columns.length).toBe(3); + expect(result.total_rows).toBe(2); + expect(result.execution_time_ms).toBe(12.5); + }); + + it("allows error state", () => { + const result: QueryResult = { + columns: [], + rows: [], + total_rows: 0, page: 1, page_size: 50, + error: "Syntax error near FROM", + }; + expect(result.error).toBe("Syntax error near FROM"); + }); + + it("can have null execution_time", () => { + const result: QueryResult = { + columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}], + rows: [], + total_rows: 0, page: 1, page_size: 50, + execution_time_ms: null, + }; + expect(result.execution_time_ms).toBeNull(); + }); +}); + +describe("ChangeItem", () => { + it("has a discriminated type", () => { + const createItem: ChangeItem = { + type: "create_table", + sql: "CREATE TABLE users (id INT)", + status: "pending", + id: "ch1", + description: "Create users table", + }; + expect(createItem.type).toBe("create_table"); + expect(createItem.status).toBe("pending"); + + const dropItem: ChangeItem = { + type: "drop_table", + sql: "DROP TABLE users", + status: "applied", + id: "ch2", + }; + expect(dropItem.type).toBe("drop_table"); + expect(dropItem.status).toBe("applied"); + }); + + it("accepts error status with error message", () => { + const item: ChangeItem = { + type: "alter_table", + sql: "ALTER TABLE users ADD COLUMN age INT", + status: "error", + error: "Column already exists", + id: "ch3", + }; + expect(item.status).toBe("error"); + expect(item.error).toBe("Column already exists"); + }); + + it("accepts all ChangeItemType values", () => { + const types: ChangeItemType[] = [ + "create_table", + "alter_table", + "drop_table", + "insert", + "update", + "delete", + "create_index", + "drop_index", + ]; + const item: ChangeItem = { + type: types[0], + sql: "test", + status: "pending", + id: "ch4", + }; + expect(types).toContain(item.type); + }); +}); + +describe("ChangeStatus", () => { + it("accepts all status values", () => { + const statuses: ChangeStatus[] = ["pending", "applied", "error"]; + expect(statuses).toHaveLength(3); + }); +}); + +describe("DbViewerTab", () => { + it("can be created with required fields", () => { + const tab: DbViewerTab = { + id: "tab1", + connection_id: "c1", + title: "Query 1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + expect(tab.title).toBe("Query 1"); + expect(tab.query).toBeUndefined(); + expect(tab.result).toBeUndefined(); + expect(tab.changes).toBeUndefined(); + }); + + it("can include query and result", () => { + const tab: DbViewerTab = { + id: "tab2", + connection_id: "c1", + title: "SELECT * FROM users", + query: "SELECT * FROM users", + result: { + 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}], + rows: [], + total_rows: 0, page: 1, page_size: 50, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + expect(tab.query).toBe("SELECT * FROM users"); + expect(tab.result?.total_rows).toBe(0); + }); + + it("can include changes array", () => { + const tab: DbViewerTab = { + id: "tab3", + connection_id: "c1", + title: "Migration", + changes: [ + { type: "create_table", sql: "CREATE TABLE t (id INT)", status: "pending", id: "ch1" }, + ], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + expect(tab.changes).toHaveLength(1); + expect(tab.changes![0].type).toBe("create_table"); + }); +}); + +describe("ConnectionTestResult", () => { + it("can represent a successful test", () => { + const result: ConnectionTestResult = { + ok: true, + server_version: "16.2", + latency_ms: 5, + }; + expect(result.ok).toBe(true); + expect(result.server_version).toBe("16.2"); + }); + + it("can represent a failed test with error", () => { + const result: ConnectionTestResult = { + ok: false, + error: "Connection refused", + }; + expect(result.ok).toBe(false); + expect(result.error).toBe("Connection refused"); + }); +}); \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..3bb6b60 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,188 @@ +export type DbType = "postgresql" | "mysql" | "sqlite" | "redis"; + +export type Theme = "dark" | "light" | "system"; +export type FontSize = "small" | "medium" | "large"; + +export interface Folder { + id: string; + name: string; + parent_id: string | null; + tag_ids: string[]; + created_at: string; + updated_at: string; +} + +export interface Tag { + id: string; + name: string; + color: string; + created_at: string; +} + +export interface Connection { + id: string; + name: string; + db_type: DbType; + host: string; + port: number | null; + username: string | null; + database?: string | null; + folder_id: string | null; + keychain_ref: string | null; + tag_ids: string[]; + created_at: string; + updated_at: string; + // SSH/SSL fields (persisted, excluding secrets) + ssh_host?: string | null; + ssh_port?: number | null; + ssh_user?: string | null; + ssh_auth_method?: string | null; + ssh_private_key_path?: string | null; + ssl_mode?: string | null; + ssl_ca_path?: string | null; + ssl_cert_path?: string | null; + ssl_key_path?: string | null; + // Environment label (production, staging, development, etc.) + environment?: string | null; +} + +export type NewConnectionMode = "simple" | "detailed"; + +export interface ConnectionInput { + name: string; + db_type: DbType; + host: string; + port: number | null; + username?: string | null; + folder_id?: string | null; + tag_ids?: string[]; + // Form-only fields; backend may ignore them until persisted. + connection_string?: string | null; + environment?: string | null; + password?: string | null; + database?: string | null; + use_keychain?: boolean; + // SSH tunnel fields + ssh_host?: string | null; + ssh_port?: number | null; + ssh_user?: string | null; + ssh_auth_method?: "password" | "key" | null; + ssh_private_key_path?: string | null; + ssh_passphrase?: string | null; + // SSL/TLS fields + ssl_mode?: "disable" | "require" | "verify-ca" | "verify-full" | null; + ssl_ca_path?: string | null; + ssl_cert_path?: string | null; + ssl_key_path?: string | null; +} + +export interface FolderInput { + name: string; + parent_id: string | null; + tag_ids?: string[]; +} + +export interface TagInput { + name: string; + color: string; +} + +export interface Settings { + confirm_before_delete: boolean; + default_folder_id: string | null; + theme: Theme; + font_size: FontSize; + default_ports: Record; + tag_order: string | null; + table_refresh_rate: number; + table_page_size: number; + shortcuts: Record; +} + +export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer"; + +export interface FilterState { + query: string; + activeFolderId: string | null; + activeTagIds: string[]; + activeDbTypes: DbType[]; +} + +export interface ValidationResult { + ok: boolean; + error: string; +} + +export interface ImportResult { + imported: number; + skipped: number; + skippedRecords: { index: number; reason: string }[]; +} + +// ─── DB Viewer Types ──────────────────────────────────────────── + +export interface TableInfo { + name: string; + schema: string; + table_type: "TABLE" | "VIEW"; + columns?: ColumnInfo[]; +} + +export interface ColumnInfo { + name: string; + data_type: string; + is_nullable: boolean; + is_pk: boolean; + is_fk: boolean; + fk_ref: [string, string] | null; + default_value: string | null; +} + +export interface QueryResult { + columns: ColumnInfo[]; + rows: unknown[][]; + total_rows: number; + page: number; + page_size: number; + execution_time_ms?: number | null; + error?: string | null; +} + +export type ChangeStatus = "pending" | "applied" | "error"; + +export type ChangeItemType = + | "create_table" + | "alter_table" + | "drop_table" + | "insert" + | "update" + | "delete" + | "create_index" + | "drop_index"; + +export interface ChangeItem { + type: ChangeItemType; + sql: string; + status: ChangeStatus; + error?: string | null; + id: string; + description?: string | null; +} + +export interface DbViewerTab { + id: string; + connection_id: string; + title: string; + query?: string | null; + result?: QueryResult | null; + changes?: ChangeItem[]; + created_at: string; + updated_at: string; +} + +export interface ConnectionTestResult { + ok: boolean; + error?: string | null; + server_version?: string | null; + latency_ms?: number | null; +} \ No newline at end of file diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..7b7c88b --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from "vitest"; +import { + validateConnectionInput, + validateFolderInput, + validateTagInput, + filterConnections, + getDescendantFolderIds, + getFolderPathLabel, +} from "./utils"; +import type { Connection, Folder, Tag } from "./types"; + +const makeConnection = (over: Partial = {}): Connection => ({ + id: "c1", + name: "Prod DB", + db_type: "postgresql", + host: "prod.example.com", + port: 5432, + username: "admin", + folder_id: null, + keychain_ref: null, + tag_ids: [], + created_at: "2026-07-26T00:00:00Z", + updated_at: "2026-07-26T00:00:00Z", + ...over, +}); + +describe("validateConnectionInput", () => { + it("accepts a valid postgresql connection", () => { + const result = validateConnectionInput({ + name: "Prod DB", + db_type: "postgresql", + host: "prod.example.com", + port: 5432, + username: "admin", + folder_id: null, + tag_ids: [], + }); + expect(result.ok).toBe(true); + }); + + it("rejects empty name", () => { + const result = validateConnectionInput({ + name: "", + db_type: "postgresql", + host: "h", + port: 5432, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("name"); + }); + + it("rejects name longer than 100 chars", () => { + const result = validateConnectionInput({ + name: "x".repeat(101), + db_type: "postgresql", + host: "h", + port: 5432, + }); + expect(result.ok).toBe(false); + }); + + it("rejects invalid db_type", () => { + const result = validateConnectionInput({ + name: "X", + db_type: "mongodb" as any, + host: "h", + port: 5432, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("db_type"); + }); + + it("rejects port out of range", () => { + const result = validateConnectionInput({ + name: "X", + db_type: "postgresql", + host: "h", + port: 99999, + }); + expect(result.ok).toBe(false); + }); + + it("allows null port for sqlite", () => { + const result = validateConnectionInput({ + name: "Local", + db_type: "sqlite", + host: "/data/analytics.db", + port: null, + }); + expect(result.ok).toBe(true); + }); + + it("rejects host longer than 255 chars", () => { + const result = validateConnectionInput({ + name: "X", + db_type: "postgresql", + host: "h".repeat(256), + port: 5432, + }); + expect(result.ok).toBe(false); + }); + + describe("SSH/SSL validation", () => { + it("rejects SSH host longer than 255 chars", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "x".repeat(256), + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("SSH host"); + }); + + it("rejects SSH port below 1", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_port: 0, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("SSH port"); + }); + + it("rejects SSH port above 65535", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_port: 70000, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("SSH port"); + }); + + it("accepts valid SSH port", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_port: 2222, + }); + expect(result.ok).toBe(true); + }); + + it("accepts null SSH port", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_port: null, + }); + expect(result.ok).toBe(true); + }); + + it("rejects SSH user longer than 100 chars", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssh_host: "bastion.example.com", + ssh_user: "u".repeat(101), + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("SSH user"); + }); + + it("rejects invalid ssl_mode", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssl_mode: "invalid" as any, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("SSL mode"); + }); + + it("accepts valid ssl_mode values", () => { + const validModes = ["disable", "require", "verify-ca", "verify-full"]; + for (const mode of validModes) { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + ssl_mode: mode as any, + }); + expect(result.ok).toBe(true); + } + }); + + it("accepts input without SSH fields", () => { + const result = validateConnectionInput({ + name: "Test", + db_type: "postgresql", + host: "localhost", + port: 5432, + }); + expect(result.ok).toBe(true); + }); + }); +}); + +describe("validateFolderInput", () => { + it("accepts valid folder", () => { + expect(validateFolderInput({ name: "Work", parent_id: null }).ok).toBe(true); + }); + it("rejects empty name", () => { + expect(validateFolderInput({ name: "", parent_id: null }).ok).toBe(false); + }); + it("rejects name longer than 100 chars", () => { + expect(validateFolderInput({ name: "x".repeat(101), parent_id: null }).ok).toBe(false); + }); +}); + +describe("validateTagInput", () => { + it("accepts valid tag", () => { + expect(validateTagInput({ name: "production", color: "#ef4444" }).ok).toBe(true); + }); + it("rejects empty name", () => { + expect(validateTagInput({ name: "", color: "#ef4444" }).ok).toBe(false); + }); + it("rejects name longer than 50 chars", () => { + expect(validateTagInput({ name: "x".repeat(51), color: "#ef4444" }).ok).toBe(false); + }); +}); + +describe("getDescendantFolderIds", () => { + const folders: Folder[] = [ + { id: "f1", name: "root", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "f2", name: "child", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" }, + { id: "f3", name: "grandchild", parent_id: "f2", tag_ids: [], created_at: "", updated_at: "" }, + { id: "f4", name: "other", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + ]; + it("returns all descendant ids including self", () => { + expect(getDescendantFolderIds(folders, "f1").sort()).toEqual(["f1", "f2", "f3"]); + }); + it("returns only self for leaf folder", () => { + expect(getDescendantFolderIds(folders, "f3")).toEqual(["f3"]); + }); +}); + +describe("filterConnections", () => { + const tags: Tag[] = [ + { id: "t1", name: "production", color: "#ef4444", created_at: "" }, + { id: "t2", name: "cache", color: "#3b82f6", created_at: "" }, + ]; + const conns: Connection[] = [ + makeConnection({ id: "c1", name: "Prod", host: "prod.example.com", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1" }), + makeConnection({ id: "c2", name: "Redis", host: "redis.internal", db_type: "redis", tag_ids: ["t2"], folder_id: "f2" }), + ]; + it("filters by search query on name", () => { + expect(filterConnections(conns, tags, { query: "prod" })).toEqual([conns[0]]); + }); + it("filters by search query on host", () => { + expect(filterConnections(conns, tags, { query: "redis.internal" })).toEqual([conns[1]]); + }); + it("search is case-insensitive", () => { + expect(filterConnections(conns, tags, { query: "PROD" })).toEqual([conns[0]]); + }); + it("filters by tag id", () => { + expect(filterConnections(conns, tags, { query: "", activeTagIds: ["t1"] })).toEqual([conns[0]]); + }); + it("filters by db_type", () => { + expect(filterConnections(conns, tags, { query: "", activeDbTypes: ["redis"] })).toEqual([conns[1]]); + }); + it("returns all when no filters", () => { + expect(filterConnections(conns, tags, { query: "" })).toEqual(conns); + }); + it("search matches tag name", () => { + expect(filterConnections(conns, tags, { query: "cache" })).toEqual([conns[1]]); + }); +}); + +describe("getFolderPathLabel", () => { + const folders: Folder[] = [ + { id: "a", name: "FolderA", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }, + { id: "b", name: "Folder1", parent_id: "a", tag_ids: [], created_at: "", updated_at: "" }, + ]; + + it("returns root label for null", () => { + expect(getFolderPathLabel(folders, null)).toBe("Root"); + }); + + it("returns nested path", () => { + expect(getFolderPathLabel(folders, "b")).toBe("FolderA → Folder1"); + }); + + it("returns root label for non-existent folder", () => { + expect(getFolderPathLabel(folders, "missing")).toBe("Root"); + }); +}); \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..e7f469d --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,132 @@ +import type { + Connection, + DbType, + Folder, + Tag, + ConnectionInput, + FolderInput, + TagInput, + ValidationResult, +} from "./types"; + +// ─── Data type abbreviations ───────────────────────────── + +const TYPE_ABBREV: Record = { + "integer": "int", + "bigint": "int8", + "smallint": "int2", + "character varying": "varchar", + "character": "char", + "timestamp with time zone": "timestamptz", + "timestamp without time zone": "timestamp", + "time with time zone": "timetz", + "time without time zone": "time", + "boolean": "bool", + "double precision": "float8", + "real": "float4", +}; + +export function abbreviateType(dataType: string): string { + const lower = dataType.toLowerCase(); + return TYPE_ABBREV[lower] ?? dataType; +} + +const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "sqlite", "redis"]; + +export function validateConnectionInput(input: ConnectionInput): ValidationResult { + if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" }; + if (input.name.length > 100) return { ok: false, error: "name must be 100 chars or fewer" }; + if (!VALID_DB_TYPES.includes(input.db_type)) + return { ok: false, error: `db_type must be one of: ${VALID_DB_TYPES.join(", ")}` }; + if (!input.host || input.host.length === 0) return { ok: false, error: "host is required" }; + if (input.host.length > 255) return { ok: false, error: "host must be 255 chars or fewer" }; + if (input.db_type !== "sqlite") { + if (input.port === null || input.port === undefined) + return { ok: false, error: "port is required for this db_type" }; + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) + return { ok: false, error: "port must be an integer between 1 and 65535" }; + } + if (input.username && input.username.length > 100) + return { ok: false, error: "username must be 100 chars or fewer" }; + + // SSH tunnel validation + if (input.ssh_host && input.ssh_host.length > 255) + return { ok: false, error: "SSH host must be 255 chars or fewer" }; + if (input.ssh_port !== undefined && input.ssh_port !== null) { + if (!Number.isInteger(input.ssh_port) || input.ssh_port < 1 || input.ssh_port > 65535) + return { ok: false, error: "SSH port must be between 1 and 65535" }; + } + if (input.ssh_user && input.ssh_user.length > 100) + return { ok: false, error: "SSH user must be 100 chars or fewer" }; + + // SSL/TLS validation + if (input.ssl_mode && !["disable", "require", "verify-ca", "verify-full"].includes(input.ssl_mode)) + return { ok: false, error: "SSL mode must be one of: disable, require, verify-ca, verify-full" }; + + return { ok: true, error: "" }; +} + +export function validateFolderInput(input: FolderInput): ValidationResult { + if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" }; + if (input.name.length > 100) return { ok: false, error: "name must be 100 chars or fewer" }; + return { ok: true, error: "" }; +} + +export function validateTagInput(input: TagInput): ValidationResult { + if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" }; + if (input.name.length > 50) return { ok: false, error: "name must be 50 chars or fewer" }; + return { ok: true, error: "" }; +} + +export function getDescendantFolderIds(folders: Folder[], rootId: string): string[] { + const result = [rootId]; + const children = folders.filter((f) => f.parent_id === rootId); + for (const child of children) { + result.push(...getDescendantFolderIds(folders, child.id)); + } + return result; +} + +export function getFolderPath(folders: Folder[], folderId: string | null): Folder[] { + const folderMap = new Map(folders.map((f) => [f.id, f])); + const path: Folder[] = []; + let current: Folder | undefined = folderId ? folderMap.get(folderId) : undefined; + while (current) { + path.unshift(current); + current = current.parent_id ? folderMap.get(current.parent_id) : undefined; + } + return path; +} + +export function getFolderPathLabel(folders: Folder[], folderId: string | null): string { + if (folderId === null) return "Root"; + const path = getFolderPath(folders, folderId); + if (path.length === 0) return "Root"; + return path.map((f) => f.name).join(" → "); +} + +export function getChildFolders(folders: Folder[], parentId: string | null): Folder[] { + return folders.filter((f) => f.parent_id === parentId); +} + +export function filterConnections( + connections: Connection[], + tags: Tag[], + filter: { query: string; activeTagIds?: string[]; activeDbTypes?: DbType[] }, +): Connection[] { + const q = filter.query.trim().toLowerCase(); + const tagIds = filter.activeTagIds ?? []; + const dbTypes = filter.activeDbTypes ?? []; + const tagNameById = new Map(tags.map((t) => [t.id, t.name.toLowerCase()])); + + return connections.filter((c) => { + if (dbTypes.length > 0 && !dbTypes.includes(c.db_type)) return false; + if (tagIds.length > 0 && !tagIds.every((id) => c.tag_ids.includes(id))) return false; + if (q.length > 0) { + const tagNames = c.tag_ids.map((id) => tagNameById.get(id) ?? "").join(" "); + const haystack = `${c.name} ${c.host} ${c.db_type} ${tagNames}`.toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 2be325e..8b1ddb9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import "./index.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/stores/connectionStore.test.ts b/src/stores/connectionStore.test.ts new file mode 100644 index 0000000..2284509 --- /dev/null +++ b/src/stores/connectionStore.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useConnectionStore } from "./connectionStore"; +import * as commands from "../lib/commands"; +import type { Connection, Folder, Tag } from "../lib/types"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const makeConn = (over: Partial = {}): Connection => ({ + id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432, + username: null, folder_id: null, keychain_ref: null, tag_ids: [], + created_at: "", updated_at: "", ...over, +}); + +beforeEach(() => { + useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null }); + vi.restoreAllMocks(); +}); + +describe("connectionStore", () => { + it("loadAll fetches connections, folders, tags", async () => { + const folders: Folder[] = [{ id: "f1", name: "root", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }]; + const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }]; + const conns: Connection[] = [makeConn()]; + vi.spyOn(commands, "getConnections").mockResolvedValue(conns); + vi.spyOn(commands, "getFolders").mockResolvedValue(folders); + vi.spyOn(commands, "getTags").mockResolvedValue(tags); + await useConnectionStore.getState().loadAll(); + expect(useConnectionStore.getState().connections).toEqual(conns); + expect(useConnectionStore.getState().folders).toEqual(folders); + expect(useConnectionStore.getState().tags).toEqual(tags); + }); + + it("loadAll sets error on failure", async () => { + vi.spyOn(commands, "getConnections").mockRejectedValue(new Error("fail")); + vi.spyOn(commands, "getFolders").mockResolvedValue([]); + vi.spyOn(commands, "getTags").mockResolvedValue([]); + await useConnectionStore.getState().loadAll(); + expect(useConnectionStore.getState().error).toContain("fail"); + }); + + it("createConnection adds to list on success", async () => { + const created = makeConn({ id: "c2", name: "New" }); + vi.spyOn(commands, "createConnection").mockResolvedValue(created); + await useConnectionStore.getState().createConnection({ name: "New", db_type: "postgresql", host: "h", port: 5432 }); + expect(useConnectionStore.getState().connections).toContainEqual(created); + }); + + it("deleteConnection removes from list", async () => { + useConnectionStore.setState({ connections: [makeConn({ id: "c1" })] }); + vi.spyOn(commands, "deleteConnection").mockResolvedValue(undefined); + await useConnectionStore.getState().deleteConnection("c1"); + expect(useConnectionStore.getState().connections).toEqual([]); + }); + + it("createFolder adds to folders", async () => { + const folder: Folder = { id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }; + vi.spyOn(commands, "createFolder").mockResolvedValue(folder); + await useConnectionStore.getState().createFolder({ name: "Work", parent_id: null }); + expect(useConnectionStore.getState().folders).toContainEqual(folder); + }); +}); \ No newline at end of file diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts new file mode 100644 index 0000000..a4da8fe --- /dev/null +++ b/src/stores/connectionStore.ts @@ -0,0 +1,119 @@ +import { create } from "zustand"; +import type { Connection, ConnectionInput, Folder, FolderInput, Tag, TagInput } from "../lib/types"; +import * as cmd from "../lib/commands"; + +interface ConnectionState { + connections: Connection[]; folders: Folder[]; tags: Tag[]; + tagOrder: string[]; + loading: boolean; error: string | null; + loadAll: () => Promise; + loadTagOrder: () => Promise; + setTagOrder: (order: string[]) => Promise; + createConnection: (input: ConnectionInput) => Promise; + deleteConnection: (id: string) => Promise; + createFolder: (input: FolderInput) => Promise; + updateFolder: (id: string, input: FolderInput) => Promise; + deleteFolder: (id: string) => Promise; + createTag: (input: TagInput) => Promise; + updateTag: (id: string, input: TagInput) => Promise; + deleteTag: (id: string) => Promise; + addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise; + cachePassword: (connectionId: string, password: string) => Promise; + getConnectionPassword: (connectionId: string) => Promise; +} + +export const useConnectionStore = create((set, get) => ({ + connections: [], folders: [], tags: [], tagOrder: [], loading: false, error: null, + loadAll: async () => { + set({ loading: true, error: null }); + try { + const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]); + set({ connections, folders, tags, loading: false }); + // Also load tag order + get().loadTagOrder(); + } catch (e) { + set({ loading: false, error: e instanceof Error ? e.message : String(e) }); + } + }, + loadTagOrder: async () => { + try { + const settings = await cmd.getSettings(); + if (settings.tag_order) { + try { + const order = JSON.parse(settings.tag_order); + if (Array.isArray(order)) set({ tagOrder: order }); + } catch {} + } + } catch {} + }, + setTagOrder: async (order) => { + await cmd.updateSetting("tag_order", JSON.stringify(order)); + set({ tagOrder: order }); + }, + createConnection: async (input) => { + const conn = await cmd.createConnection(input); + // Persist password to OS keychain (not SQLite) + if (input.password) { + await cmd.saveConnectionPassword(conn.id, input.password); + } + set((s) => ({ connections: [...s.connections, conn] })); + }, + deleteConnection: async (id) => { + await cmd.deleteConnection(id); + // Remove password from keychain + try { await cmd.deleteConnectionPassword(id); } catch { /* ignore */ } + set((s) => ({ connections: s.connections.filter((c) => c.id !== id) })); + }, + createFolder: async (input) => { + const folder = await cmd.createFolder(input); + set((s) => ({ folders: [...s.folders, folder] })); + }, + updateFolder: async (id, input) => { + const folder = await cmd.updateFolder(id, input); + set((s) => ({ folders: s.folders.map((f) => f.id === id ? folder : f) })); + }, + deleteFolder: async (id) => { + await cmd.deleteFolder(id); + set((s) => ({ folders: s.folders.filter((f) => f.id !== id), connections: s.connections.map((c) => c.folder_id === id ? { ...c, folder_id: null } : c) })); + }, + createTag: async (input) => { + const tag = await cmd.createTag(input); + set((s) => ({ tags: [...s.tags, tag] })); + }, + updateTag: async (id, input) => { + const tag = await cmd.updateTag(id, input); + set((s) => ({ tags: s.tags.map((t) => t.id === id ? tag : t) })); + }, + deleteTag: async (id) => { + await cmd.deleteTag(id); + set((s) => ({ + tags: s.tags.filter((t) => t.id !== id), + connections: s.connections.map((c) => c.tag_ids.includes(id) ? { ...c, tag_ids: c.tag_ids.filter((t) => t !== id) } : c), + folders: s.folders.map((f) => f.tag_ids.includes(id) ? { ...f, tag_ids: f.tag_ids.filter((t) => t !== id) } : f), + })); + }, + cachePassword: async (connectionId, password) => { + await cmd.saveConnectionPassword(connectionId, password); + }, + getConnectionPassword: async (connectionId) => { + return cmd.getConnectionPassword(connectionId); + }, + addTagToItems: async (tagId, folderIds, connectionIds) => { + await Promise.all([ + ...folderIds.map((fid) => cmd.addFolderTags(fid, [tagId])), + ...connectionIds.map((cid) => cmd.addConnectionTags(cid, [tagId])), + ]); + set((s) => ({ + folders: s.folders.map((f) => + folderIds.includes(f.id) && !f.tag_ids.includes(tagId) + ? { ...f, tag_ids: [...f.tag_ids, tagId] } + : f, + ), + connections: s.connections.map((c) => + connectionIds.includes(c.id) && !c.tag_ids.includes(tagId) + ? { ...c, tag_ids: [...c.tag_ids, tagId] } + : c, + ), + })); + }, +})); \ No newline at end of file diff --git a/src/stores/dbViewerStore.test.ts b/src/stores/dbViewerStore.test.ts new file mode 100644 index 0000000..910396e --- /dev/null +++ b/src/stores/dbViewerStore.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useDbViewerStore } from "./dbViewerStore"; +import type { QueryResult, TableInfo } from "../lib/types"; + +beforeEach(() => { + useDbViewerStore.getState().reset(); +}); + +describe("dbViewerStore", () => { + it("starts empty", () => { + const state = useDbViewerStore.getState(); + expect(state.tabs).toEqual([]); + expect(state.activeTabId).toBeNull(); + expect(state.changesQueue).toEqual([]); + expect(state.databases).toEqual([]); + expect(state.schemas).toEqual([]); + expect(state.tables).toEqual([]); + expect(state.currentDatabase).toBeNull(); + expect(state.currentSchema).toBeNull(); + }); + + it("openTab adds a new tab", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + const state = useDbViewerStore.getState(); + expect(state.tabs).toHaveLength(1); + const tab = state.tabs[0]; + expect(tab.schema).toBe("public"); + expect(tab.table).toBe("users"); + expect(tab.page).toBe(1); + expect(state.activeTabId).toBe(tab.id); + }); + + it("openTab does not duplicate", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + store.openTab("public", "users"); + const state = useDbViewerStore.getState(); + expect(state.tabs).toHaveLength(1); + }); + + it("openTab forceNew creates duplicate", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + store.openTab("public", "users"); + const tabsAfterTwo = useDbViewerStore.getState().tabs; + expect(tabsAfterTwo).toHaveLength(1); + + store.openTab("public", "users", true); + const state = useDbViewerStore.getState(); + expect(state.tabs).toHaveLength(2); + }); + + it("closeTab removes tab and switches activeTabId", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + const tab1Id = useDbViewerStore.getState().tabs[0].id; + store.openTab("public", "posts", true); + + store.closeTab(tab1Id); + const state = useDbViewerStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.activeTabId).toBe(state.tabs[0].id); + }); + + it("setPage updates pagination", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + const tabId = useDbViewerStore.getState().tabs[0].id; + + store.setPage(tabId, 3); + const tab = useDbViewerStore.getState().tabs[0]; + expect(tab.page).toBe(3); + }); + + it("setTabData updates tab data", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + const tabId = useDbViewerStore.getState().tabs[0].id; + + // First set loading to true to verify it gets cleared + store.setTabLoading(tabId, true); + + 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}], + rows: [[1, "Alice"]], + total_rows: 1, page: 1, page_size: 50, + }; + store.setTabData(tabId, mockData); + + const tab = useDbViewerStore.getState().tabs[0]; + expect(tab.data).toEqual(mockData); + expect(tab.loading).toBe(false); + expect(tab.error).toBeNull(); + }); + + it("setTabError sets error", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + const tabId = useDbViewerStore.getState().tabs[0].id; + + store.setTabError(tabId, "Something went wrong"); + const tab = useDbViewerStore.getState().tabs[0]; + expect(tab.error).toBe("Something went wrong"); + expect(tab.loading).toBe(false); + }); + + it("addChange appends to queue", () => { + const store = useDbViewerStore.getState(); + store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" }); + + const queue = useDbViewerStore.getState().changesQueue; + expect(queue).toHaveLength(1); + expect(queue[0].status).toBe("pending"); + expect(queue[0].id).toBeTruthy(); + expect(queue[0].createdAt).toBeGreaterThan(0); + }); + + it("cancelChange marks change as cancelled", () => { + const store = useDbViewerStore.getState(); + store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" }); + const changeId = useDbViewerStore.getState().changesQueue[0].id; + + store.cancelChange(changeId); + const change = useDbViewerStore.getState().changesQueue[0]; + expect(change.status).toBe("cancelled"); + }); + + it("markChangeCommitted updates status", () => { + const store = useDbViewerStore.getState(); + store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" }); + const changeId = useDbViewerStore.getState().changesQueue[0].id; + + store.markChangeCommitted(changeId); + const change = useDbViewerStore.getState().changesQueue[0]; + expect(change.status).toBe("committed"); + }); + + it("markChangeFailed updates status and records error", () => { + const store = useDbViewerStore.getState(); + store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" }); + const changeId = useDbViewerStore.getState().changesQueue[0].id; + + store.markChangeFailed(changeId, "Constraint violation"); + const change = useDbViewerStore.getState().changesQueue[0]; + expect(change.status).toBe("failed"); + expect(change.error).toBe("Constraint violation"); + }); + + it("reset clears all state", () => { + const store = useDbViewerStore.getState(); + store.openTab("public", "users"); + store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" }); + store.populate(["mydb"], ["public"], [{ name: "users", schema: "public", table_type: "TABLE" }]); + + store.reset(); + + const state = useDbViewerStore.getState(); + expect(state.tabs).toEqual([]); + expect(state.activeTabId).toBeNull(); + expect(state.changesQueue).toEqual([]); + expect(state.databases).toEqual([]); + expect(state.schemas).toEqual([]); + expect(state.tables).toEqual([]); + expect(state.currentDatabase).toBeNull(); + expect(state.currentSchema).toBeNull(); + }); + + it("populate sets databases, schemas, tables", () => { + const tables: TableInfo[] = [ + { name: "users", schema: "public", table_type: "TABLE" }, + { name: "posts", schema: "public", table_type: "TABLE" }, + ]; + const store = useDbViewerStore.getState(); + store.populate(["mydb", "testdb"], ["public", "private"], tables); + + const state = useDbViewerStore.getState(); + expect(state.databases).toEqual(["mydb", "testdb"]); + expect(state.schemas).toEqual(["public", "private"]); + expect(state.tables).toEqual(tables); + }); +}); \ No newline at end of file diff --git a/src/stores/dbViewerStore.ts b/src/stores/dbViewerStore.ts new file mode 100644 index 0000000..e3d40d0 --- /dev/null +++ b/src/stores/dbViewerStore.ts @@ -0,0 +1,256 @@ +import { create } from "zustand"; +import type { QueryResult, TableInfo, ChangeItemType } from "../lib/types"; + +// ─── Local types ──────────────────────────────────────────────── + +export type QueueStatus = "pending" | "cancelled" | "committed" | "failed"; + +export interface QueueItem { + id: string; + type: ChangeItemType; + sql: string; + schema?: string; + table?: string; + primaryKey?: Record; + oldData?: Record | null; + newData?: Record | null; + status: QueueStatus; + error?: string | null; + description?: string | null; + createdAt: number; +} + +export interface ViewerTab { + id: string; + schema: string; + table: string; + page: number; + pageSize: number; + loading: boolean; + error: string | null; + data: QueryResult | null; + columnFilter?: { column: string; value: string }; +} + +// ─── Auto-increment counters ─────────────────────────────────── + +let tabCounter = 0; +let changeCounter = 0; + +const initialTab = (schema: string, table: string, defaultPageSize?: number): ViewerTab => ({ + id: `tab-${++tabCounter}`, + schema, + table, + page: 1, + pageSize: defaultPageSize ?? 50, + loading: true, + error: null, + data: null, +}); + +// ─── State interface ──────────────────────────────────────────── + +interface DbViewerState { + tabs: ViewerTab[]; + activeTabId: string | null; + defaultPageSize: number; + changesQueue: QueueItem[]; + databases: string[]; + schemas: string[]; + tables: TableInfo[]; + currentDatabase: string | null; + currentSchema: string | null; + + // Actions + openTab: (schema: string, table: string, forceNew?: boolean) => void; + setDefaultPageSize: (size: number) => void; + closeTab: (tabId: string) => void; + setActiveTab: (tabId: string) => void; + setPage: (tabId: string, page: number) => void; + setPageSize: (tabId: string, pageSize: number) => void; + setTabData: (tabId: string, data: QueryResult) => void; + setTabLoading: (tabId: string, loading: boolean) => void; + setTabError: (tabId: string, error: string) => void; + setColumnFilter: (tabId: string, column: string, value: string) => void; + clearColumnFilter: (tabId: string) => void; + addChange: (input: { + type: ChangeItemType; + sql?: string; + schema?: string; + table?: string; + primaryKey?: Record; + oldData?: Record | null; + newData?: Record | null; + description?: string | null; + }) => void; + cancelChange: (changeId: string) => void; + markChangeCommitted: (changeId: string) => void; + markChangeFailed: (changeId: string, error: string) => void; + setCurrentDatabase: (db: string | null) => void; + setCurrentSchema: (schema: string | null) => void; + populate: ( + databases: string[], + schemas: string[], + tables: TableInfo[], + ) => void; + reset: () => void; +} + +// ─── Initial state ────────────────────────────────────────────── + +const initialState = { + tabs: [] as ViewerTab[], + activeTabId: null as string | null, + defaultPageSize: 50, + changesQueue: [] as QueueItem[], + databases: [] as string[], + schemas: [] as string[], + tables: [] as TableInfo[], + currentDatabase: null as string | null, + currentSchema: null as string | null, +}; + +// ─── Store ────────────────────────────────────────────────────── + +export const useDbViewerStore = create((set, get) => ({ + ...initialState, + + openTab: (schema, table, forceNew = false) => { + const { tabs } = get(); + + // Dedup: if not forceNew and an identical tab exists, just activate it + if (!forceNew) { + const existing = tabs.find( + (t) => t.schema === schema && t.table === table, + ); + if (existing) { + set({ activeTabId: existing.id }); + return; + } + } + + const tab = initialTab(schema, table, get().defaultPageSize); + set({ tabs: [...tabs, tab], activeTabId: tab.id }); + }, + + setDefaultPageSize: (size) => set({ defaultPageSize: size }), + + closeTab: (tabId) => { + const { tabs, activeTabId } = get(); + const remaining = tabs.filter((t) => t.id !== tabId); + const newActiveId = + activeTabId === tabId + ? remaining.length > 0 + ? remaining[remaining.length - 1].id + : null + : activeTabId; + set({ tabs: remaining, activeTabId: newActiveId }); + }, + + setActiveTab: (tabId) => set({ activeTabId: tabId }), + + setPage: (tabId, page) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId + ? { ...t, page, loading: true, error: null } + : t, + ), + })), + + setPageSize: (tabId, pageSize) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId + ? { ...t, pageSize, page: 1, loading: true, error: null } + : t, + ), + })), + + setTabData: (tabId, data) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, data, loading: false, error: null } : t, + ), + })), + + setTabLoading: (tabId, loading) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, loading } : t, + ), + })), + + setTabError: (tabId, error) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, error, loading: false } : t, + ), + })), + + setColumnFilter: (tabId, column, value) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, columnFilter: { column, value } } : t, + ), + })), + + clearColumnFilter: (tabId) => + set((state) => ({ + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, columnFilter: undefined } : t, + ), + })), + + addChange: (input) => { + const item: QueueItem = { + id: `ch-${++changeCounter}`, + type: input.type, + sql: input.sql ?? "", + schema: input.schema, + table: input.table, + primaryKey: input.primaryKey, + oldData: input.oldData ?? null, + newData: input.newData ?? null, + status: "pending", + description: input.description ?? null, + createdAt: Date.now(), + }; + set((state) => ({ changesQueue: [...state.changesQueue, item] })); + }, + + cancelChange: (changeId) => + set((state) => ({ + changesQueue: state.changesQueue.map((c) => + c.id === changeId ? { ...c, status: "cancelled" as const } : c, + ), + })), + + markChangeCommitted: (changeId) => + set((state) => ({ + changesQueue: state.changesQueue.map((c) => + c.id === changeId ? { ...c, status: "committed" as const } : c, + ), + })), + + markChangeFailed: (changeId, error) => + set((state) => ({ + changesQueue: state.changesQueue.map((c) => + c.id === changeId + ? { ...c, status: "failed" as const, error } + : c, + ), + })), + + setCurrentDatabase: (db) => set({ currentDatabase: db }), + setCurrentSchema: (schema) => set({ currentSchema: schema }), + + populate: (databases, schemas, tables) => + set({ databases, schemas, tables }), + + reset: () => { + tabCounter = 0; + changeCounter = 0; + set({ ...initialState, tabs: [], changesQueue: [] }); + }, +})); \ No newline at end of file diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts new file mode 100644 index 0000000..66291eb --- /dev/null +++ b/src/stores/notificationStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +export interface Notification { + id: string; + message: string; + type: "success" | "error" | "info"; +} + +interface NotificationState { + notifications: Notification[]; + notify: (message: string, type?: Notification["type"]) => void; + dismiss: (id: string) => void; +} + +let counter = 0; + +export const useNotificationStore = create((set) => ({ + notifications: [], + notify: (message, type = "info") => { + const id = `notif-${++counter}`; + set((s) => ({ notifications: [...s.notifications, { id, message, type }] })); + setTimeout(() => { + set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })); + }, 4000); + }, + dismiss: (id) => set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })), +})); \ No newline at end of file diff --git a/src/stores/settingsStore.test.ts b/src/stores/settingsStore.test.ts new file mode 100644 index 0000000..7b9f74e --- /dev/null +++ b/src/stores/settingsStore.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useSettingsStore } from "./settingsStore"; +import * as commands from "../lib/commands"; + +beforeEach(() => { + useSettingsStore.setState({ settings: null, loading: false, error: null }); + vi.restoreAllMocks(); +}); + +describe("settingsStore", () => { + it("load fetches settings", async () => { + const settings = { confirm_before_delete: true, default_folder_id: null, theme: "dark" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {} }; + vi.spyOn(commands, "getSettings").mockResolvedValue(settings); + await useSettingsStore.getState().load(); + expect(useSettingsStore.getState().settings).toEqual(settings); + }); + + it("updateSetting persists then reloads", async () => { + vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined); + const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {} }; + vi.spyOn(commands, "getSettings").mockResolvedValue(settings); + await useSettingsStore.getState().updateSetting("theme", "light"); + expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light"); + expect(useSettingsStore.getState().settings?.theme).toBe("light"); + }); +}); \ No newline at end of file diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts new file mode 100644 index 0000000..74178ec --- /dev/null +++ b/src/stores/settingsStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; +import type { Settings } from "../lib/types"; +import * as cmd from "../lib/commands"; + +interface SettingsState { + settings: Settings | null; loading: boolean; error: string | null; + load: () => Promise; + updateSetting: (key: string, value: string) => Promise; +} + +export const useSettingsStore = create((set, get) => ({ + settings: null, loading: false, error: null, + load: async () => { + set({ loading: true, error: null }); + try { + const settings = await cmd.getSettings(); + set({ settings, loading: false }); + } catch (e) { + set({ loading: false, error: e instanceof Error ? e.message : String(e) }); + } + }, + updateSetting: async (key, value) => { + await cmd.updateSetting(key, value); + await get().load(); + }, +})); \ No newline at end of file diff --git a/src/stores/uiStore.test.ts b/src/stores/uiStore.test.ts new file mode 100644 index 0000000..0ffb94e --- /dev/null +++ b/src/stores/uiStore.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useUiStore } from "./uiStore"; + +beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", prefilledConnectionString: null, activeConnectionId: null })); + +describe("uiStore", () => { + it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home")); + it("setActiveView changes view", () => { + useUiStore.getState().setActiveView("settings"); + expect(useUiStore.getState().activeView).toBe("settings"); + }); + it("setSearchQuery updates query", () => { + useUiStore.getState().setSearchQuery("prod"); + expect(useUiStore.getState().searchQuery).toBe("prod"); + }); + it("toggleTag adds and removes tag", () => { + useUiStore.getState().toggleTag("t1"); + expect(useUiStore.getState().activeTagIds).toEqual(["t1"]); + useUiStore.getState().toggleTag("t1"); + expect(useUiStore.getState().activeTagIds).toEqual([]); + }); + it("toggleDbType adds and removes type", () => { + useUiStore.getState().toggleDbType("redis"); + expect(useUiStore.getState().activeDbTypes).toEqual(["redis"]); + useUiStore.getState().toggleDbType("redis"); + expect(useUiStore.getState().activeDbTypes).toEqual([]); + }); + it("clearFilters resets search and filters but not view", () => { + useUiStore.getState().setSearchQuery("x"); + useUiStore.getState().toggleTag("t1"); + useUiStore.getState().setActiveView("settings"); + useUiStore.getState().clearFilters(); + expect(useUiStore.getState().searchQuery).toBe(""); + expect(useUiStore.getState().activeTagIds).toEqual([]); + expect(useUiStore.getState().activeView).toBe("settings"); + }); + it("sets and clears prefilled connection string", () => { + useUiStore.getState().setPrefilledConnectionString("postgresql://a@b/c"); + expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://a@b/c"); + useUiStore.getState().clearPrefilledConnectionString(); + expect(useUiStore.getState().prefilledConnectionString).toBeNull(); + }); + + it("sets and clears activeConnectionId", () => { + useUiStore.getState().setActiveConnectionId("c1"); + expect(useUiStore.getState().activeConnectionId).toBe("c1"); + useUiStore.getState().setActiveConnectionId(null); + expect(useUiStore.getState().activeConnectionId).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts new file mode 100644 index 0000000..04e91f2 --- /dev/null +++ b/src/stores/uiStore.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; +import type { ActiveView, DbType } from "../lib/types"; + +interface UiState { + searchQuery: string; + activeFolderId: string | null; + activeTagIds: string[]; + activeDbTypes: DbType[]; + activeView: ActiveView; + selectedItemIds: string[]; + prefilledConnectionString: string | null; + activeConnectionId: string | null; + setActiveView: (view: ActiveView) => void; + setSearchQuery: (q: string) => void; + setActiveFolderId: (id: string | null) => void; + toggleTag: (id: string) => void; + toggleDbType: (type: DbType) => void; + clearFilters: () => void; + toggleItemSelection: (id: string) => void; + selectAllItems: (ids: string[]) => void; + clearSelection: () => void; + setPrefilledConnectionString: (value: string) => void; + clearPrefilledConnectionString: () => void; + setActiveConnectionId: (id: string | null) => void; +} + +export const useUiStore = create((set) => ({ + searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, + setActiveView: (view) => set({ activeView: view }), + setSearchQuery: (q) => set({ searchQuery: q }), + setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }), + toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })), + toggleDbType: (type) => set((s) => ({ activeDbTypes: s.activeDbTypes.includes(type) ? s.activeDbTypes.filter((t) => t !== type) : [...s.activeDbTypes, type] })), + clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null }), + toggleItemSelection: (id) => set((s) => ({ + selectedItemIds: s.selectedItemIds.includes(id) + ? s.selectedItemIds.filter((i) => i !== id) + : [...s.selectedItemIds, id], + })), + selectAllItems: (ids) => set({ selectedItemIds: ids }), + clearSelection: () => set({ selectedItemIds: [] }), + setPrefilledConnectionString: (value) => set({ prefilledConnectionString: value }), + clearPrefilledConnectionString: () => set({ prefilledConnectionString: null }), + setActiveConnectionId: (id) => set({ activeConnectionId: id }), +})); \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..02c423f --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; \ No newline at end of file diff --git a/src/test/smoke.test.tsx b/src/test/smoke.test.tsx new file mode 100644 index 0000000..bb58e1d --- /dev/null +++ b/src/test/smoke.test.tsx @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; + +describe("test infrastructure", () => { + it("vitest is configured with jsdom", () => { + const div = document.createElement("div"); + div.textContent = "hello"; + document.body.appendChild(div); + expect(div).toHaveTextContent("hello"); + }); +}); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index ddad22a..e0cc2e9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ export default defineConfig(async () => ({ - plugins: [react()], + plugins: [tailwindcss(), react()], // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a850efd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + css: false, + }, +}); \ No newline at end of file