feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Connection[]> { return invoke<Connection[]>("get_connections"); }
|
||||
export async function createConnection(input: ConnectionInput): Promise<Connection> { return invoke<Connection>("create_connection", { input }); }
|
||||
export async function updateConnection(id: string, input: ConnectionInput): Promise<Connection> { return invoke<Connection>("update_connection", { id, input }); }
|
||||
export async function deleteConnection(id: string): Promise<void> { return invoke<void>("delete_connection", { id }); }
|
||||
export async function addConnectionTags(connectionId: string, tagIds: string[]): Promise<void> { return invoke<void>("add_connection_tags", { connectionId, tagIds }); }
|
||||
export async function getFolders(): Promise<Folder[]> { return invoke<Folder[]>("get_folders"); }
|
||||
export async function createFolder(input: FolderInput): Promise<Folder> { return invoke<Folder>("create_folder", { input }); }
|
||||
export async function updateFolder(id: string, input: FolderInput): Promise<Folder> { return invoke<Folder>("update_folder", { id, input }); }
|
||||
export async function deleteFolder(id: string): Promise<void> { return invoke<void>("delete_folder", { id }); }
|
||||
export async function addFolderTags(folderId: string, tagIds: string[]): Promise<void> { return invoke<void>("add_folder_tags", { folderId, tagIds }); }
|
||||
export async function getTags(): Promise<Tag[]> { return invoke<Tag[]>("get_tags"); }
|
||||
export async function createTag(input: TagInput): Promise<Tag> { return invoke<Tag>("create_tag", { input }); }
|
||||
export async function deleteTag(id: string): Promise<void> { return invoke<void>("delete_tag", { id }); }
|
||||
export async function updateTag(id: string, input: TagInput): Promise<Tag> { return invoke<Tag>("update_tag", { id, input }); }
|
||||
export async function getSettings(): Promise<Settings> { return invoke<Settings>("get_settings"); }
|
||||
export async function updateSetting(key: string, value: string): Promise<void> { return invoke<void>("update_setting", { key, value }); }
|
||||
export async function importConnections(json: string): Promise<ImportResult> { return invoke<ImportResult>("import_connections", { json }); }
|
||||
export async function exportConnections(): Promise<string> { return invoke<string>("export_connections"); }
|
||||
export async function testConnection(input: ConnectionInput): Promise<ConnectionTestResult> {
|
||||
return invoke<ConnectionTestResult>("test_connection", { config: input });
|
||||
}
|
||||
|
||||
// ─── Keychain ──────────────────────────────────────────────────
|
||||
|
||||
export async function saveConnectionPassword(connectionId: string, password: string): Promise<void> {
|
||||
return invoke<void>("save_connection_password", { connectionId, password });
|
||||
}
|
||||
|
||||
export async function getConnectionPassword(connectionId: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_connection_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function deleteConnectionPassword(connectionId: string): Promise<void> {
|
||||
return invoke<void>("delete_connection_password", { connectionId });
|
||||
}
|
||||
|
||||
export async function recreateDemoDb(): Promise<string> {
|
||||
return invoke<string>("recreate_demo_db");
|
||||
}
|
||||
|
||||
// ─── DB Viewer Lifecycle ────────────────────────────────────────
|
||||
|
||||
export async function dbConnect(connectionId: string, input: ConnectionInput): Promise<void> {
|
||||
return invoke<void>("db_connect", { connectionId, config: input });
|
||||
}
|
||||
|
||||
export async function dbDisconnect(connectionId: string): Promise<void> {
|
||||
return invoke<void>("db_disconnect", { connectionId });
|
||||
}
|
||||
|
||||
export async function getDatabases(connectionId: string): Promise<string[]> {
|
||||
return invoke<string[]>("get_databases", { connectionId });
|
||||
}
|
||||
|
||||
export async function getSchemas(connectionId: string): Promise<string[]> {
|
||||
return invoke<string[]>("get_schemas", { connectionId });
|
||||
}
|
||||
|
||||
export async function getTables(connectionId: string, schema?: string): Promise<TableInfo[]> {
|
||||
return invoke<TableInfo[]>("get_tables", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getTableData(
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("get_table_data", { connectionId, schema, table, page, pageSize });
|
||||
}
|
||||
|
||||
export async function executeChange(connectionId: string, change: ChangeItem): Promise<void> {
|
||||
return invoke<void>("execute_change", { connectionId, change });
|
||||
}
|
||||
|
||||
export async function getFkPreview(
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
column: string,
|
||||
value: string,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("get_fk_preview", { connectionId, schema, table, column, value });
|
||||
}
|
||||
|
||||
export async function refreshConnection(connectionId: string): Promise<void> {
|
||||
return invoke<void>("refresh_connection", { connectionId });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, DbType> = {
|
||||
"postgresql:": "postgresql",
|
||||
"postgres:": "postgresql",
|
||||
"mysql:": "mysql",
|
||||
"sqlite:": "sqlite",
|
||||
"file:": "sqlite",
|
||||
"redis:": "redis",
|
||||
"rediss:": "redis",
|
||||
};
|
||||
|
||||
const DEFAULT_PORTS: Record<DbType, number | null> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export const DB_ICONS: Record<DbType, string> = {
|
||||
postgresql: "🐘",
|
||||
mysql: "🐬",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
|
||||
export const DB_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
sqlite: "SQLite",
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Environment = "production" | "staging" | "development" | null;
|
||||
|
||||
export const ENV_LABELS: Record<string, string> = {
|
||||
production: "Production",
|
||||
staging: "Staging",
|
||||
development: "Development",
|
||||
};
|
||||
|
||||
export const ENV_COLORS: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ImportResult | null> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, number | null>;
|
||||
tag_order: string | null;
|
||||
table_refresh_rate: number;
|
||||
table_page_size: number;
|
||||
shortcuts: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
Connection,
|
||||
DbType,
|
||||
Folder,
|
||||
Tag,
|
||||
ConnectionInput,
|
||||
FolderInput,
|
||||
TagInput,
|
||||
ValidationResult,
|
||||
} from "./types";
|
||||
|
||||
// ─── Data type abbreviations ─────────────────────────────
|
||||
|
||||
const TYPE_ABBREV: Record<string, string> = {
|
||||
"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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user