diff --git a/AGENTS.md b/AGENTS.md index 8cb7e7d..37087b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,8 +315,9 @@ cargo test # Rust tests ### Demo & Onboarding | Feature | Status | Details | | :--- | :---: | :--- | -| Demo SQLite database (auto-seeded) | ✅ | users, products, orders, order_items tables | -| Re-add demo DB button | ✅ | Settings → Advanced | +| Demo SQLite database (auto-seeded) | ✅ | Feature-rich e-commerce demo (SQLite) exercising every SQLite-available feature: users/products/categories (self-FK)/addresses/orders/order_items (composite PK)/audit_log (500 rows, pagination + virtualization + JSON `details`)/files (BLOB)/page_views (no PK → rowid editing)/app_settings (TEXT PK)/marketing_campaigns (empty → Empty Table change)/order_summary VIEW. JSON columns declared lowercase `json` (popover works), CHECK/UNIQUE/defaults/indexes surface in copied DDL, smart-sort tiers covered (updated_at/created_at/last_login_at/*_id/quantity), nullable FKs + long text for the smart editors. Version-stamped via `PRAGMA user_version`; stale demo files auto-recreate on launch | +| Re-add demo DB button | ✅ | Settings → General → Demo; re-creates the connection (same app-data-dir file as startup) | +| Regenerate demo DB button | ✅ | Settings → General → Demo; confirm dialog, drops the live pool handle, wipes + re-seeds the demo file (clears any edits made against the demo) | | Getting started / onboarding flow | ❌ | | | Welcome tooltips / tour | ❌ | | diff --git a/README.md b/README.md index 96f06c5..87db65b 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ gridline/ - **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination - **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors - **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support -- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database +- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, feature-rich demo SQLite database (12 objects incl. a view, 500-row audit table, JSON/BLOB/composite-PK/no-PK/empty tables) with re-add + regenerate actions in Settings - **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend - **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear - **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) diff --git a/src-tauri/src/commands/demo.rs b/src-tauri/src/commands/demo.rs index 0e6b610..c87dc8d 100644 --- a/src-tauri/src/commands/demo.rs +++ b/src-tauri/src/commands/demo.rs @@ -2,44 +2,49 @@ use crate::models::ConnectionInput; use crate::store::Store; use crate::AppState; use rusqlite::Connection; +use std::path::Path; use std::sync::Mutex; use tauri::Manager; const DEMO_DB_FILENAME: &str = "demo.db"; const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)"; +/// Bump whenever the demo schema or seed data changes so existing demo files +/// are recreated on the next launch. The demo is disposable by design — it +/// should always showcase the current feature set. +const DEMO_SCHEMA_VERSION: i64 = 2; -/// 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() +/// True when the demo file's `PRAGMA user_version` is at or above the current +/// schema version (i.e. the file already carries the full feature set). +fn demo_file_is_current(conn: &Connection) -> Result { + let version: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) .map_err(|e| e.to_string())?; - std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; + Ok(version >= DEMO_SCHEMA_VERSION) +} - 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}"))?; +/// Ensure the demo SQLite file at `path` exists and is seeded with the current +/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo +/// always showcases every feature. +fn ensure_demo_file(path: &Path) -> Result<(), String> { + let stale = path.exists() && { + let conn = Connection::open(path).map_err(|e| e.to_string())?; + !demo_file_is_current(&conn).unwrap_or(false) + }; + if stale { + std::fs::remove_file(path).map_err(|e| e.to_string())?; } + if !path.exists() { + let conn = + Connection::open(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}"))?; + } + Ok(()) +} - // Create the demo connection - let input = ConnectionInput { +/// Build the connection input for the demo SQLite file at `db_path`. +fn demo_connection_input(db_path: &Path) -> ConnectionInput { + ConnectionInput { name: DEMO_CONNECTION_NAME.to_string(), db_type: "sqlite".to_string(), host: db_path.to_string_lossy().to_string(), @@ -61,163 +66,405 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex) -> Re ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None, - }; + } +} +/// Resolve the demo database file under the app data directory (the same +/// location the startup `ensure_demo_db` flow seeds), creating the directory +/// when needed. +fn demo_db_path(app: &tauri::AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; + Ok(data_dir.join(DEMO_DB_FILENAME)) +} + +/// Register the demo connection unless one already exists. +fn ensure_demo_connection(store: &Mutex, db_path: &Path) -> Result<(), String> { + let exists = { + let s = store.lock().map_err(|e| e.to_string())?; + s.get_connections()? + .iter() + .any(|c| c.name == DEMO_CONNECTION_NAME) + }; + if exists { + return Ok(()); + } + let input = demo_connection_input(db_path); let s = store.lock().map_err(|e| e.to_string())?; s.create_connection(input)?; - Ok(()) } +/// Drop any live pool handle for the demo connection so its SQLite file can +/// be deleted and recreated cleanly. +async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> { + let demo_id = { + let s = state.db_store.lock().map_err(|e| e.to_string())?; + s.get_connections()? + .iter() + .find(|c| c.name == DEMO_CONNECTION_NAME) + .map(|c| c.id.clone()) + }; + if let Some(id) = demo_id { + let mut pm = state.pool_manager.lock().await; + pm.remove(&id); + } + Ok(()) +} + +/// Ensure the demo SQLite database exists and a corresponding connection is +/// registered. Safe to call on every app start — it's idempotent, and it +/// upgrades stale demo files to the current schema automatically. +pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex) -> Result<(), String> { + let db_path = demo_db_path(app_handle)?; + ensure_demo_file(&db_path)?; + ensure_demo_connection(store, &db_path) +} + /// Tauri command to re-add the demo connection from the settings screen. +/// Recreates the demo file when it is missing or stale, then registers the +/// connection (always under the app data directory, matching startup). #[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()) +pub fn recreate_demo_db( + app: tauri::AppHandle, + state: tauri::State, +) -> Result { + let db_path = demo_db_path(&app)?; + ensure_demo_file(&db_path)?; + ensure_demo_connection(&state.db_store, &db_path)?; + Ok("Demo database connection re-created.".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 +/// Tauri command to regenerate the demo database from the settings screen: +/// drops any live pool handle, wipes the current file (including any edits +/// made against it) and re-seeds it with fresh demo data. +#[tauri::command] +pub async fn regenerate_demo_db( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let db_path = demo_db_path(&app)?; + disconnect_demo_pool(&state).await?; 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_password: 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(()); + std::fs::remove_file(&db_path).map_err(|e| e.to_string())?; } - - // 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_password: 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(()) + ensure_demo_file(&db_path)?; + ensure_demo_connection(&state.db_store, &db_path)?; + Ok("Demo database regenerated with fresh data.".to_string()) } +/// The demo schema + seed data. Validated to exercise every Gridline feature +/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells, +/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table, +/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for +/// pagination / virtualization / filtering demos. fn get_demo_schema() -> String { - " + r#" + PRAGMA user_version = 2; + + -- ── Core: users ───────────────────────────────────────────────────────── + -- Row editing, JSON popover (preferences), nullable cols (phone/birth_date), + -- long text (bio -> scrolling textarea editor), smart-sort tiers + -- (updated_at / created_at / last_login_at), UNIQUE (email), defaults. 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')) + phone TEXT, + birth_date TEXT, + bio TEXT, + preferences json, + balance REAL NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); - CREATE TABLE IF NOT EXISTS products ( + + -- ── Core: categories (self-referencing FK) ────────────────────────────── + CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - price REAL NOT NULL, - category TEXT NOT NULL, - stock INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER REFERENCES categories(id), + slug TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- ── Core: products ────────────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + price REAL NOT NULL CHECK (price >= 0), + category_id INTEGER REFERENCES categories(id), + stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), + rating REAL, + discontinued INTEGER NOT NULL DEFAULT 0, + attributes json, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── Core: addresses (1:N from users, FK dropdown editor) ──────────────── + CREATE TABLE IF NOT EXISTS addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + label TEXT NOT NULL DEFAULT 'Home', + street TEXT NOT NULL, + city TEXT NOT NULL, + zip TEXT, + country TEXT NOT NULL DEFAULT 'USA', + is_primary INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── Core: orders (two FKs -> users and addresses; CHECK status) ───────── 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')) + shipping_address_id INTEGER REFERENCES addresses(id), + total REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled')), + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + shipped_at TEXT ); + + -- ── Core: order_items (composite PRIMARY KEY; CHECK) ──────────────────── 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 + quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), + unit_price REAL NOT NULL, + PRIMARY KEY (order_id, product_id) ); - 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'); + + -- ── Big table for pagination / virtualization / filtering (500 rows) ──── + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + severity TEXT NOT NULL DEFAULT 'info' + CHECK (severity IN ('info','warning','error','critical')), + details json, + duration_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── BLOB demo ─────────────────────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + content BLOB, + size_bytes INTEGER NOT NULL, + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── No primary key on purpose: exercises the rowid row-locator editing ── + CREATE TABLE IF NOT EXISTS page_views ( + url TEXT NOT NULL, + session_id TEXT NOT NULL, + user_agent TEXT, + viewed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── Non-integer (TEXT) primary key ────────────────────────────────────── + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- ── Deliberately empty: exercises the Empty Table change + empty state ── + CREATE TABLE IF NOT EXISTS marketing_campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + budget REAL, + starts_at TEXT, + ends_at TEXT, + status TEXT NOT NULL DEFAULT 'draft' + ); + + -- ── Read-only view for the query editor / ER diagram ──────────────────── + CREATE VIEW IF NOT EXISTS order_summary AS + SELECT + o.id AS order_id, + u.name AS customer_name, + COUNT(oi.product_id) AS item_count, + o.total AS order_total, + o.status, + o.created_at + FROM orders o + JOIN users u ON u.id = o.user_id + LEFT JOIN order_items oi ON oi.order_id = o.id + GROUP BY o.id, u.name, o.total, o.status, o.created_at; + + -- ── Indexes (surface in the copied DDL) ───────────────────────────────── + CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); + CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id); + CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at); + CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at); + + -- ═══════════════════════════════════════════════════════════════════════ + -- Seed data + -- ═══════════════════════════════════════════════════════════════════════ + + INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES + (1, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12', + 'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.', + '{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}', + 249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')), + (2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30', + 'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.', + '{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}', + 12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')), + (3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19', + 'Occasional shopper who mostly buys desk accessories for her home office in Seattle.', + NULL, + 0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')), + (4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL, + 'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.', + '{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}', + 87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')), + (5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08', + 'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.', + '{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}', + 33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes')); + + INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES + (1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')), + (2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')), + (3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')), + (4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')), + (5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days')); + + INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES + (1, 'SKU-WM-001', 'Wireless Mouse', + 'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.', + 29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')), + (2, 'SKU-MK-001', 'Mechanical Keyboard', + 'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.', + 89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')), + (3, 'SKU-HUB-001', 'USB-C Hub', + 'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.', + 34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')), + (4, 'SKU-MN-001', '27" 4K Monitor', + '27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.', + 449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')), + (5, 'SKU-LS-001', 'Laptop Stand', + 'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.', + 49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')), + (6, 'SKU-WC-001', 'Webcam 1080p', + 'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.', + 59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')), + (7, 'SKU-DL-001', 'Desk Lamp LED', + 'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.', + 39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')), + (8, 'SKU-EC-001', 'Ergonomic Chair', + 'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.', + 599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')), + (9, 'SKU-UC-001', 'USB-C Cable 2m', + 'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.', + 14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')), + (10, 'SKU-MA-001', 'Monitor Arm', + 'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.', + 79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days')); + + INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES + (1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')), + (2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')), + (3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')), + (4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')), + (5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')), + (6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')), + (7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')), + (8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days')); + + INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES + (1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')), + (2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL), + (3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')), + (4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')), + (5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL), + (6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL); + 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 + (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), + (6, 7, 1, 39.99), + (6, 3, 1, 34.99); + + -- 500 generated rows for pagination / virtualization / filter demos. + -- Guarded by NOT EXISTS so re-running the schema never duplicates rows. + WITH RECURSIVE seq(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM seq WHERE n < 500 + ) + INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at) + SELECT + CASE WHEN n % 9 = 0 THEN NULL ELSE (n % 5) + 1 END, + CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update' + WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END, + CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END, + (n % 40) + 1, + CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning' + WHEN 3 THEN 'error' ELSE 'critical' END, + CASE WHEN n % 7 = 0 THEN NULL + ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END, + (n * 37) % 2000, + datetime('now', printf('-%d minutes', n * 7)) + FROM seq + WHERE NOT EXISTS (SELECT 1 FROM audit_log); + + INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES + (1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')), + (2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')), + (3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')), + (4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')), + (5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours')); + + INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM ( + VALUES + ('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')), + ('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')), + ('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')), + ('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')), + ('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')), + ('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')), + ('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')), + ('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')), + ('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')), + ('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')), + ('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')), + ('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes')) + ) WHERE NOT EXISTS (SELECT 1 FROM page_views); + + INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES + ('site_name', 'Gridline Demo Store', datetime('now','-30 days')), + ('maintenance_mode', 'false', datetime('now','-2 days')), + ('max_cart_items', '50', datetime('now','-14 days')), + ('currency', 'USD', datetime('now','-30 days')); + "# + .to_string() +} + +#[cfg(test)] +#[path = "demo.test.rs"] +mod tests; \ No newline at end of file diff --git a/src-tauri/src/commands/demo.test.rs b/src-tauri/src/commands/demo.test.rs new file mode 100644 index 0000000..956df55 --- /dev/null +++ b/src-tauri/src/commands/demo.test.rs @@ -0,0 +1,246 @@ +use super::*; + +/// Open an in-memory SQLite database seeded with the demo schema. +fn seed_demo() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch(&get_demo_schema()).unwrap(); + conn +} + +/// Count rows in a table/view. +fn count(conn: &rusqlite::Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() +} + +#[test] +fn demo_schema_creates_expected_objects() { + let conn = seed_demo(); + let mut stmt = conn + .prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name") + .unwrap(); + let objects: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + let expected: Vec<(&str, &str)> = vec![ + ("addresses", "table"), + ("app_settings", "table"), + ("audit_log", "table"), + ("categories", "table"), + ("files", "table"), + ("marketing_campaigns", "table"), + ("order_items", "table"), + ("order_summary", "view"), + ("orders", "table"), + ("page_views", "table"), + ("products", "table"), + ("users", "table"), + ]; + assert_eq!( + objects, + expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::>(), + "demo schema must contain exactly the expected tables + view" + ); +} + +#[test] +fn demo_schema_seeds_expected_rows() { + let conn = seed_demo(); + assert_eq!(count(&conn, "users"), 5); + assert_eq!(count(&conn, "categories"), 5); + assert_eq!(count(&conn, "products"), 10); + assert_eq!(count(&conn, "addresses"), 8); + assert_eq!(count(&conn, "orders"), 6); + assert_eq!(count(&conn, "order_items"), 10); + assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos"); + assert_eq!(count(&conn, "files"), 5); + assert_eq!(count(&conn, "page_views"), 12); + assert_eq!(count(&conn, "app_settings"), 4); + assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change"); + assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order"); +} + +#[test] +fn demo_schema_is_idempotent() { + let conn = seed_demo(); + // Re-running the full schema (e.g. on a fresh file after a partial seed) + // must not duplicate rows. + conn.execute_batch(&get_demo_schema()).unwrap(); + assert_eq!(count(&conn, "users"), 5); + assert_eq!(count(&conn, "audit_log"), 500); + assert_eq!(count(&conn, "page_views"), 12); + assert_eq!(count(&conn, "products"), 10); +} + +#[test] +fn demo_schema_sets_user_version() { + let conn = seed_demo(); + let v: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, DEMO_SCHEMA_VERSION, "demo file must stamp PRAGMA user_version for upgrade detection"); +} + +#[test] +fn demo_json_columns_hold_valid_json() { + let conn = seed_demo(); + // Every non-NULL value in a `json`-declared column must parse as JSON so + // the grid's JSON popover can format it. + for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] { + let mut stmt = conn + .prepare(&format!( + "SELECT {column} FROM {table} WHERE {column} IS NOT NULL" + )) + .unwrap(); + let values: Vec = stmt + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert!(!values.is_empty(), "{table}.{column} should have non-null values"); + for v in &values { + assert!( + serde_json::from_str::(v).is_ok(), + "{table}.{column} must hold valid JSON, got: {v}" + ); + } + } + // And the declared type must be lowercase `json` so the frontend's + // `data_type === "json"` check triggers the JSON cell popover. + for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] { + let dt: String = conn + .query_row( + &format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"), + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json"); + } +} + +#[test] +fn demo_view_is_queryable() { + let conn = seed_demo(); + let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap(); + let rows: Vec<(i64, String, i64, f64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert_eq!(rows.len(), 6); + let first = &rows[0]; + assert_eq!(first.0, 1); + assert_eq!(first.1, "Alice Johnson"); + assert_eq!(first.2, 2, "order 1 should have 2 line items"); + assert_eq!(first.4, "completed"); +} + +#[test] +fn demo_foreign_keys_are_consistent() { + let conn = seed_demo(); + // PRAGMA foreign_key_check reports any orphaned child rows. + let violations: Vec<(String, i64, String, i64)> = conn + .prepare("PRAGMA foreign_key_check") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert!( + violations.is_empty(), + "seed data must satisfy every FK: {violations:?}" + ); + // audit_log.user_id must be NULL or reference an existing user. + let orphans: i64 = conn + .query_row( + "SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(orphans, 0); +} + +#[test] +fn demo_tables_exercise_key_constraint_shapes() { + let conn = seed_demo(); + // order_items: composite PRIMARY KEY across two columns. SQLite reports + // pk as the 1-based position within the key, so both columns are > 0. + let composite: Vec<(String, bool)> = conn + .prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert_eq!( + composite.iter().map(|(n, _)| n.as_str()).collect::>(), + vec!["order_id", "product_id"] + ); + // page_views: no primary key at all (rowid row-locator editing demo). + let pk_cols: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", [], |r| r.get(0)) + .unwrap(); + assert_eq!(pk_cols, 0, "page_views must have no PK so the rowid locator kicks in"); + // app_settings: TEXT primary key. + let text_pk: String = conn + .query_row( + "SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(text_pk, "key"); + // categories: self-referencing FK. + let self_fk: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(self_fk, 1); +} + +#[test] +fn demo_file_is_recreated_when_stale() { + // A file stamped with an older user_version must be recreated by + // ensure_demo_file; a current file must be left untouched. + let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("demo.db"); + + // Stale file: old version, old schema. + let stale = rusqlite::Connection::open(&path).unwrap(); + stale.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);") + .unwrap(); + drop(stale); + ensure_demo_file(&path).unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap(); + assert_eq!(v, DEMO_SCHEMA_VERSION, "stale demo file must be recreated with the current version"); + assert_eq!(count(&conn, "users"), 5, "recreated file must be fully seeded"); + drop(conn); + + // Current file: ensure_demo_file must not touch it. + ensure_demo_file(&path).unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + assert_eq!(count(&conn, "users"), 5, "current demo file must not be reseeded"); + + // Regenerate flow: the file is deleted entirely, then recreated from + // scratch with the current schema (what `regenerate_demo_db` does). + drop(conn); + std::fs::remove_file(&path).unwrap(); + ensure_demo_file(&path).unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap(); + assert_eq!(v, DEMO_SCHEMA_VERSION); + assert_eq!(count(&conn, "users"), 5); + assert_eq!(count(&conn, "audit_log"), 500); + + std::fs::remove_dir_all(&dir).ok(); +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acd6e43..e1c1c38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -123,6 +123,7 @@ pub fn run() { keychain::get_connection_ssh_passphrase, keychain::delete_connection_ssh_passphrase, demo::recreate_demo_db, + demo::regenerate_demo_db, backup::detect_pg_tools, backup::pg_dump, backup::pg_restore, diff --git a/src/components/settings/GeneralSettingsTab.tsx b/src/components/settings/GeneralSettingsTab.tsx index 5435ec5..783ff06 100644 --- a/src/components/settings/GeneralSettingsTab.tsx +++ b/src/components/settings/GeneralSettingsTab.tsx @@ -1,11 +1,14 @@ import { useSettingsStore } from "../../stores/settingsStore"; import { useConnectionStore } from "../../stores/connectionStore"; +import { useNotificationStore } from "../../stores/notificationStore"; import { Select } from "../ui/Select"; import { ThemePicker } from "../ui/ThemePicker"; import { AccentPicker } from "../ui/AccentPicker"; import { SettingsRow } from "../ui/SettingsRow"; +import { ConfirmDialog } from "../ui/ConfirmDialog"; import * as cmd from "../../lib/commands"; import type { FontSize } from "../../lib/types"; +import { useState } from "react"; const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [ { value: "small", label: "Small" }, @@ -32,6 +35,10 @@ const PAGE_SIZE_OPTIONS = [ export function GeneralSettingsTab() { const { settings, updateSetting, load } = useSettingsStore(); const folders = useConnectionStore((s) => s.folders); + const loadAll = useConnectionStore((s) => s.loadAll); + const notify = useNotificationStore((s) => s.notify); + const [confirmRegenerate, setConfirmRegenerate] = useState(false); + const [regenerating, setRegenerating] = useState(false); if (!settings) return null; @@ -42,10 +49,26 @@ export function GeneralSettingsTab() { const handleReAddDemo = async () => { try { - await cmd.recreateDemoDb(); + const msg = await cmd.recreateDemoDb(); await load(); + await loadAll(); + notify(msg, "success"); } catch (e) { - // ignore + notify(e instanceof Error ? e.message : String(e), "error"); + } + }; + + const handleRegenerateDemo = async () => { + setConfirmRegenerate(false); + setRegenerating(true); + try { + const msg = await cmd.regenerateDemoDb(); + await loadAll(); + notify(msg, "success"); + } catch (e) { + notify(e instanceof Error ? e.message : String(e), "error"); + } finally { + setRegenerating(false); } }; @@ -140,7 +163,28 @@ export function GeneralSettingsTab() { Re-add demo + + + + setConfirmRegenerate(false)} + /> ); diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 3ac084e..2bbbae0 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -76,6 +76,10 @@ export async function recreateDemoDb(): Promise { return invoke("recreate_demo_db"); } +export async function regenerateDemoDb(): Promise { + return invoke("regenerate_demo_db"); +} + // ─── DB Viewer Lifecycle ──────────────────────────────────────── export async function dbConnect(connectionId: string, input: ConnectionInput): Promise {