1 Commits

Author SHA1 Message Date
d431352816 Merge pull request 'main' (#2) from main into master
Reviewed-on: #2
2026-04-08 10:04:43 +00:00
164 changed files with 11765 additions and 7964 deletions

View File

@@ -9,15 +9,13 @@ Tusk is a PostgreSQL database management GUI built with Tauri 2 (Rust backend) a
## Development Commands
```bash
npm run app:dev # Start full app in dev mode (Vite HMR + Rust backend) — alias for `tauri dev`
npm run app:bundle # Build production desktop executable — alias for `tauri build`
npm run tauri dev # Start full app in dev mode (Vite HMR + Rust backend)
npm run dev # Start only the Vite frontend dev server (port 5173)
npm run build # Build frontend (tsc + vite build)
npm run tauri build # Build production desktop executable
npm run lint # ESLint
```
`npm run tauri <cmd>` still works for any raw Tauri CLI subcommand.
Rust backend builds automatically via Tauri when running `npm run tauri dev/build`. To check Rust compilation independently:
```bash
cd src-tauri && cargo check
@@ -41,10 +39,10 @@ All frontend-backend communication goes through Tauri's typed IPC. The full pipe
### Backend (src-tauri/src/)
- **`state.rs`** — `AppState`: holds connection pools (`pools`, `ch_clients`), read-only flags, DB flavors (PostgreSQL/Greenplum/ClickHouse), config path, chat-agent caches (`overview_cache`, `tables_by_db_cache`), MCP signal channels, and `ai_settings`. Connections default to read-only.
- **`state.rs`** — `AppState`: holds `RwLock<HashMap<connection_id, PgPool>>`, read-only flags, config path. Connections default to read-only.
- **`error.rs`** — `TuskError` enum with `thiserror`, serialized as string for IPC. `TuskResult<T>` type alias.
- **`utils.rs`** — `escape_ident()` for safe SQL identifier quoting.
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries, ai, chat, chat_tools, memory, settings). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
- **`commands/`** — one module per domain (connections, queries, schema, data, export, management, history, saved_queries). Each function takes `State<'_, AppState>` and returns `TuskResult<T>`.
- **`models/`** — serde-serializable structs matching TypeScript types.
SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterized queries. Read-only mode wraps queries in `SET TRANSACTION READ ONLY` + `ROLLBACK`.
@@ -53,8 +51,7 @@ SQL safety: identifiers use `escape_ident()`, data queries use sqlx parameterize
- **State**: Zustand store (`stores/app-store.ts`) — connections, active connection/database, tabs, read-only flags, pg version.
- **Data fetching**: TanStack React Query hooks in `hooks/` — one hook per domain, wrapping `src/lib/tauri.ts` functions.
- **AI chat**: tool-calling architecture — backend commands in `commands/chat.rs` / `commands/chat_tools.rs`, frontend tool definitions in `components/chat/tool-registry.ts`. Chat panel (`ChatPanel.tsx`) replaces the former inline `AiBar`, explain/fix, and chart-preview UIs.
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, warm dark "Graphite & Honey" theme (IBM Plex Mono, honey accent). CodeMirror SQL editor with a custom theme from `src/lib/editor-theme.ts`.
- **UI**: shadcn/ui + Radix primitives, Tailwind CSS 4, dark mode via next-themes. SQL editor uses CodeMirror.
- **Layout**: resizable panels (sidebar + main area with tab bar).
### Stored Data
@@ -67,4 +64,3 @@ Connections are persisted to `~/.config/tusk/connections.json`. History and save
- **Path alias**: `@` maps to `./src`
- **Rust errors**: always use `TuskError` variants, never `unwrap()` in commands
- **New PG types**: add conversion case in `pg_value_to_json()` in `commands/queries.rs`
- **AI chat tools**: add Rust-side tool implementation in `commands/chat_tools.rs` and register it in both the backend dispatch (`ChatTool`) and the frontend `components/chat/tool-registry.ts` (schema, display component, card builder).

View File

@@ -6,10 +6,7 @@
<title>Tusk</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400;1,500&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

483
package-lock.json generated
View File

@@ -15,17 +15,19 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/dagre": "^0.7.54",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.6.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"sql-formatter": "^15.7.0",
"tailwind-merge": "^3.4.0",
@@ -3653,42 +3655,6 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.7.tgz",
"integrity": "sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -4070,12 +4036,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
@@ -4849,23 +4810,20 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
@@ -4876,40 +4834,35 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
@@ -4970,12 +4923,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -5437,6 +5384,66 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5870,6 +5877,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -6255,18 +6268,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -6276,6 +6277,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -6285,15 +6308,6 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -6306,67 +6320,15 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -6376,6 +6338,51 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -6425,12 +6432,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@@ -6730,16 +6731,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -7030,12 +7021,6 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -7625,6 +7610,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/graphql": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
@@ -7804,16 +7798,6 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -7858,15 +7842,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@@ -8580,6 +8555,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9769,32 +9750,10 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -9901,36 +9860,6 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -9945,21 +9874,6 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -9980,12 +9894,6 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -10685,6 +10593,7 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
@@ -11117,28 +11026,6 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -10,9 +10,7 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"tauri": "tauri",
"app:dev": "tauri dev",
"app:bundle": "tauri build"
"tauri": "tauri"
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
@@ -22,17 +20,19 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"@types/dagre": "^0.7.54",
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.6.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"sql-formatter": "^15.7.0",
"tailwind-merge": "^3.4.0",

1
src-tauri/Cargo.lock generated
View File

@@ -4705,6 +4705,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",

View File

@@ -21,7 +21,7 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros", "process", "io-util"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["serde"] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

View File

@@ -1,39 +0,0 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#27406e"/>
<stop offset="0.55" stop-color="#172f4f"/>
<stop offset="1" stop-color="#0d1730"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.42" r="0.6">
<stop offset="0" stop-color="#3a5fa0" stop-opacity="0.55"/>
<stop offset="1" stop-color="#3a5fa0" stop-opacity="0"/>
</radialGradient>
<linearGradient id="ivory" x1="0.2" y1="0.05" x2="0.85" y2="0.95">
<stop offset="0" stop-color="#fffdf6"/>
<stop offset="0.55" stop-color="#f3e8d2"/>
<stop offset="1" stop-color="#d8c39a"/>
</linearGradient>
<filter id="ds" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#bg)"/>
<rect x="0" y="0" width="1024" height="1024" rx="224" fill="url(#glow)"/>
<g filter="url(#ds)">
<!-- single bold tusk: thick rounded root at upper-right, tapering along a
gentle curve to a sharp tip at lower-left -->
<path d="M 712 408
C 720 600, 560 720, 322 742
C 470 700, 600 470, 628 318
A 60 60 0 0 1 712 408 Z"
fill="url(#ivory)"/>
<!-- soft sheen along the belly (upper-left edge) for ivory depth -->
<path d="M 322 742
C 470 700, 600 470, 628 318
C 612 470, 470 660, 360 726 Z"
fill="#ffffff" opacity="0.18"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
use crate::db::clickhouse::ChClient;
use crate::error::{TuskError, TuskResult};
use crate::models::connection::ConnectionConfig;
use crate::state::{AppState, DbFlavor};
@@ -24,34 +23,6 @@ pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::Pat
Ok(dir.join("connections.json"))
}
/// Read all saved connection configs from disk.
pub(crate) fn load_all_connections(app: &AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
let path = get_connections_path(app)?;
if !path.exists() {
return Ok(vec![]);
}
let data = fs::read_to_string(&path)?;
let connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
Ok(connections)
}
/// Look up a single saved connection by id. Used by tools that need credentials
/// (e.g. switch_database from inside the chat agent loop) but only have the id in scope.
pub(crate) fn load_connection_config(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<ConnectionConfig> {
load_all_connections(app)?
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| {
TuskError::Custom(format!(
"Connection '{}' not found in connections.json",
connection_id
))
})
}
#[tauri::command]
pub async fn get_connections(app: AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
let path = get_connections_path(&app)?;
@@ -84,25 +55,6 @@ pub async fn save_connection(app: AppHandle, config: ConnectionConfig) -> TuskRe
Ok(())
}
async fn close_connection(state: &AppState, id: &str) {
let mut pools = state.pools.write().await;
if let Some(pool) = pools.remove(id) {
pool.close().await;
}
drop(pools);
let mut clients = state.ch_clients.write().await;
clients.remove(id);
drop(clients);
let mut ro = state.read_only.write().await;
ro.remove(id);
drop(ro);
let mut flavors = state.db_flavors.write().await;
flavors.remove(id);
drop(flavors);
state.gp_majors.write().await.remove(id);
state.invalidate_chat_caches_for(id).await;
}
#[tauri::command]
pub async fn delete_connection(
app: AppHandle,
@@ -117,47 +69,36 @@ pub async fn delete_connection(
let data = serde_json::to_string_pretty(&connections)?;
fs::write(&path, data)?;
}
close_connection(&state, &id).await;
// Best-effort cleanup of per-connection persisted state; errors are logged
// but don't block the deletion (the connections.json is the source of truth).
if let Err(e) = crate::commands::memory::delete_memory_core(&app, &id) {
log::warn!("failed to delete memory file for {}: {}", id, e);
}
if let Err(e) =
crate::commands::saved_queries::delete_by_connection_core(&app, &id).await
{
log::warn!("failed to clean saved queries for {}: {}", id, e);
// Close pool if connected
let mut pools = state.pools.write().await;
if let Some(pool) = pools.remove(&id) {
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
let mut flavors = state.db_flavors.write().await;
flavors.remove(&id);
Ok(())
}
#[tauri::command]
pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
match config.db_flavor {
DbFlavor::ClickHouse => {
let client = ChClient::new(
&config.host,
config.port,
config.secure,
&config.user,
&config.password,
&config.database,
);
client.ping().await
}
_ => {
let pool = PgPool::connect(&config.connection_url())
.await
.map_err(TuskError::Database)?;
let row = sqlx::query("SELECT version()")
.fetch_one(&pool)
.await
.map_err(TuskError::Database)?;
let version: String = row.get(0);
pool.close().await;
Ok(version)
}
}
let pool = PgPool::connect(&config.connection_url())
.await
.map_err(TuskError::Database)?;
let row = sqlx::query("SELECT version()")
.fetch_one(&pool)
.await
.map_err(TuskError::Database)?;
let version: String = row.get(0);
pool.close().await;
Ok(version)
}
#[tauri::command]
@@ -165,128 +106,39 @@ pub async fn connect(
state: State<'_, Arc<AppState>>,
config: ConnectionConfig,
) -> TuskResult<ConnectResult> {
match config.db_flavor {
DbFlavor::ClickHouse => {
let client = ChClient::new(
&config.host,
config.port,
config.secure,
&config.user,
&config.password,
&config.database,
);
let version = client.ping().await?;
let arc = Arc::new(client);
state.ch_clients.write().await.insert(config.id.clone(), arc);
state.read_only.write().await.insert(config.id.clone(), true);
state
.db_flavors
.write()
.await
.insert(config.id.clone(), DbFlavor::ClickHouse);
Ok(ConnectResult {
version,
flavor: DbFlavor::ClickHouse,
})
}
_ => {
let pool = PgPool::connect(&config.connection_url())
.await
.map_err(TuskError::Database)?;
sqlx::query("SELECT 1")
.execute(&pool)
.await
.map_err(TuskError::Database)?;
let row = sqlx::query("SELECT version()")
.fetch_one(&pool)
.await
.map_err(TuskError::Database)?;
let version: String = row.get(0);
let flavor = if version.to_lowercase().contains("greenplum") {
DbFlavor::Greenplum
} else {
DbFlavor::PostgreSQL
};
// Pull the GP major (6 or 7) from a string like
// "PostgreSQL 14.4 (Greenplum Database 7.1.0 ...)".
// GP6 and GP7 expose very different system catalogs, so downstream
// schema-introspection code branches on this.
let gp_major: Option<u8> = if flavor == DbFlavor::Greenplum {
version
.split("Greenplum Database ")
.nth(1)
.and_then(|tail| tail.split('.').next())
.and_then(|major| major.parse::<u8>().ok())
} else {
None
};
state.pools.write().await.insert(config.id.clone(), pool);
state.read_only.write().await.insert(config.id.clone(), true);
state
.db_flavors
.write()
.await
.insert(config.id.clone(), flavor);
if let Some(m) = gp_major {
state.gp_majors.write().await.insert(config.id.clone(), m);
} else {
state.gp_majors.write().await.remove(&config.id);
}
Ok(ConnectResult { version, flavor })
}
}
}
let pool = PgPool::connect(&config.connection_url())
.await
.map_err(TuskError::Database)?;
/// Core implementation of switching the active database for a connection.
/// Reusable from both the Tauri command (frontend-driven) and the chat agent
/// loop (model-driven via the switch_database tool).
pub(crate) async fn switch_database_core(
state: &AppState,
config: &ConnectionConfig,
database: &str,
) -> TuskResult<()> {
let mut switched = config.clone();
switched.database = database.to_string();
// Verify connection
sqlx::query("SELECT 1")
.execute(&pool)
.await
.map_err(TuskError::Database)?;
let result: TuskResult<()> = match config.db_flavor {
DbFlavor::ClickHouse => {
let client = ChClient::new(
&switched.host,
switched.port,
switched.secure,
&switched.user,
&switched.password,
&switched.database,
);
client.ping().await?;
state
.ch_clients
.write()
.await
.insert(config.id.clone(), Arc::new(client));
Ok(())
}
_ => {
let pool = PgPool::connect(&switched.connection_url())
.await
.map_err(TuskError::Database)?;
sqlx::query("SELECT 1")
.execute(&pool)
.await
.map_err(TuskError::Database)?;
let mut pools = state.pools.write().await;
if let Some(old_pool) = pools.remove(&config.id) {
old_pool.close().await;
}
pools.insert(config.id.clone(), pool);
Ok(())
}
// Detect database flavor via version()
let row = sqlx::query("SELECT version()")
.fetch_one(&pool)
.await
.map_err(TuskError::Database)?;
let version: String = row.get(0);
let flavor = if version.to_lowercase().contains("greenplum") {
DbFlavor::Greenplum
} else {
DbFlavor::PostgreSQL
};
// Drop every cache that's bound to this connection's previous database.
state.invalidate_chat_caches_for(&config.id).await;
let mut pools = state.pools.write().await;
pools.insert(config.id.clone(), pool);
result
let mut ro = state.read_only.write().await;
ro.insert(config.id.clone(), true);
let mut flavors = state.db_flavors.write().await;
flavors.insert(config.id.clone(), flavor);
Ok(ConnectResult { version, flavor })
}
#[tauri::command]
@@ -295,12 +147,40 @@ pub async fn switch_database(
config: ConnectionConfig,
database: String,
) -> TuskResult<()> {
switch_database_core(&state, &config, &database).await
let mut switched = config.clone();
switched.database = database;
let pool = PgPool::connect(&switched.connection_url())
.await
.map_err(TuskError::Database)?;
sqlx::query("SELECT 1")
.execute(&pool)
.await
.map_err(TuskError::Database)?;
let mut pools = state.pools.write().await;
if let Some(old_pool) = pools.remove(&config.id) {
old_pool.close().await;
}
pools.insert(config.id.clone(), pool);
Ok(())
}
#[tauri::command]
pub async fn disconnect(state: State<'_, Arc<AppState>>, id: String) -> TuskResult<()> {
close_connection(&state, &id).await;
let mut pools = state.pools.write().await;
if let Some(pool) = pools.remove(&id) {
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
let mut flavors = state.db_flavors.write().await;
flavors.remove(&id);
Ok(())
}

View File

@@ -1,7 +1,7 @@
use crate::commands::queries::pg_value_to_json;
use crate::error::{TuskError, TuskResult};
use crate::models::query_result::PaginatedQueryResult;
use crate::state::{AppState, DbFlavor};
use crate::state::AppState;
use crate::utils::escape_ident;
use serde_json::Value;
use sqlx::{Column, Row, TypeInfo};
@@ -9,80 +9,6 @@ use std::sync::Arc;
use std::time::Instant;
use tauri::State;
async fn ch_get_table_data(
state: &AppState,
connection_id: &str,
schema: &str,
table: &str,
page: u32,
page_size: u32,
sort_column: Option<&str>,
sort_direction: Option<&str>,
filter: Option<&str>,
) -> TuskResult<PaginatedQueryResult> {
let client = state.get_ch_client(connection_id).await?;
let qualified = format!(
"{}.{}",
ch_quote_ident(schema),
ch_quote_ident(table)
);
let mut where_clause = String::new();
if let Some(f) = filter {
if !f.trim().is_empty() {
crate::db::sql_guard::ensure_readonly_sql(&format!("SELECT 1 FROM x WHERE {}", f))?;
where_clause = format!(" WHERE {}", f);
}
}
let mut order_clause = String::new();
if let Some(col) = sort_column {
if !col.trim().is_empty() {
let dir = match sort_direction {
Some("DESC") | Some("desc") => "DESC",
_ => "ASC",
};
order_clause = format!(" ORDER BY {} {}", ch_quote_ident(col), dir);
}
}
let offset = (page.saturating_sub(1)) as i64 * page_size as i64;
let data_sql = format!(
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
qualified, where_clause, order_clause, page_size, offset
);
let count_sql = format!("SELECT count() AS c FROM {}{}", qualified, where_clause);
let result = client.execute_query(&data_sql, true).await?;
let count_rows = client.fetch_objects(&count_sql).await?;
let total_rows = count_rows
.first()
.and_then(|o| o.get("c"))
.and_then(|v| match v {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.parse::<i64>().ok(),
_ => None,
})
.unwrap_or(0);
Ok(PaginatedQueryResult {
columns: result.columns,
types: result.types,
rows: result.rows,
row_count: result.row_count,
execution_time_ms: result.execution_time_ms,
total_rows,
page,
page_size,
ctids: vec![],
})
}
fn ch_quote_ident(s: &str) -> String {
let escaped = s.replace('`', "``");
format!("`{}`", escaped)
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn get_table_data(
@@ -96,21 +22,6 @@ pub async fn get_table_data(
sort_direction: Option<String>,
filter: Option<String>,
) -> TuskResult<PaginatedQueryResult> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return ch_get_table_data(
&state,
&connection_id,
&schema,
&table,
page,
page_size,
sort_column.as_deref(),
sort_direction.as_deref(),
filter.as_deref(),
)
.await;
}
let pool = state.get_pool(&connection_id).await?;
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
@@ -136,19 +47,9 @@ pub async fn get_table_data(
let offset = (page.saturating_sub(1)) * page_size;
// Greenplum AO / AOCO tables don't expose `ctid` and even on heap tables
// it isn't a reliable per-row identifier across segments — so on GP we
// skip the ctid column entirely. Edits without a PK will be rejected by
// update_row/delete_rows below with a friendly error.
let include_ctid = !matches!(flavor, DbFlavor::Greenplum);
let select_clause = if include_ctid {
"*, ctid::text"
} else {
"*"
};
let data_sql = format!(
"SELECT {} FROM {}{}{} LIMIT {} OFFSET {}",
select_clause, qualified, where_clause, order_clause, page_size, offset
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
qualified, where_clause, order_clause, page_size, offset
);
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
@@ -173,7 +74,7 @@ pub async fn get_table_data(
tx.rollback().await.map_err(TuskError::Database)?;
let execution_time_ms = start.elapsed().as_millis() as u64;
let execution_time_ms = start.elapsed().as_millis();
let total_rows: i64 = count_row.get(0);
let mut all_columns = Vec::new();
@@ -245,11 +146,6 @@ pub async fn update_row(
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
return Err(TuskError::Custom(
"Inline row edit is not supported for ClickHouse — use SQL ALTER … UPDATE.".into(),
));
}
let pool = state.get_pool(&connection_id).await?;
@@ -258,13 +154,6 @@ pub async fn update_row(
let set_clause = format!("{} = $1", escape_ident(&column));
if pk_columns.is_empty() {
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
return Err(TuskError::Custom(
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
heap-table ctid isn't unique across segments). Inline edit requires \
a primary key — add one or use a SQL UPDATE.".into(),
));
}
// Fallback: use ctid for row identification
let ctid_val = ctid.ok_or_else(|| {
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
@@ -313,11 +202,6 @@ pub async fn insert_row(
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
return Err(TuskError::Custom(
"Inline row insert is not supported for ClickHouse — use SQL INSERT.".into(),
));
}
let pool = state.get_pool(&connection_id).await?;
@@ -356,11 +240,6 @@ pub async fn delete_rows(
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
if matches!(state.get_flavor(&connection_id).await, DbFlavor::ClickHouse) {
return Err(TuskError::Custom(
"Inline row delete is not supported for ClickHouse — use SQL ALTER … DELETE.".into(),
));
}
let pool = state.get_pool(&connection_id).await?;
@@ -371,13 +250,6 @@ pub async fn delete_rows(
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
if pk_columns.is_empty() {
if matches!(state.get_flavor(&connection_id).await, DbFlavor::Greenplum) {
return Err(TuskError::Custom(
"Greenplum doesn't expose a stable ctid (AO/AOCO tables have none, \
heap-table ctid isn't unique across segments). Inline delete requires \
a primary key — add one or use a SQL DELETE.".into(),
));
}
// Fallback: use ctids for row identification
let ctid_list = ctids.ok_or_else(|| {
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
use crate::commands::queries::pg_value_to_json;
use crate::error::TuskResult;
use crate::models::connection::ConnectionConfig;
use crate::models::lookup::{
EntityLookupResult, LookupDatabaseResult, LookupProgress, LookupTableMatch,
};
use crate::utils::escape_ident;
use sqlx::postgres::PgPoolOptions;
use sqlx::{Column, Row, TypeInfo};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tauri::{AppHandle, Emitter};
use tokio::sync::Semaphore;
struct TableCandidate {
schema: String,
table: String,
data_type: String,
}
async fn search_database(
config: &ConnectionConfig,
database: &str,
column_name: &str,
value: &str,
) -> LookupDatabaseResult {
let start = Instant::now();
let mut db_config = config.clone();
db_config.database = database.to_string();
let url = db_config.connection_url();
let pool = match PgPoolOptions::new()
.max_connections(2)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(&url)
.await
{
Ok(p) => p,
Err(e) => {
return LookupDatabaseResult {
database: database.to_string(),
tables: vec![],
error: Some(format!("Connection failed: {}", e)),
search_time_ms: start.elapsed().as_millis(),
};
}
};
let result = tokio::time::timeout(
std::time::Duration::from_secs(120),
search_database_inner(&pool, database, column_name, value),
)
.await;
pool.close().await;
match result {
Ok(db_result) => {
let mut db_result = db_result;
db_result.search_time_ms = start.elapsed().as_millis();
db_result
}
Err(_) => LookupDatabaseResult {
database: database.to_string(),
tables: vec![],
error: Some("Timeout (120s)".to_string()),
search_time_ms: start.elapsed().as_millis(),
},
}
}
async fn search_database_inner(
pool: &sqlx::PgPool,
database: &str,
column_name: &str,
value: &str,
) -> LookupDatabaseResult {
// Find tables that have this column
let candidates = match sqlx::query_as::<_, (String, String, String)>(
"SELECT table_schema, table_name, data_type \
FROM information_schema.columns \
WHERE column_name = $1 \
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit')",
)
.bind(column_name)
.fetch_all(pool)
.await
{
Ok(rows) => rows
.into_iter()
.map(|(schema, table, data_type)| TableCandidate {
schema,
table,
data_type,
})
.collect::<Vec<_>>(),
Err(e) => {
return LookupDatabaseResult {
database: database.to_string(),
tables: vec![],
error: Some(format!("Schema query failed: {}", e)),
search_time_ms: 0,
};
}
};
let mut tables = Vec::new();
for candidate in &candidates {
let qualified = format!(
"{}.{}",
escape_ident(&candidate.schema),
escape_ident(&candidate.table)
);
let col_ident = escape_ident(column_name);
// Read-only transaction: SELECT rows + COUNT
let select_sql = format!(
"SELECT * FROM {} WHERE {}::text = $1 LIMIT 50",
qualified, col_ident
);
let count_sql = format!(
"SELECT COUNT(*) FROM {} WHERE {}::text = $1",
qualified, col_ident
);
let mut tx = match pool.begin().await {
Ok(tx) => tx,
Err(e) => {
tables.push(LookupTableMatch {
schema: candidate.schema.clone(),
table: candidate.table.clone(),
column_type: candidate.data_type.clone(),
columns: vec![],
types: vec![],
rows: vec![],
row_count: 0,
total_count: 0,
});
log::warn!(
"Failed to begin tx for {}.{}: {}",
candidate.schema,
candidate.table,
e
);
continue;
}
};
if let Err(e) = sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
log::warn!("Failed SET TRANSACTION READ ONLY: {}", e);
continue;
}
let rows_result = sqlx::query(&select_sql)
.bind(value)
.fetch_all(&mut *tx)
.await;
let count_result: Result<i64, _> = sqlx::query_scalar(&count_sql)
.bind(value)
.fetch_one(&mut *tx)
.await;
let _ = tx.rollback().await;
match rows_result {
Ok(rows) if !rows.is_empty() => {
let mut col_names = Vec::new();
let mut col_types = Vec::new();
if let Some(first) = rows.first() {
for col in first.columns() {
col_names.push(col.name().to_string());
col_types.push(col.type_info().name().to_string());
}
}
let result_rows: Vec<Vec<serde_json::Value>> = rows
.iter()
.map(|row| {
(0..col_names.len())
.map(|i| pg_value_to_json(row, i))
.collect()
})
.collect();
let row_count = result_rows.len();
let total_count = count_result.unwrap_or(row_count as i64);
tables.push(LookupTableMatch {
schema: candidate.schema.clone(),
table: candidate.table.clone(),
column_type: candidate.data_type.clone(),
columns: col_names,
types: col_types,
rows: result_rows,
row_count,
total_count,
});
}
Ok(_) => {
// No rows matched — skip
}
Err(e) => {
log::warn!(
"Query failed for {}.{}: {}",
candidate.schema,
candidate.table,
e
);
}
}
}
LookupDatabaseResult {
database: database.to_string(),
tables,
error: None,
search_time_ms: 0,
}
}
#[tauri::command]
pub async fn entity_lookup(
app: AppHandle,
config: ConnectionConfig,
column_name: String,
value: String,
databases: Option<Vec<String>>,
lookup_id: String,
) -> TuskResult<EntityLookupResult> {
let start = Instant::now();
// 1. Get list of databases
let url = config.connection_url();
let pool = PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(&url)
.await
.map_err(crate::error::TuskError::Database)?;
let db_names: Vec<String> = sqlx::query_scalar(
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
)
.fetch_all(&pool)
.await
.map_err(crate::error::TuskError::Database)?;
pool.close().await;
// Filter if specific databases requested
let db_names: Vec<String> = if let Some(ref filter) = databases {
db_names
.into_iter()
.filter(|d| filter.contains(d))
.collect()
} else {
db_names
};
let total = db_names.len();
let completed = Arc::new(AtomicUsize::new(0));
let semaphore = Arc::new(Semaphore::new(5));
// 2. Parallel search across databases
let mut handles = Vec::new();
for db_name in db_names {
let config = config.clone();
let column_name = column_name.clone();
let value = value.clone();
let lookup_id = lookup_id.clone();
let app = app.clone();
let semaphore = semaphore.clone();
let completed = completed.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
// Emit "searching" progress
let _ = app.emit(
"lookup-progress",
LookupProgress {
lookup_id: lookup_id.clone(),
database: db_name.clone(),
status: "searching".to_string(),
tables_found: 0,
rows_found: 0,
error: None,
completed: completed.load(Ordering::Relaxed),
total,
},
);
let result = search_database(&config, &db_name, &column_name, &value).await;
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
let status = if result.error.is_some() {
"error"
} else {
"done"
};
let _ = app.emit(
"lookup-progress",
LookupProgress {
lookup_id: lookup_id.clone(),
database: db_name.clone(),
status: status.to_string(),
tables_found: result.tables.len(),
rows_found: result.tables.iter().map(|t| t.row_count).sum(),
error: result.error.clone(),
completed: done,
total,
},
);
result
});
handles.push(handle);
}
// 3. Collect results
let mut all_results = Vec::new();
for handle in handles {
match handle.await {
Ok(result) => all_results.push(result),
Err(e) => {
log::error!("Join error: {}", e);
}
}
}
// Sort: databases with matches first, then by name
all_results.sort_by(|a, b| {
let a_has = !a.tables.is_empty();
let b_has = !b.tables.is_empty();
b_has.cmp(&a_has).then(a.database.cmp(&b.database))
});
let total_databases_searched = all_results.len();
let total_tables_matched: usize = all_results.iter().map(|d| d.tables.len()).sum();
let total_rows_found: usize = all_results
.iter()
.flat_map(|d| d.tables.iter())
.map(|t| t.row_count)
.sum();
Ok(EntityLookupResult {
column_name,
value,
databases: all_results,
total_databases_searched,
total_tables_matched,
total_rows_found,
total_time_ms: start.elapsed().as_millis(),
})
}

View File

@@ -0,0 +1,594 @@
use crate::error::{TuskError, TuskResult};
use crate::models::management::*;
use crate::state::{AppState, DbFlavor};
use crate::utils::escape_ident;
use sqlx::Row;
use std::sync::Arc;
use tauri::State;
#[tauri::command]
pub async fn get_database_info(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<DatabaseInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let rows = sqlx::query(
"SELECT d.datname, \
pg_catalog.pg_get_userbyid(d.datdba) AS owner, \
pg_catalog.pg_encoding_to_char(d.encoding) AS encoding, \
d.datcollate, \
d.datctype, \
COALESCE(t.spcname, 'pg_default') AS tablespace, \
d.datconnlimit, \
pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.datname)) AS size, \
pg_catalog.shobj_description(d.oid, 'pg_database') AS description \
FROM pg_catalog.pg_database d \
LEFT JOIN pg_catalog.pg_tablespace t ON d.dattablespace = t.oid \
WHERE NOT d.datistemplate \
ORDER BY d.datname",
)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let databases = rows
.iter()
.map(|row| DatabaseInfo {
name: row.get("datname"),
owner: row.get("owner"),
encoding: row.get("encoding"),
collation: row.get("datcollate"),
ctype: row.get("datctype"),
tablespace: row.get("tablespace"),
connection_limit: row.get("datconnlimit"),
size: row.get("size"),
description: row.get("description"),
})
.collect();
Ok(databases)
}
#[tauri::command]
pub async fn create_database(
state: State<'_, Arc<AppState>>,
connection_id: String,
params: CreateDatabaseParams,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let mut sql = format!("CREATE DATABASE {}", escape_ident(&params.name));
if let Some(ref owner) = params.owner {
sql.push_str(&format!(" OWNER {}", escape_ident(owner)));
}
if let Some(ref template) = params.template {
sql.push_str(&format!(" TEMPLATE {}", escape_ident(template)));
}
if let Some(ref encoding) = params.encoding {
sql.push_str(&format!(" ENCODING '{}'", encoding.replace('\'', "''")));
}
if let Some(ref tablespace) = params.tablespace {
sql.push_str(&format!(" TABLESPACE {}", escape_ident(tablespace)));
}
if let Some(limit) = params.connection_limit {
sql.push_str(&format!(" CONNECTION LIMIT {}", limit));
}
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn drop_database(
state: State<'_, Arc<AppState>>,
connection_id: String,
name: String,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
// Terminate active connections to the target database
sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1::name AND pid <> pg_backend_pid()")
.bind(&name)
.execute(pool)
.await
.map_err(TuskError::Database)?;
let drop_sql = format!("DROP DATABASE {}", escape_ident(&name));
sqlx::query(&drop_sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn list_roles(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<RoleInfo>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let rows = sqlx::query(
"SELECT r.rolname, \
r.rolsuper, \
r.rolcanlogin, \
r.rolcreatedb, \
r.rolcreaterole, \
r.rolinherit, \
r.rolreplication, \
r.rolconnlimit, \
r.rolpassword IS NOT NULL AS password_set, \
r.rolvaliduntil::text, \
COALESCE(( \
SELECT array_agg(g.rolname ORDER BY g.rolname) \
FROM pg_catalog.pg_auth_members m \
JOIN pg_catalog.pg_roles g ON m.roleid = g.oid \
WHERE m.member = r.oid \
), ARRAY[]::text[]) AS member_of, \
COALESCE(( \
SELECT array_agg(m2.rolname ORDER BY m2.rolname) \
FROM pg_catalog.pg_auth_members am \
JOIN pg_catalog.pg_roles m2 ON am.member = m2.oid \
WHERE am.roleid = r.oid \
), ARRAY[]::text[]) AS members, \
pg_catalog.shobj_description(r.oid, 'pg_authid') AS description \
FROM pg_catalog.pg_roles r \
WHERE r.rolname !~ '^pg_' \
ORDER BY r.rolname",
)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let roles = rows
.iter()
.map(|row| RoleInfo {
name: row.get("rolname"),
is_superuser: row.get("rolsuper"),
can_login: row.get("rolcanlogin"),
can_create_db: row.get("rolcreatedb"),
can_create_role: row.get("rolcreaterole"),
inherit: row.get("rolinherit"),
is_replication: row.get("rolreplication"),
connection_limit: row.get("rolconnlimit"),
password_set: row.get("password_set"),
valid_until: row.get("rolvaliduntil"),
member_of: row.get("member_of"),
members: row.get("members"),
description: row.get("description"),
})
.collect();
Ok(roles)
}
#[tauri::command]
pub async fn create_role(
state: State<'_, Arc<AppState>>,
connection_id: String,
params: CreateRoleParams,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let mut sql = format!("CREATE ROLE {}", escape_ident(&params.name));
let mut options = Vec::new();
options.push(if params.login { "LOGIN" } else { "NOLOGIN" });
options.push(if params.superuser {
"SUPERUSER"
} else {
"NOSUPERUSER"
});
options.push(if params.createdb {
"CREATEDB"
} else {
"NOCREATEDB"
});
options.push(if params.createrole {
"CREATEROLE"
} else {
"NOCREATEROLE"
});
options.push(if params.inherit {
"INHERIT"
} else {
"NOINHERIT"
});
options.push(if params.replication {
"REPLICATION"
} else {
"NOREPLICATION"
});
if let Some(ref password) = params.password {
options.push("PASSWORD");
// Will be appended separately
sql.push_str(&format!(" {}", options.join(" ")));
sql.push_str(&format!(" '{}'", password.replace('\'', "''")));
} else {
sql.push_str(&format!(" {}", options.join(" ")));
}
if let Some(limit) = params.connection_limit {
sql.push_str(&format!(" CONNECTION LIMIT {}", limit));
}
if let Some(ref valid_until) = params.valid_until {
sql.push_str(&format!(
" VALID UNTIL '{}'",
valid_until.replace('\'', "''")
));
}
if !params.in_roles.is_empty() {
let roles: Vec<String> = params.in_roles.iter().map(|r| escape_ident(r)).collect();
sql.push_str(&format!(" IN ROLE {}", roles.join(", ")));
}
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn alter_role(
state: State<'_, Arc<AppState>>,
connection_id: String,
params: AlterRoleParams,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let mut options = Vec::new();
if let Some(login) = params.login {
options.push(if login {
"LOGIN".to_string()
} else {
"NOLOGIN".to_string()
});
}
if let Some(superuser) = params.superuser {
options.push(if superuser {
"SUPERUSER".to_string()
} else {
"NOSUPERUSER".to_string()
});
}
if let Some(createdb) = params.createdb {
options.push(if createdb {
"CREATEDB".to_string()
} else {
"NOCREATEDB".to_string()
});
}
if let Some(createrole) = params.createrole {
options.push(if createrole {
"CREATEROLE".to_string()
} else {
"NOCREATEROLE".to_string()
});
}
if let Some(inherit) = params.inherit {
options.push(if inherit {
"INHERIT".to_string()
} else {
"NOINHERIT".to_string()
});
}
if let Some(replication) = params.replication {
options.push(if replication {
"REPLICATION".to_string()
} else {
"NOREPLICATION".to_string()
});
}
if let Some(ref password) = params.password {
options.push(format!("PASSWORD '{}'", password.replace('\'', "''")));
}
if let Some(limit) = params.connection_limit {
options.push(format!("CONNECTION LIMIT {}", limit));
}
if let Some(ref valid_until) = params.valid_until {
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
}
if !options.is_empty() {
let sql = format!(
"ALTER ROLE {} {}",
escape_ident(&params.name),
options.join(" ")
);
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
}
if let Some(ref new_name) = params.rename_to {
let sql = format!(
"ALTER ROLE {} RENAME TO {}",
escape_ident(&params.name),
escape_ident(new_name)
);
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
}
Ok(())
}
#[tauri::command]
pub async fn drop_role(
state: State<'_, Arc<AppState>>,
connection_id: String,
name: String,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let sql = format!("DROP ROLE {}", escape_ident(&name));
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn get_table_privileges(
state: State<'_, Arc<AppState>>,
connection_id: String,
schema: String,
table: String,
) -> TuskResult<Vec<TablePrivilege>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let rows = sqlx::query(
"SELECT grantee, table_schema, table_name, privilege_type, \
is_grantable = 'YES' AS is_grantable \
FROM information_schema.role_table_grants \
WHERE table_schema = $1 AND table_name = $2 \
ORDER BY grantee, privilege_type",
)
.bind(&schema)
.bind(&table)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let privileges = rows
.iter()
.map(|row| TablePrivilege {
grantee: row.get("grantee"),
table_schema: row.get("table_schema"),
table_name: row.get("table_name"),
privilege_type: row.get("privilege_type"),
is_grantable: row.get("is_grantable"),
})
.collect();
Ok(privileges)
}
#[tauri::command]
pub async fn grant_revoke(
state: State<'_, Arc<AppState>>,
connection_id: String,
params: GrantRevokeParams,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let privs = params.privileges.join(", ");
let object_type = params.object_type.to_uppercase();
let object_ref = escape_ident(&params.object_name);
let role_ref = escape_ident(&params.role_name);
let sql = if params.action.to_uppercase() == "GRANT" {
let grant_option = if params.with_grant_option {
" WITH GRANT OPTION"
} else {
""
};
format!(
"GRANT {} ON {} {} TO {}{}",
privs, object_type, object_ref, role_ref, grant_option
)
} else {
format!(
"REVOKE {} ON {} {} FROM {}",
privs, object_type, object_ref, role_ref
)
};
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn manage_role_membership(
state: State<'_, Arc<AppState>>,
connection_id: String,
params: RoleMembershipParams,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let role_ref = escape_ident(&params.role_name);
let member_ref = escape_ident(&params.member_name);
let sql = if params.action.to_uppercase() == "GRANT" {
format!("GRANT {} TO {}", role_ref, member_ref)
} else {
format!("REVOKE {} FROM {}", role_ref, member_ref)
};
sqlx::query(&sql)
.execute(pool)
.await
.map_err(TuskError::Database)?;
Ok(())
}
#[tauri::command]
pub async fn list_sessions(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<SessionInfo>> {
let flavor = state.get_flavor(&connection_id).await;
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let sql = if flavor == DbFlavor::Greenplum {
"SELECT pid, usename, datname, state, query, \
query_start::text, NULL::text as wait_event_type, NULL::text as wait_event, \
client_addr::text \
FROM pg_stat_activity \
WHERE datname IS NOT NULL \
ORDER BY query_start DESC NULLS LAST"
} else {
"SELECT pid, usename, datname, state, query, \
query_start::text, wait_event_type, wait_event, \
client_addr::text \
FROM pg_stat_activity \
WHERE datname IS NOT NULL \
ORDER BY query_start DESC NULLS LAST"
};
let rows = sqlx::query(sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let sessions = rows
.iter()
.map(|row| SessionInfo {
pid: row.get("pid"),
usename: row.get("usename"),
datname: row.get("datname"),
state: row.get("state"),
query: row.get("query"),
query_start: row.get("query_start"),
wait_event_type: row.get("wait_event_type"),
wait_event: row.get("wait_event"),
client_addr: row.get("client_addr"),
})
.collect();
Ok(sessions)
}
#[tauri::command]
pub async fn cancel_query(
state: State<'_, Arc<AppState>>,
connection_id: String,
pid: i32,
) -> TuskResult<bool> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let row = sqlx::query("SELECT pg_cancel_backend($1)")
.bind(pid)
.fetch_one(pool)
.await
.map_err(TuskError::Database)?;
Ok(row.get::<bool, _>(0))
}
#[tauri::command]
pub async fn terminate_backend(
state: State<'_, Arc<AppState>>,
connection_id: String,
pid: i32,
) -> TuskResult<bool> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let row = sqlx::query("SELECT pg_terminate_backend($1)")
.bind(pid)
.fetch_one(pool)
.await
.map_err(TuskError::Database)?;
Ok(row.get::<bool, _>(0))
}

View File

@@ -1,229 +0,0 @@
//! Per-connection long-term memory for the chat agent (F1).
//!
//! Stored as a markdown file at `<app_data_dir>/memory/<connection_id>.md`.
//! The agent appends notes via the `remember` tool; the user can view and edit
//! the file in the Memory sidebar tab. The same content is injected into the
//! LEARNED NOTES section of the system prompt every turn.
use crate::error::{TuskError, TuskResult};
use chrono::Utc;
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// Soft cap on memory file size. Overflow drops oldest `## ts` blocks until fits.
pub const MEMORY_BYTE_CAP: usize = 16 * 1024;
pub(crate) fn get_memory_path(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<PathBuf> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| TuskError::Config(e.to_string()))?
.join("memory");
fs::create_dir_all(&dir)?;
let safe = sanitize_connection_id(connection_id);
Ok(dir.join(format!("{}.md", safe)))
}
fn sanitize_connection_id(id: &str) -> String {
id.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
_ => '_',
})
.collect()
}
pub(crate) fn read_memory_core(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<String> {
let path = get_memory_path(app, connection_id)?;
if !path.exists() {
return Ok(String::new());
}
Ok(fs::read_to_string(&path)?)
}
pub(crate) fn write_memory_core(
app: &AppHandle,
connection_id: &str,
content: &str,
) -> TuskResult<()> {
let path = get_memory_path(app, connection_id)?;
let trimmed = enforce_size_cap(content, MEMORY_BYTE_CAP);
fs::write(&path, trimmed)?;
Ok(())
}
pub(crate) fn append_memory_core(
app: &AppHandle,
connection_id: &str,
note: &str,
) -> TuskResult<()> {
let trimmed_note = note.trim();
if trimmed_note.is_empty() {
return Err(TuskError::Custom("remember: note must not be empty".into()));
}
let existing = read_memory_core(app, connection_id)?;
let mut buf = String::new();
if existing.is_empty() {
buf.push_str("# Memory\n\n");
} else {
buf.push_str(&existing);
if !buf.ends_with('\n') {
buf.push('\n');
}
if !buf.ends_with("\n\n") {
buf.push('\n');
}
}
let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
buf.push_str(&format!("## {}\n{}\n", ts, trimmed_note));
let final_content = enforce_size_cap(&buf, MEMORY_BYTE_CAP);
let path = get_memory_path(app, connection_id)?;
fs::write(&path, final_content)?;
Ok(())
}
/// Trim the file from the *oldest* note (top) until it fits within `cap` bytes.
/// Always preserves the trailing notes (the most recent observations). Keeps
/// the leading `# Memory\n\n` header if present.
pub(crate) fn enforce_size_cap(content: &str, cap: usize) -> String {
if content.len() <= cap {
return content.to_string();
}
let header = if content.starts_with("# Memory") {
match content.find("\n## ") {
Some(pos) => &content[..pos + 1],
None => "# Memory\n\n",
}
} else {
""
};
// Split into note blocks by "\n## " marker.
// First block (after header) might lack the leading "## " — handle uniformly.
let body_start = header.len();
let body = &content[body_start..];
let mut blocks: Vec<&str> = Vec::new();
let mut idx = 0;
while idx < body.len() {
// Find the next "\n## " starting at idx; if not found, the rest is one block.
let rel = body[idx..].find("\n## ");
match rel {
Some(r) => {
blocks.push(&body[idx..idx + r + 1]); // include trailing newline before next block
idx = idx + r + 1; // start of "## "
}
None => {
blocks.push(&body[idx..]);
break;
}
}
}
// Drop blocks from the front until total fits.
let mut current_size: usize = header.len() + blocks.iter().map(|b| b.len()).sum::<usize>();
let mut start = 0usize;
while current_size > cap && start < blocks.len() {
current_size -= blocks[start].len();
start += 1;
}
let mut out = String::with_capacity(current_size);
out.push_str(header);
for b in &blocks[start..] {
out.push_str(b);
}
out
}
/// Best-effort delete of a connection's memory file. Returns Ok(()) when the
/// file doesn't exist; only surfaces an error for actual filesystem failures.
/// Used by delete_connection to keep memory/ from filling up with orphan files.
pub(crate) fn delete_memory_core(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<()> {
let path = get_memory_path(app, connection_id)?;
if !path.exists() {
return Ok(());
}
fs::remove_file(&path)?;
Ok(())
}
#[tauri::command]
pub async fn get_memory(app: AppHandle, connection_id: String) -> TuskResult<String> {
read_memory_core(&app, &connection_id)
}
#[tauri::command]
pub async fn save_memory(
app: AppHandle,
connection_id: String,
content: String,
) -> TuskResult<()> {
write_memory_core(&app, &connection_id, &content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_passthrough_under_limit() {
let small = "# Memory\n\n## 2026-01-01T00:00:00Z\nshort note\n";
assert_eq!(enforce_size_cap(small, MEMORY_BYTE_CAP), small);
}
#[test]
fn cap_drops_oldest_blocks() {
// 3 blocks of ~6KB each -> 18KB total > 16KB cap
let block_body = "x".repeat(6000);
let content = format!(
"# Memory\n\n## 2026-01-01T00:00:00Z\n{body}\n## 2026-02-01T00:00:00Z\n{body}\n## 2026-03-01T00:00:00Z\n{body}\n",
body = block_body
);
assert!(content.len() > MEMORY_BYTE_CAP);
let trimmed = enforce_size_cap(&content, MEMORY_BYTE_CAP);
assert!(trimmed.len() <= MEMORY_BYTE_CAP);
// Most recent block must survive.
assert!(trimmed.contains("2026-03-01T00:00:00Z"));
// Oldest must be dropped.
assert!(!trimmed.contains("2026-01-01T00:00:00Z"));
// Header preserved.
assert!(trimmed.starts_with("# Memory"));
}
#[test]
fn cap_keeps_only_latest_when_single_block_huge() {
let block_body = "y".repeat(20_000);
let content = format!(
"# Memory\n\n## 2026-01-01T00:00:00Z\n{}\n",
block_body
);
let trimmed = enforce_size_cap(&content, MEMORY_BYTE_CAP);
// Even after dropping that single block we keep at least the header,
// so the result is just the header (or close to it).
assert!(trimmed.starts_with("# Memory"));
assert!(trimmed.len() <= MEMORY_BYTE_CAP);
}
#[test]
fn sanitize_strips_path_chars() {
assert_eq!(sanitize_connection_id("abc/../etc"), "abc____etc");
assert_eq!(
sanitize_connection_id("cf9feefd-59ab-4a7c"),
"cf9feefd-59ab-4a7c"
);
}
}

View File

@@ -1,12 +1,13 @@
pub mod ai;
pub mod chat;
pub mod chat_tools;
pub mod connections;
pub mod data;
pub mod docker;
pub mod export;
pub mod history;
pub mod memory;
pub mod lookup;
pub mod management;
pub mod queries;
pub mod saved_queries;
pub mod schema;
pub mod settings;
pub mod snapshot;

View File

@@ -1,7 +1,6 @@
use crate::db::sql_guard::ensure_readonly_sql;
use crate::error::{TuskError, TuskResult};
use crate::models::query_result::QueryResult;
use crate::state::{AppState, DbFlavor};
use crate::state::AppState;
use serde_json::Value;
use sqlx::postgres::PgRow;
use sqlx::{Column, Row, TypeInfo};
@@ -44,11 +43,6 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
}
"DATE" => try_get!(chrono::NaiveDate),
"TIME" => try_get!(chrono::NaiveTime),
"INTERVAL" => match row.try_get::<Option<sqlx::postgres::types::PgInterval>, _>(index) {
Ok(Some(v)) => return Value::String(format_pg_interval(&v)),
Ok(None) => return Value::Null,
Err(_) => {}
},
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
Ok(None) => return Value::Null,
@@ -81,60 +75,12 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
}
}
/// Render a PostgreSQL INTERVAL the same way `psql` does:
/// "1 year 2 mons 3 days 04:05:06.789012"
/// Components are emitted only when non-zero; the time component fires
/// whenever microseconds != 0 OR everything else is zero.
fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String {
let years = iv.months / 12;
let months = iv.months % 12;
let mut parts: Vec<String> = Vec::new();
if years != 0 {
parts.push(format!("{} year{}", years, if years.abs() == 1 { "" } else { "s" }));
}
if months != 0 {
parts.push(format!("{} mon{}", months, if months.abs() == 1 { "" } else { "s" }));
}
if iv.days != 0 {
parts.push(format!("{} day{}", iv.days, if iv.days.abs() == 1 { "" } else { "s" }));
}
if iv.microseconds != 0 || parts.is_empty() {
let total_us = iv.microseconds.unsigned_abs();
let total_seconds = total_us / 1_000_000;
let micros = (total_us % 1_000_000) as u32;
let h = total_seconds / 3600;
let m = (total_seconds / 60) % 60;
let s = total_seconds % 60;
let sign = if iv.microseconds < 0 { "-" } else { "" };
let time_part = if micros == 0 {
format!("{}{:02}:{:02}:{:02}", sign, h, m, s)
} else {
format!(
"{}{:02}:{:02}:{:02}.{:06}",
sign, h, m, s, micros
)
};
parts.push(time_part);
}
parts.join(" ")
}
pub async fn execute_query_core(
state: &AppState,
connection_id: &str,
sql: &str,
) -> TuskResult<QueryResult> {
let read_only = state.is_read_only(connection_id).await;
let flavor = state.get_flavor(connection_id).await;
if read_only {
ensure_readonly_sql(sql)?;
}
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(connection_id).await?;
return client.execute_query(sql, read_only).await;
}
let pools = state.pools.read().await;
let pool = pools
@@ -160,7 +106,7 @@ pub async fn execute_query_core(
.await
.map_err(TuskError::Database)?
};
let execution_time_ms = start.elapsed().as_millis() as u64;
let execution_time_ms = start.elapsed().as_millis();
let mut columns = Vec::new();
let mut types = Vec::new();
@@ -200,57 +146,3 @@ pub async fn execute_query(
) -> TuskResult<QueryResult> {
execute_query_core(&state, &connection_id, &sql).await
}
#[cfg(test)]
mod tests {
use super::format_pg_interval;
use sqlx::postgres::types::PgInterval;
#[test]
fn interval_zero_renders_as_zero_time() {
let iv = PgInterval { months: 0, days: 0, microseconds: 0 };
assert_eq!(format_pg_interval(&iv), "00:00:00");
}
#[test]
fn interval_pure_time_micros() {
// 1h 30m
let iv = PgInterval { months: 0, days: 0, microseconds: 90 * 60 * 1_000_000 };
assert_eq!(format_pg_interval(&iv), "01:30:00");
}
#[test]
fn interval_days_only() {
let iv = PgInterval { months: 0, days: 3, microseconds: 0 };
assert_eq!(format_pg_interval(&iv), "3 days");
}
#[test]
fn interval_one_day() {
let iv = PgInterval { months: 0, days: 1, microseconds: 0 };
assert_eq!(format_pg_interval(&iv), "1 day");
}
#[test]
fn interval_mixed_components() {
// 1 year 2 mons 3 days 04:05:06
let iv = PgInterval {
months: 14,
days: 3,
microseconds: ((4 * 3600) + (5 * 60) + 6) * 1_000_000,
};
assert_eq!(format_pg_interval(&iv), "1 year 2 mons 3 days 04:05:06");
}
#[test]
fn interval_negative_time() {
let iv = PgInterval { months: 0, days: 0, microseconds: -3_600_000_000 };
assert_eq!(format_pg_interval(&iv), "-01:00:00");
}
#[test]
fn interval_with_microseconds_fraction() {
let iv = PgInterval { months: 0, days: 0, microseconds: 1_500_000 };
assert_eq!(format_pg_interval(&iv), "00:00:01.500000");
}
}

View File

@@ -12,11 +12,12 @@ fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
Ok(dir.join("saved_queries.json"))
}
pub(crate) async fn list_saved_queries_core(
app: &AppHandle,
search: Option<&str>,
#[tauri::command]
pub async fn list_saved_queries(
app: AppHandle,
search: Option<String>,
) -> TuskResult<Vec<SavedQuery>> {
let path = get_saved_queries_path(app)?;
let path = get_saved_queries_path(&app)?;
if !path.exists() {
return Ok(vec![]);
}
@@ -26,7 +27,7 @@ pub(crate) async fn list_saved_queries_core(
let filtered: Vec<SavedQuery> = entries
.into_iter()
.filter(|e| {
if let Some(s) = search {
if let Some(ref s) = search {
let lower = s.to_lowercase();
e.name.to_lowercase().contains(&lower) || e.sql.to_lowercase().contains(&lower)
} else {
@@ -38,8 +39,9 @@ pub(crate) async fn list_saved_queries_core(
Ok(filtered)
}
pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskResult<()> {
let path = get_saved_queries_path(app)?;
#[tauri::command]
pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> {
let path = get_saved_queries_path(&app)?;
let mut entries = if path.exists() {
let data = fs::read_to_string(&path)?;
serde_json::from_str::<Vec<SavedQuery>>(&data).unwrap_or_default()
@@ -54,42 +56,6 @@ pub(crate) async fn save_query_core(app: &AppHandle, query: SavedQuery) -> TuskR
Ok(())
}
/// Drop every saved-query entry tied to a specific connection_id. Entries with
/// `connection_id == None` (older "global" saves) are deliberately preserved
/// so they remain visible across all connections.
pub(crate) async fn delete_by_connection_core(
app: &AppHandle,
connection_id: &str,
) -> TuskResult<()> {
let path = get_saved_queries_path(app)?;
if !path.exists() {
return Ok(());
}
let data = fs::read_to_string(&path)?;
let mut entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
let before = entries.len();
entries.retain(|e| e.connection_id.as_deref() != Some(connection_id));
if entries.len() == before {
return Ok(());
}
let data = serde_json::to_string_pretty(&entries)?;
fs::write(&path, data)?;
Ok(())
}
#[tauri::command]
pub async fn list_saved_queries(
app: AppHandle,
search: Option<String>,
) -> TuskResult<Vec<SavedQuery>> {
list_saved_queries_core(&app, search.as_deref()).await
}
#[tauri::command]
pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> {
save_query_core(&app, query).await
}
#[tauri::command]
pub async fn delete_saved_query(app: AppHandle, id: String) -> TuskResult<()> {
let path = get_saved_queries_path(&app)?;

View File

@@ -1,53 +1,20 @@
use crate::error::{TuskError, TuskResult};
use crate::models::schema::{
ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject, TriggerInfo,
ColumnDetail, ColumnInfo, ConstraintInfo, ErdColumn, ErdData, ErdRelationship, ErdTable,
IndexInfo, SchemaObject, TriggerInfo,
};
use crate::state::{AppState, DbFlavor};
use serde_json::Value;
use sqlx::Row;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::State;
fn ch_string_literal(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
format!("'{}'", escaped)
}
fn ch_obj_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
obj.get(key).and_then(|v| match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
}
fn ch_obj_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
obj.get(key).and_then(|v| match v {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.parse::<i64>().ok(),
_ => None,
})
}
pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
let flavor = state.get_flavor(connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(connection_id).await?;
let rows = client
.fetch_objects(
"SELECT name FROM system.databases \
WHERE name NOT IN ('system','INFORMATION_SCHEMA','information_schema') \
ORDER BY name",
)
.await?;
return Ok(rows
.iter()
.filter_map(|o| ch_obj_string(o, "name"))
.collect());
}
let pool = state.get_pool(connection_id).await?;
#[tauri::command]
pub async fn list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<String>> {
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
"SELECT datname FROM pg_database \
@@ -61,24 +28,10 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> TuskR
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
}
#[tauri::command]
pub async fn list_databases(
state: State<'_, Arc<AppState>>,
connection_id: String,
) -> TuskResult<Vec<String>> {
list_databases_core(&state, &connection_id).await
}
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
let flavor = state.get_flavor(connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
// ClickHouse has no schema layer — surface the active database as a virtual schema.
let client = state.get_ch_client(connection_id).await?;
return Ok(vec![client.database.clone()]);
}
let pool = state.get_pool(connection_id).await?;
let flavor = state.get_flavor(connection_id).await;
let sql = if flavor == DbFlavor::Greenplum {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
@@ -110,29 +63,6 @@ pub async fn list_tables_core(
connection_id: &str,
schema: &str,
) -> TuskResult<Vec<SchemaObject>> {
let flavor = state.get_flavor(connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(connection_id).await?;
let escaped = ch_string_literal(schema);
let sql = format!(
"SELECT name, total_rows, total_bytes FROM system.tables \
WHERE database = {} AND engine NOT LIKE '%View' \
ORDER BY name",
escaped
);
let rows = client.fetch_objects(&sql).await?;
return Ok(rows
.iter()
.map(|o| SchemaObject {
name: ch_obj_string(o, "name").unwrap_or_default(),
object_type: "table".to_string(),
schema: schema.to_string(),
row_count: ch_obj_i64(o, "total_rows"),
size_bytes: ch_obj_i64(o, "total_bytes"),
})
.collect());
}
let pool = state.get_pool(connection_id).await?;
let rows = sqlx::query(
@@ -177,28 +107,6 @@ pub async fn list_views(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(&connection_id).await?;
let sql = format!(
"SELECT name FROM system.tables \
WHERE database = {} AND engine LIKE '%View' \
ORDER BY name",
ch_string_literal(&schema)
);
let rows = client.fetch_objects(&sql).await?;
return Ok(rows
.iter()
.map(|o| SchemaObject {
name: ch_obj_string(o, "name").unwrap_or_default(),
object_type: "view".to_string(),
schema: schema.clone(),
row_count: None,
size_bytes: None,
})
.collect());
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -229,11 +137,6 @@ pub async fn list_functions(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
// ClickHouse functions are global, not schema-scoped — surface empty here.
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -264,10 +167,6 @@ pub async fn list_indexes(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -298,10 +197,6 @@ pub async fn list_sequences(
connection_id: String,
schema: String,
) -> TuskResult<Vec<SchemaObject>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -332,36 +227,6 @@ pub async fn get_table_columns_core(
schema: &str,
table: &str,
) -> TuskResult<Vec<ColumnInfo>> {
let flavor = state.get_flavor(connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(connection_id).await?;
let sql = format!(
"SELECT name, type, default_expression, is_in_primary_key, comment, position \
FROM system.columns WHERE database = {} AND table = {} \
ORDER BY position",
ch_string_literal(schema),
ch_string_literal(table)
);
let rows = client.fetch_objects(&sql).await?;
return Ok(rows
.iter()
.map(|o| {
let type_str = ch_obj_string(o, "type").unwrap_or_default();
let is_nullable = type_str.starts_with("Nullable(");
ColumnInfo {
name: ch_obj_string(o, "name").unwrap_or_default(),
data_type: type_str,
is_nullable,
column_default: ch_obj_string(o, "default_expression"),
ordinal_position: ch_obj_i64(o, "position").unwrap_or(0) as i32,
character_maximum_length: None,
is_primary_key: ch_obj_i64(o, "is_in_primary_key").unwrap_or(0) != 0,
comment: ch_obj_string(o, "comment"),
}
})
.collect());
}
let pool = state.get_pool(connection_id).await?;
let rows = sqlx::query(
@@ -431,10 +296,6 @@ pub async fn get_table_constraints(
schema: String,
table: String,
) -> TuskResult<Vec<ConstraintInfo>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -511,10 +372,6 @@ pub async fn get_table_indexes(
schema: String,
table: String,
) -> TuskResult<Vec<IndexInfo>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -553,25 +410,6 @@ pub async fn get_completion_schema(
connection_id: String,
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let client = state.get_ch_client(&connection_id).await?;
let sql = format!(
"SELECT database, table, name FROM system.columns \
WHERE database = {} \
ORDER BY database, table, position",
ch_string_literal(&client.database)
);
let rows = client.fetch_objects(&sql).await?;
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
for row in rows {
let db = ch_obj_string(&row, "database").unwrap_or_default();
let table = ch_obj_string(&row, "table").unwrap_or_default();
let column = ch_obj_string(&row, "name").unwrap_or_default();
result.entry(db).or_default().entry(table).or_default().push(column);
}
return Ok(result);
}
let pool = state.get_pool(&connection_id).await?;
let sql = if flavor == DbFlavor::Greenplum {
@@ -616,19 +454,6 @@ pub async fn get_column_details(
table: String,
) -> TuskResult<Vec<ColumnDetail>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
let columns = get_table_columns_core(&state, &connection_id, &schema, &table).await?;
return Ok(columns
.into_iter()
.map(|c| ColumnDetail {
column_name: c.name,
data_type: c.data_type,
is_nullable: c.is_nullable,
column_default: c.column_default,
is_identity: false,
})
.collect());
}
let pool = state.get_pool(&connection_id).await?;
let sql = if flavor == DbFlavor::Greenplum {
@@ -675,10 +500,6 @@ pub async fn get_table_triggers(
schema: String,
table: String,
) -> TuskResult<Vec<TriggerInfo>> {
let flavor = state.get_flavor(&connection_id).await;
if matches!(flavor, DbFlavor::ClickHouse) {
return Ok(vec![]);
}
let pool = state.get_pool(&connection_id).await?;
let rows = sqlx::query(
@@ -726,3 +547,127 @@ pub async fn get_table_triggers(
.collect())
}
#[tauri::command]
pub async fn get_schema_erd(
state: State<'_, Arc<AppState>>,
connection_id: String,
schema: String,
) -> TuskResult<ErdData> {
let pool = state.get_pool(&connection_id).await?;
// Get all tables with columns
let col_rows = sqlx::query(
"SELECT \
c.table_name, \
c.column_name, \
c.data_type, \
c.is_nullable = 'YES' AS is_nullable, \
COALESCE(( \
SELECT true FROM pg_constraint con \
JOIN pg_class cl ON cl.oid = con.conrelid \
JOIN pg_namespace ns ON ns.oid = cl.relnamespace \
WHERE con.contype = 'p' \
AND ns.nspname = $1 AND cl.relname = c.table_name \
AND EXISTS ( \
SELECT 1 FROM unnest(con.conkey) k \
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k \
WHERE a.attname = c.column_name \
) \
LIMIT 1 \
), false) AS is_pk \
FROM information_schema.columns c \
JOIN information_schema.tables t \
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
WHERE c.table_schema = $1 AND t.table_type = 'BASE TABLE' \
ORDER BY c.table_name, c.ordinal_position",
)
.bind(&schema)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
// Build tables map
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
for row in &col_rows {
let table_name: String = row.get(0);
let entry = tables_map
.entry(table_name.clone())
.or_insert_with(|| ErdTable {
schema: schema.clone(),
name: table_name,
columns: Vec::new(),
});
entry.columns.push(ErdColumn {
name: row.get(1),
data_type: row.get(2),
is_nullable: row.get(3),
is_primary_key: row.get(4),
});
}
let tables: Vec<ErdTable> = tables_map.into_values().collect();
// Get all FK relationships
let fk_rows = sqlx::query(
"SELECT \
c.conname AS constraint_name, \
src_ns.nspname AS source_schema, \
src_cl.relname AS source_table, \
ARRAY( \
SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum \
ORDER BY k.ord \
)::text[] AS source_columns, \
ref_ns.nspname AS target_schema, \
ref_cl.relname AS target_table, \
ARRAY( \
SELECT a.attname FROM unnest(c.confkey) WITH ORDINALITY AS k(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.attnum \
ORDER BY k.ord \
)::text[] AS target_columns, \
CASE c.confupdtype \
WHEN 'a' THEN 'NO ACTION' \
WHEN 'r' THEN 'RESTRICT' \
WHEN 'c' THEN 'CASCADE' \
WHEN 'n' THEN 'SET NULL' \
WHEN 'd' THEN 'SET DEFAULT' \
END AS update_rule, \
CASE c.confdeltype \
WHEN 'a' THEN 'NO ACTION' \
WHEN 'r' THEN 'RESTRICT' \
WHEN 'c' THEN 'CASCADE' \
WHEN 'n' THEN 'SET NULL' \
WHEN 'd' THEN 'SET DEFAULT' \
END AS delete_rule \
FROM pg_constraint c \
JOIN pg_class src_cl ON src_cl.oid = c.conrelid \
JOIN pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace \
JOIN pg_class ref_cl ON ref_cl.oid = c.confrelid \
JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace \
WHERE c.contype = 'f' AND src_ns.nspname = $1 \
ORDER BY c.conname",
)
.bind(&schema)
.fetch_all(&pool)
.await
.map_err(TuskError::Database)?;
let relationships: Vec<ErdRelationship> = fk_rows
.iter()
.map(|r| ErdRelationship {
constraint_name: r.get(0),
source_schema: r.get(1),
source_table: r.get(2),
source_columns: r.get(3),
target_schema: r.get(4),
target_table: r.get(5),
target_columns: r.get(6),
update_rule: r.get(7),
delete_rule: r.get(8),
})
.collect();
Ok(ErdData {
tables,
relationships,
})
}

View File

@@ -1,6 +1,6 @@
use crate::error::{TuskError, TuskResult};
use crate::mcp;
use crate::models::settings::{AppSettings, McpStatus};
use crate::models::settings::{AppSettings, DockerHost, McpStatus};
use crate::state::AppState;
use std::fs;
use std::sync::Arc;
@@ -36,6 +36,15 @@ pub async fn save_app_settings(
let data = serde_json::to_string_pretty(&settings)?;
fs::write(&path, data)?;
// Apply docker host setting
{
let mut docker_host = state.docker_host.write().await;
*docker_host = match settings.docker.host {
DockerHost::Remote => settings.docker.remote_url.clone(),
DockerHost::Local => None,
};
}
// Apply MCP setting: restart or stop
let is_running = *state.mcp_running.read().await;

View File

@@ -0,0 +1,362 @@
use crate::commands::ai::fetch_foreign_keys_raw;
use crate::commands::data::bind_json_value;
use crate::commands::queries::pg_value_to_json;
use crate::error::{TuskError, TuskResult};
use crate::models::snapshot::{
CreateSnapshotParams, RestoreSnapshotParams, Snapshot, SnapshotMetadata, SnapshotProgress,
SnapshotTableData, SnapshotTableMeta,
};
use crate::state::AppState;
use crate::utils::{escape_ident, topological_sort_tables};
use serde_json::Value;
use sqlx::{Column, Row, TypeInfo};
use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, State};
#[tauri::command]
pub async fn create_snapshot(
app: AppHandle,
state: State<'_, Arc<AppState>>,
params: CreateSnapshotParams,
snapshot_id: String,
file_path: String,
) -> TuskResult<SnapshotMetadata> {
let pool = state.get_pool(&params.connection_id).await?;
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "preparing".to_string(),
percent: 5,
message: "Preparing snapshot...".to_string(),
detail: None,
},
);
let mut target_tables: Vec<(String, String)> = params
.tables
.iter()
.map(|t| (t.schema.clone(), t.table.clone()))
.collect();
// Fetch FK info once — used for both dependency expansion and topological sort
let fk_rows = fetch_foreign_keys_raw(&pool).await?;
if params.include_dependencies {
for fk in &fk_rows {
if target_tables
.iter()
.any(|(s, t)| s == &fk.schema && t == &fk.table)
{
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
if !target_tables.contains(&parent) {
target_tables.push(parent);
}
}
}
}
// FK-based topological sort
let fk_edges: Vec<(String, String, String, String)> = fk_rows
.iter()
.map(|fk| {
(
fk.schema.clone(),
fk.table.clone(),
fk.ref_schema.clone(),
fk.ref_table.clone(),
)
})
.collect();
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
let total_tables = sorted_tables.len();
let mut snapshot_tables: Vec<SnapshotTableData> = Vec::new();
let mut table_metas: Vec<SnapshotTableMeta> = Vec::new();
let mut total_rows: u64 = 0;
for (i, (schema, table)) in sorted_tables.iter().enumerate() {
let percent = (10 + (i * 80 / total_tables.max(1))).min(90) as u8;
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "exporting".to_string(),
percent,
message: format!("Exporting {}.{}...", schema, table),
detail: None,
},
);
let qualified = format!("{}.{}", escape_ident(schema), escape_ident(table));
let sql = format!("SELECT * FROM {}", qualified);
let rows = sqlx::query(&sql)
.fetch_all(&mut *tx)
.await
.map_err(TuskError::Database)?;
let mut columns = Vec::new();
let mut column_types = Vec::new();
if let Some(first) = rows.first() {
for col in first.columns() {
columns.push(col.name().to_string());
column_types.push(col.type_info().name().to_string());
}
}
let data_rows: Vec<Vec<Value>> = rows
.iter()
.map(|row| {
(0..columns.len())
.map(|i| pg_value_to_json(row, i))
.collect()
})
.collect();
let row_count = data_rows.len() as u64;
total_rows += row_count;
table_metas.push(SnapshotTableMeta {
schema: schema.clone(),
table: table.clone(),
row_count,
columns: columns.clone(),
column_types: column_types.clone(),
});
snapshot_tables.push(SnapshotTableData {
schema: schema.clone(),
table: table.clone(),
columns,
column_types,
rows: data_rows,
});
}
tx.rollback().await.map_err(TuskError::Database)?;
let metadata = SnapshotMetadata {
id: snapshot_id.clone(),
name: params.name.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
connection_name: String::new(),
database: String::new(),
tables: table_metas,
total_rows,
file_size_bytes: 0,
version: 1,
};
let snapshot = Snapshot {
metadata: metadata.clone(),
tables: snapshot_tables,
};
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "saving".to_string(),
percent: 95,
message: "Saving snapshot file...".to_string(),
detail: None,
},
);
let json = serde_json::to_string_pretty(&snapshot)?;
let file_size = json.len() as u64;
fs::write(&file_path, json)?;
let mut final_metadata = metadata;
final_metadata.file_size_bytes = file_size;
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "done".to_string(),
percent: 100,
message: "Snapshot created successfully".to_string(),
detail: Some(format!("{} rows, {} tables", total_rows, total_tables)),
},
);
Ok(final_metadata)
}
#[tauri::command]
pub async fn restore_snapshot(
app: AppHandle,
state: State<'_, Arc<AppState>>,
params: RestoreSnapshotParams,
snapshot_id: String,
) -> TuskResult<u64> {
if state.is_read_only(&params.connection_id).await {
return Err(TuskError::ReadOnly);
}
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "reading".to_string(),
percent: 5,
message: "Reading snapshot file...".to_string(),
detail: None,
},
);
let data = fs::read_to_string(&params.file_path)?;
let snapshot: Snapshot = serde_json::from_str(&data)?;
let pool = state.get_pool(&params.connection_id).await?;
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
// TRUNCATE in reverse order (children first)
if params.truncate_before_restore {
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "truncating".to_string(),
percent: 15,
message: "Truncating existing data...".to_string(),
detail: None,
},
);
for table_data in snapshot.tables.iter().rev() {
let qualified = format!(
"{}.{}",
escape_ident(&table_data.schema),
escape_ident(&table_data.table)
);
let truncate_sql = format!("TRUNCATE {} CASCADE", qualified);
sqlx::query(&truncate_sql)
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
}
}
// INSERT in forward order (parents first)
let total_tables = snapshot.tables.len();
let mut total_inserted: u64 = 0;
for (i, table_data) in snapshot.tables.iter().enumerate() {
if table_data.columns.is_empty() || table_data.rows.is_empty() {
continue;
}
let percent = (20 + (i * 75 / total_tables.max(1))).min(95) as u8;
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "inserting".to_string(),
percent,
message: format!("Restoring {}.{}...", table_data.schema, table_data.table),
detail: Some(format!("{} rows", table_data.rows.len())),
},
);
let qualified = format!(
"{}.{}",
escape_ident(&table_data.schema),
escape_ident(&table_data.table)
);
let col_list: Vec<String> = table_data.columns.iter().map(|c| escape_ident(c)).collect();
let placeholders: Vec<String> = (1..=table_data.columns.len())
.map(|i| format!("${}", i))
.collect();
let sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
qualified,
col_list.join(", "),
placeholders.join(", ")
);
// Chunked insert
for row in &table_data.rows {
let mut query = sqlx::query(&sql);
for val in row {
query = bind_json_value(query, val);
}
query.execute(&mut *tx).await.map_err(TuskError::Database)?;
total_inserted += 1;
}
}
tx.commit().await.map_err(TuskError::Database)?;
let _ = app.emit(
"snapshot-progress",
SnapshotProgress {
snapshot_id: snapshot_id.clone(),
stage: "done".to_string(),
percent: 100,
message: "Restore completed successfully".to_string(),
detail: Some(format!("{} rows restored", total_inserted)),
},
);
state.invalidate_schema_cache(&params.connection_id).await;
Ok(total_inserted)
}
#[tauri::command]
pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| TuskError::Config(e.to_string()))?
.join("snapshots");
if !dir.exists() {
return Ok(Vec::new());
}
let mut snapshots = Vec::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(data) = fs::read_to_string(&path) {
if let Ok(snapshot) = serde_json::from_str::<Snapshot>(&data) {
let mut meta = snapshot.metadata;
meta.file_size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
snapshots.push(meta);
}
}
}
}
snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(snapshots)
}
#[tauri::command]
pub async fn read_snapshot_metadata(file_path: String) -> TuskResult<SnapshotMetadata> {
let data = fs::read_to_string(&file_path)?;
let snapshot: Snapshot = serde_json::from_str(&data)?;
let mut meta = snapshot.metadata;
meta.file_size_bytes = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
Ok(meta)
}

View File

@@ -1,168 +0,0 @@
use crate::error::{TuskError, TuskResult};
use crate::models::query_result::QueryResult;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::sync::LazyLock;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
fn http_client() -> &'static reqwest::Client {
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(DEFAULT_TIMEOUT)
.build()
.unwrap_or_default()
});
&CLIENT
}
#[derive(Debug, Clone)]
pub struct ChClient {
pub base_url: String,
pub user: String,
pub password: String,
pub database: String,
}
impl ChClient {
pub fn new(host: &str, port: u16, secure: bool, user: &str, password: &str, database: &str) -> Self {
let scheme = if secure { "https" } else { "http" };
let base_url = format!("{}://{}:{}", scheme, host, port);
Self {
base_url,
user: user.to_string(),
password: password.to_string(),
database: database.to_string(),
}
}
fn endpoint(&self, database: Option<&str>, format: Option<&str>, read_only: bool) -> String {
let db = database.unwrap_or(&self.database);
let mut params = vec![
format!("database={}", urlencode(db)),
format!("user={}", urlencode(&self.user)),
];
if !self.password.is_empty() {
params.push(format!("password={}", urlencode(&self.password)));
}
if let Some(fmt) = format {
params.push(format!("default_format={}", urlencode(fmt)));
}
if read_only {
params.push("readonly=1".to_string());
}
format!("{}/?{}", self.base_url, params.join("&"))
}
/// Execute SQL and return raw response body.
pub async fn execute_raw(&self, sql: &str, format: Option<&str>, read_only: bool) -> TuskResult<String> {
let url = self.endpoint(None, format, read_only);
let resp = http_client()
.post(&url)
.body(sql.to_string())
.send()
.await
.map_err(|e| TuskError::Custom(format!("ClickHouse request failed: {}", e)))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| TuskError::Custom(format!("Failed to read ClickHouse response: {}", e)))?;
if !status.is_success() {
return Err(TuskError::Custom(format!(
"ClickHouse error ({}): {}",
status,
body.trim()
)));
}
Ok(body)
}
/// Test connection by running `SELECT 1` and return the server version.
pub async fn ping(&self) -> TuskResult<String> {
// Use raw FORMAT TabSeparated to fetch version
let body = self.execute_raw("SELECT version()", Some("TabSeparated"), false).await?;
Ok(body.trim().to_string())
}
/// Execute SQL and parse rows via JSONCompact to preserve column metadata + types.
pub async fn execute_query(&self, sql: &str, read_only: bool) -> TuskResult<QueryResult> {
let start = Instant::now();
let body = self.execute_raw(sql, Some("JSONCompact"), read_only).await?;
let execution_time_ms = start.elapsed().as_millis() as u64;
// Empty body for statements without result set (DDL etc.) — return zero rows
if body.trim().is_empty() {
return Ok(QueryResult {
columns: vec![],
types: vec![],
rows: vec![],
row_count: 0,
execution_time_ms,
});
}
let parsed: ChJsonCompactResponse = serde_json::from_str(&body).map_err(|e| {
TuskError::Custom(format!(
"Failed to parse ClickHouse JSONCompact response: {} (body head: {})",
e,
body.chars().take(200).collect::<String>()
))
})?;
let columns: Vec<String> = parsed.meta.iter().map(|m| m.name.clone()).collect();
let types: Vec<String> = parsed.meta.iter().map(|m| m.r#type.clone()).collect();
let row_count = parsed.data.len();
Ok(QueryResult {
columns,
types,
rows: parsed.data,
row_count,
execution_time_ms,
})
}
/// Execute SQL expecting result rows as objects (for schema introspection helpers).
pub async fn fetch_objects(&self, sql: &str) -> TuskResult<Vec<Map<String, Value>>> {
let body = self.execute_raw(sql, Some("JSONEachRow"), false).await?;
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let value: Value = serde_json::from_str(line).map_err(|e| {
TuskError::Custom(format!("Failed to parse JSONEachRow line: {}", e))
})?;
if let Value::Object(obj) = value {
out.push(obj);
}
}
Ok(out)
}
}
#[derive(Debug, Deserialize)]
struct ChJsonCompactResponse {
meta: Vec<ChMetaEntry>,
data: Vec<Vec<Value>>,
}
#[derive(Debug, Deserialize)]
struct ChMetaEntry {
name: String,
r#type: String,
}
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| match c {
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
| '+' | ',' | ';' | '=' | '%' | ' ' => format!("%{:02X}", c as u8),
_ => c.to_string(),
})
.collect()
}

View File

@@ -1,2 +0,0 @@
pub mod clickhouse;
pub mod sql_guard;

View File

@@ -1,140 +0,0 @@
use crate::error::{TuskError, TuskResult};
/// Cross-flavor whitelist guard for read-only SQL execution.
/// Allows: SELECT, WITH ... SELECT, SHOW, EXPLAIN, DESCRIBE.
/// Rejects: INSERT, UPDATE, DELETE, ALTER, DROP, CREATE, TRUNCATE,
/// RENAME, GRANT, REVOKE, ATTACH, DETACH, OPTIMIZE, SYSTEM.
pub fn ensure_readonly_sql(sql: &str) -> TuskResult<()> {
let normalized = strip_leading_comments(sql).to_ascii_uppercase();
let trimmed = normalized.trim();
if trimmed.is_empty() {
return Err(TuskError::Validation("Empty SQL statement".into()));
}
let allowed_starts = ["SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC ", "DESC\n", "VALUES"];
let starts_ok = allowed_starts
.iter()
.any(|p| trimmed.starts_with(p) || trimmed == p.trim());
if !starts_ok {
return Err(TuskError::ReadOnly);
}
// Reject if any forbidden keyword appears as a top-level token
let forbidden = [
"INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "CREATE", "TRUNCATE",
"RENAME", "GRANT", "REVOKE", "ATTACH", "DETACH", "OPTIMIZE", "SYSTEM",
"REPLACE", "MERGE",
];
for kw in forbidden {
if contains_keyword(&normalized, kw) {
return Err(TuskError::ReadOnly);
}
}
Ok(())
}
fn strip_leading_comments(sql: &str) -> &str {
let mut s = sql.trim_start();
loop {
if let Some(rest) = s.strip_prefix("--") {
// line comment — skip to newline
match rest.find('\n') {
Some(idx) => s = rest[idx + 1..].trim_start(),
None => return "",
}
} else if let Some(rest) = s.strip_prefix("/*") {
match rest.find("*/") {
Some(idx) => s = rest[idx + 2..].trim_start(),
None => return "",
}
} else {
break;
}
}
s
}
fn contains_keyword(haystack: &str, kw: &str) -> bool {
let bytes = haystack.as_bytes();
let needle = kw.as_bytes();
let mut i = 0;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle {
let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
let after = i + needle.len();
let after_ok = after == bytes.len() || !is_word_char(bytes[after]);
if before_ok && after_ok {
return true;
}
}
i += 1;
}
false
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_select() {
assert!(ensure_readonly_sql("SELECT 1").is_ok());
assert!(ensure_readonly_sql(" SELECT * FROM t").is_ok());
assert!(ensure_readonly_sql("select 1").is_ok());
}
#[test]
fn allows_with_select() {
assert!(ensure_readonly_sql("WITH x AS (SELECT 1) SELECT * FROM x").is_ok());
}
#[test]
fn allows_explain_show() {
assert!(ensure_readonly_sql("EXPLAIN SELECT 1").is_ok());
assert!(ensure_readonly_sql("SHOW TABLES").is_ok());
assert!(ensure_readonly_sql("DESCRIBE t").is_ok());
}
#[test]
fn rejects_dml_ddl() {
assert!(ensure_readonly_sql("INSERT INTO t VALUES (1)").is_err());
assert!(ensure_readonly_sql("UPDATE t SET a=1").is_err());
assert!(ensure_readonly_sql("DELETE FROM t").is_err());
assert!(ensure_readonly_sql("DROP TABLE t").is_err());
assert!(ensure_readonly_sql("CREATE TABLE t(a int)").is_err());
assert!(ensure_readonly_sql("TRUNCATE TABLE t").is_err());
}
#[test]
fn rejects_writable_cte() {
// PG writable CTE — looks like WITH but contains INSERT
assert!(
ensure_readonly_sql("WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x")
.is_err()
);
}
#[test]
fn rejects_select_with_drop_chain() {
assert!(ensure_readonly_sql("SELECT 1; DROP TABLE t").is_err());
}
#[test]
fn allows_select_with_keyword_in_string() {
// Real-world: column names containing forbidden keywords should pass; string literals
// containing them should also pass. Our guard is conservative and may reject some
// legitimate queries — that is acceptable for the read-only safety net.
// This test documents the limitation: queries embedding "DROP" as a literal will be rejected.
// The user can disable read-only mode to run them.
}
#[test]
fn strips_leading_comments() {
assert!(ensure_readonly_sql("-- comment\nSELECT 1").is_ok());
assert!(ensure_readonly_sql("/* block */ SELECT 1").is_ok());
assert!(ensure_readonly_sql("/* multi\nline */\n SELECT 1").is_ok());
}
}

View File

@@ -11,6 +11,9 @@ pub enum TuskError {
#[error("Serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Connection not found: {0}")]
ConnectionNotFound(String),
#[error("Not connected: {0}")]
NotConnected(String),
@@ -20,6 +23,9 @@ pub enum TuskError {
#[error("AI error: {0}")]
Ai(String),
#[error("Docker error: {0}")]
Docker(String),
#[error("Configuration error: {0}")]
Config(String),

View File

@@ -1,12 +1,11 @@
mod commands;
mod db;
mod error;
mod mcp;
mod models;
mod state;
mod utils;
use models::settings::AppSettings;
use models::settings::{AppSettings, DockerHost};
use state::AppState;
use std::sync::Arc;
use tauri::Manager;
@@ -38,9 +37,21 @@ pub fn run() {
AppSettings::default()
};
// Apply docker host from settings
let docker_host = match settings.docker.host {
DockerHost::Remote => settings.docker.remote_url.clone(),
DockerHost::Local => None,
};
let mcp_enabled = settings.mcp.enabled;
let mcp_port = settings.mcp.port;
// Set docker host synchronously (state is fresh, no contention)
let state_for_setup = state.clone();
tauri::async_runtime::block_on(async {
*state_for_setup.docker_host.write().await = docker_host;
});
if mcp_enabled {
let shutdown_rx = state.mcp_shutdown_tx.subscribe();
let mcp_state = state.clone();
@@ -90,6 +101,7 @@ pub fn run() {
commands::schema::get_completion_schema,
commands::schema::get_column_details,
commands::schema::get_table_triggers,
commands::schema::get_schema_erd,
// data
commands::data::get_table_data,
commands::data::update_row,
@@ -98,6 +110,20 @@ pub fn run() {
// export
commands::export::export_csv,
commands::export::export_json,
// management
commands::management::get_database_info,
commands::management::create_database,
commands::management::drop_database,
commands::management::list_roles,
commands::management::create_role,
commands::management::alter_role,
commands::management::drop_role,
commands::management::get_table_privileges,
commands::management::grant_revoke,
commands::management::manage_role_membership,
commands::management::list_sessions,
commands::management::cancel_query,
commands::management::terminate_backend,
// history
commands::history::add_history_entry,
commands::history::get_history,
@@ -110,14 +136,30 @@ pub fn run() {
commands::ai::get_ai_settings,
commands::ai::save_ai_settings,
commands::ai::list_ollama_models,
commands::ai::list_fireworks_models,
commands::ai::list_openrouter_models,
// chat
commands::chat::chat_send,
commands::chat::chat_compact,
// memory
commands::memory::get_memory,
commands::memory::save_memory,
commands::ai::generate_sql,
commands::ai::explain_sql,
commands::ai::fix_sql_error,
commands::ai::generate_validation_sql,
commands::ai::run_validation_rule,
commands::ai::suggest_validation_rules,
commands::ai::generate_test_data_preview,
commands::ai::insert_generated_data,
commands::ai::get_index_advisor_report,
commands::ai::apply_index_recommendation,
// snapshot
commands::snapshot::create_snapshot,
commands::snapshot::restore_snapshot,
commands::snapshot::list_snapshots,
commands::snapshot::read_snapshot_metadata,
// lookup
commands::lookup::entity_lookup,
// docker
commands::docker::check_docker,
commands::docker::list_tusk_containers,
commands::docker::clone_to_docker,
commands::docker::start_container,
commands::docker::stop_container,
commands::docker::remove_container,
// settings
commands::settings::get_app_settings,
commands::settings::save_app_settings,

View File

@@ -1,36 +1,20 @@
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AiProvider {
#[default]
Ollama,
Fireworks,
OpenRouter,
}
/// Deserialize a provider string, coercing legacy `openai`/`anthropic` and any
/// unknown value to `Ollama`. Keeps existing config files loadable after the
/// stub providers were removed.
impl<'de> Deserialize<'de> for AiProvider {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(match s.as_str() {
"fireworks" => AiProvider::Fireworks,
"openrouter" => AiProvider::OpenRouter,
_ => AiProvider::Ollama,
})
}
OpenAi,
Anthropic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiSettings {
pub provider: AiProvider,
pub ollama_url: String,
#[serde(default)]
pub fireworks_api_key: Option<String>,
#[serde(default)]
pub openrouter_api_key: Option<String>,
pub openai_api_key: Option<String>,
pub anthropic_api_key: Option<String>,
pub model: String,
}
@@ -39,15 +23,13 @@ impl Default for AiSettings {
Self {
provider: AiProvider::Ollama,
ollama_url: "http://localhost:11434".to_string(),
fireworks_api_key: None,
openrouter_api_key: None,
openai_api_key: None,
anthropic_api_key: None,
model: String::new(),
}
}
}
/// Generic chat message used by all chat providers (Ollama, Fireworks, OpenAI-compatible).
/// `{role, content}` shape is identical across these APIs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaChatMessage {
pub role: String,
@@ -59,8 +41,6 @@ pub struct OllamaChatRequest {
pub model: String,
pub messages: Vec<OllamaChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -73,50 +53,134 @@ pub struct OllamaTagsResponse {
pub models: Vec<OllamaModel>,
}
/// Generic chat-model descriptor exposed to the UI dropdown.
/// Reused as the return shape for both Ollama and Fireworks model listings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaModel {
pub name: String,
}
// ---------------------------------------------------------------------------
// OpenAI-compatible chat-completions (Fireworks, OpenRouter)
// These request/response shapes are shared by every OpenAI-compatible provider;
// the `Fireworks*` names are retained for historical reasons.
// ---------------------------------------------------------------------------
// --- Wave 1: Validation ---
#[derive(Debug, Clone, Serialize)]
pub struct FireworksChatRequest {
pub model: String,
pub messages: Vec<OllamaChatMessage>,
pub temperature: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<FireworksResponseFormat>,
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationStatus {
Pending,
Generating,
Running,
Passed,
Failed,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct FireworksResponseFormat {
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Debug, Deserialize)]
pub struct FireworksChatResponse {
pub choices: Vec<FireworksChoice>,
}
#[derive(Debug, Deserialize)]
pub struct FireworksChoice {
pub message: OllamaChatMessage,
}
#[derive(Debug, Deserialize)]
pub struct FireworksModelsResponse {
pub data: Vec<FireworksModelEntry>,
}
#[derive(Debug, Deserialize)]
pub struct FireworksModelEntry {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationRule {
pub id: String,
pub description: String,
pub generated_sql: String,
pub status: ValidationStatus,
pub violation_count: u64,
pub sample_violations: Vec<Vec<serde_json::Value>>,
pub violation_columns: Vec<String>,
pub error: Option<String>,
}
// --- Wave 2: Data Generator ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateDataParams {
pub connection_id: String,
pub schema: String,
pub table: String,
pub row_count: u32,
pub include_related: bool,
pub custom_instructions: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedDataPreview {
pub tables: Vec<GeneratedTableData>,
pub insert_order: Vec<String>,
pub total_rows: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedTableData {
pub schema: String,
pub table: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
pub row_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataGenProgress {
pub gen_id: String,
pub stage: String,
pub percent: u8,
pub message: String,
pub detail: Option<String>,
}
// --- Wave 3A: Index Advisor ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableStats {
pub schema: String,
pub table: String,
pub seq_scan: i64,
pub idx_scan: i64,
pub n_live_tup: i64,
pub table_size: String,
pub index_size: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexStats {
pub schema: String,
pub table: String,
pub index_name: String,
pub idx_scan: i64,
pub index_size: String,
pub definition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlowQuery {
pub query: String,
pub calls: i64,
pub total_time_ms: f64,
pub mean_time_ms: f64,
pub rows: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexRecommendationType {
#[serde(rename = "create_index")]
Create,
#[serde(rename = "drop_index")]
Drop,
#[serde(rename = "replace_index")]
Replace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexRecommendation {
pub id: String,
pub recommendation_type: IndexRecommendationType,
pub table_schema: String,
pub table_name: String,
pub index_name: Option<String>,
pub ddl: String,
pub rationale: String,
pub estimated_impact: String,
pub priority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexAdvisorReport {
pub table_stats: Vec<TableStats>,
pub index_stats: Vec<IndexStats>,
pub slow_queries: Vec<SlowQuery>,
pub recommendations: Vec<IndexRecommendation>,
pub has_pg_stat_statements: bool,
}

View File

@@ -1,33 +0,0 @@
use crate::models::query_result::QueryResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
User { id: String, text: String, created_at: i64 },
Assistant { id: String, text: String, created_at: i64 },
ToolCall { id: String, tool: String, input_json: String, created_at: i64 },
ToolResult {
id: String,
tool: String,
is_error: bool,
text: Option<String>,
result: Option<QueryResult>,
created_at: i64,
},
}
/// Approximate model-context budget usage for the current chat thread.
/// Measured in characters of the serialized history that we send to the LLM.
/// Token estimate ≈ used_chars / 3 for mixed Cyrillic/ASCII content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextUsage {
pub used_chars: u64,
pub budget_chars: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatTurnResult {
pub messages: Vec<ChatMessage>,
pub usage: ContextUsage,
}

View File

@@ -1,4 +1,3 @@
use crate::state::DbFlavor;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -13,17 +12,6 @@ pub struct ConnectionConfig {
pub ssl_mode: Option<String>,
pub color: Option<String>,
pub environment: Option<String>,
/// Database flavor selected by the user. Defaults to PostgreSQL for backwards
/// compatibility with older `connections.json` files written before multi-DB support.
#[serde(default = "default_flavor")]
pub db_flavor: DbFlavor,
/// HTTPS for ClickHouse. Defaults to false.
#[serde(default)]
pub secure: bool,
}
fn default_flavor() -> DbFlavor {
DbFlavor::PostgreSQL
}
impl ConnectionConfig {

View File

@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerStatus {
pub installed: bool,
pub daemon_running: bool,
pub version: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CloneMode {
SchemaOnly,
FullClone,
SampleData,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CloneToDockerParams {
pub source_connection_id: String,
pub source_database: String,
pub container_name: String,
pub pg_version: String,
pub host_port: Option<u16>,
pub clone_mode: CloneMode,
pub sample_rows: Option<u32>,
pub postgres_password: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CloneProgress {
pub clone_id: String,
pub stage: String,
pub percent: u8,
pub message: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuskContainer {
pub container_id: String,
pub name: String,
pub status: String,
pub host_port: u16,
pub pg_version: String,
pub source_database: Option<String>,
pub source_connection: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CloneResult {
pub container: TuskContainer,
pub connection_id: String,
pub connection_url: String,
}

View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LookupTableMatch {
pub schema: String,
pub table: String,
pub column_type: String,
pub columns: Vec<String>,
pub types: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
pub row_count: usize,
pub total_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LookupDatabaseResult {
pub database: String,
pub tables: Vec<LookupTableMatch>,
pub error: Option<String>,
pub search_time_ms: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityLookupResult {
pub column_name: String,
pub value: String,
pub databases: Vec<LookupDatabaseResult>,
pub total_databases_searched: usize,
pub total_tables_matched: usize,
pub total_rows_found: usize,
pub total_time_ms: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LookupProgress {
pub lookup_id: String,
pub database: String,
pub status: String,
pub tables_found: usize,
pub rows_found: usize,
pub error: Option<String>,
pub completed: usize,
pub total: usize,
}

View File

@@ -0,0 +1,110 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DatabaseInfo {
pub name: String,
pub owner: String,
pub encoding: String,
pub collation: String,
pub ctype: String,
pub tablespace: String,
pub connection_limit: i32,
pub size: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDatabaseParams {
pub name: String,
pub owner: Option<String>,
pub template: Option<String>,
pub encoding: Option<String>,
pub tablespace: Option<String>,
pub connection_limit: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoleInfo {
pub name: String,
pub is_superuser: bool,
pub can_login: bool,
pub can_create_db: bool,
pub can_create_role: bool,
pub inherit: bool,
pub is_replication: bool,
pub connection_limit: i32,
pub password_set: bool,
pub valid_until: Option<String>,
pub member_of: Vec<String>,
pub members: Vec<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateRoleParams {
pub name: String,
pub password: Option<String>,
pub login: bool,
pub superuser: bool,
pub createdb: bool,
pub createrole: bool,
pub inherit: bool,
pub replication: bool,
pub connection_limit: Option<i32>,
pub valid_until: Option<String>,
pub in_roles: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct AlterRoleParams {
pub name: String,
pub password: Option<String>,
pub login: Option<bool>,
pub superuser: Option<bool>,
pub createdb: Option<bool>,
pub createrole: Option<bool>,
pub inherit: Option<bool>,
pub replication: Option<bool>,
pub connection_limit: Option<i32>,
pub valid_until: Option<String>,
pub rename_to: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TablePrivilege {
pub grantee: String,
pub table_schema: String,
pub table_name: String,
pub privilege_type: String,
pub is_grantable: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionInfo {
pub pid: i32,
pub usename: Option<String>,
pub datname: Option<String>,
pub state: Option<String>,
pub query: Option<String>,
pub query_start: Option<String>,
pub wait_event_type: Option<String>,
pub wait_event: Option<String>,
pub client_addr: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GrantRevokeParams {
pub action: String,
pub privileges: Vec<String>,
pub object_type: String,
pub object_name: String,
pub role_name: String,
pub with_grant_option: bool,
}
#[derive(Debug, Deserialize)]
pub struct RoleMembershipParams {
pub action: String,
pub role_name: String,
pub member_name: String,
}

View File

@@ -1,8 +1,11 @@
pub mod ai;
pub mod chat;
pub mod connection;
pub mod docker;
pub mod history;
pub mod lookup;
pub mod management;
pub mod query_result;
pub mod saved_queries;
pub mod schema;
pub mod settings;
pub mod snapshot;

View File

@@ -1,15 +1,13 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// Tauri's IPC layer does not support u128/i128 in command arguments,
// so timings round-trip through frontend → backend as u64 ms.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResult {
pub columns: Vec<String>,
pub types: Vec<String>,
pub rows: Vec<Vec<Value>>,
pub row_count: usize,
pub execution_time_ms: u64,
pub execution_time_ms: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -18,7 +16,7 @@ pub struct PaginatedQueryResult {
pub types: Vec<String>,
pub rows: Vec<Vec<Value>>,
pub row_count: usize,
pub execution_time_ms: u64,
pub execution_time_ms: u128,
pub total_rows: i64,
pub page: u32,
pub page_size: u32,

View File

@@ -60,3 +60,37 @@ pub struct TriggerInfo {
pub is_enabled: bool,
pub definition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdColumn {
pub name: String,
pub data_type: String,
pub is_nullable: bool,
pub is_primary_key: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdTable {
pub schema: String,
pub name: String,
pub columns: Vec<ErdColumn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdRelationship {
pub constraint_name: String,
pub source_schema: String,
pub source_table: String,
pub source_columns: Vec<String>,
pub target_schema: String,
pub target_table: String,
pub target_columns: Vec<String>,
pub update_rule: String,
pub delete_rule: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErdData {
pub tables: Vec<ErdTable>,
pub relationships: Vec<ErdRelationship>,
}

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppSettings {
pub mcp: McpSettings,
pub docker: DockerSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +21,28 @@ impl Default for McpSettings {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerSettings {
pub host: DockerHost,
pub remote_url: Option<String>,
}
impl Default for DockerSettings {
fn default() -> Self {
Self {
host: DockerHost::Local,
remote_url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DockerHost {
Local,
Remote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStatus {
pub enabled: bool,

View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMetadata {
pub id: String,
pub name: String,
pub created_at: String,
pub connection_name: String,
pub database: String,
pub tables: Vec<SnapshotTableMeta>,
pub total_rows: u64,
pub file_size_bytes: u64,
pub version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotTableMeta {
pub schema: String,
pub table: String,
pub row_count: u64,
pub columns: Vec<String>,
pub column_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub metadata: SnapshotMetadata,
pub tables: Vec<SnapshotTableData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotTableData {
pub schema: String,
pub table: String,
pub columns: Vec<String>,
pub column_types: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotProgress {
pub snapshot_id: String,
pub stage: String,
pub percent: u8,
pub message: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSnapshotParams {
pub connection_id: String,
pub tables: Vec<TableRef>,
pub name: String,
pub include_dependencies: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableRef {
pub schema: String,
pub table: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreSnapshotParams {
pub connection_id: String,
pub file_path: String,
pub truncate_before_restore: bool,
}

View File

@@ -1,11 +1,9 @@
use crate::db::clickhouse::ChClient;
use crate::error::{TuskError, TuskResult};
use crate::models::ai::AiSettings;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio::sync::{watch, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -13,67 +11,43 @@ use tokio::sync::{watch, RwLock};
pub enum DbFlavor {
PostgreSQL,
Greenplum,
ClickHouse,
}
#[derive(Clone)]
pub struct CachedString {
pub value: String,
pub cached_at: Instant,
}
#[derive(Clone)]
pub struct CachedVec<T: Clone> {
pub value: Vec<T>,
pub struct SchemaCacheEntry {
pub schema_text: String,
pub cached_at: Instant,
}
pub struct AppState {
pub pools: RwLock<HashMap<String, PgPool>>,
pub ch_clients: RwLock<HashMap<String, Arc<ChClient>>>,
pub read_only: RwLock<HashMap<String, bool>>,
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
/// Greenplum major version (6 or 7), tracked separately because GP6 and GP7
/// expose very different system catalogs (GP6 = PG9.4 base, GP7 = PG14 base).
pub gp_majors: RwLock<HashMap<String, u8>>,
/// Chat agent caches: lite overview per connection.
pub overview_cache: RwLock<HashMap<String, CachedString>>,
/// Chat agent caches: list of tables per (connection_id, db_name) — used for
/// list_tables on a non-active PG database via temporary pool.
pub tables_by_db_cache: RwLock<HashMap<(String, String), CachedVec<String>>>,
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
pub mcp_shutdown_tx: watch::Sender<bool>,
pub mcp_running: RwLock<bool>,
pub docker_host: RwLock<Option<String>>,
pub ai_settings: RwLock<Option<AiSettings>>,
}
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
impl AppState {
pub fn new() -> Self {
let (mcp_shutdown_tx, _) = watch::channel(false);
Self {
pools: RwLock::new(HashMap::new()),
ch_clients: RwLock::new(HashMap::new()),
read_only: RwLock::new(HashMap::new()),
db_flavors: RwLock::new(HashMap::new()),
gp_majors: RwLock::new(HashMap::new()),
overview_cache: RwLock::new(HashMap::new()),
tables_by_db_cache: RwLock::new(HashMap::new()),
schema_cache: RwLock::new(HashMap::new()),
mcp_shutdown_tx,
mcp_running: RwLock::new(false),
docker_host: RwLock::new(None),
ai_settings: RwLock::new(None),
}
}
/// Drop every chat-agent cache entry tied to this connection.
/// Called by switch_database_core, disconnect, and on connection delete.
pub async fn invalidate_chat_caches_for(&self, connection_id: &str) {
self.overview_cache.write().await.remove(connection_id);
self.tables_by_db_cache
.write()
.await
.retain(|(cid, _), _| cid != connection_id);
}
pub async fn get_pool(&self, connection_id: &str) -> TuskResult<PgPool> {
let pools = self.pools.read().await;
pools
@@ -82,14 +56,6 @@ impl AppState {
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
}
pub async fn get_ch_client(&self, connection_id: &str) -> TuskResult<Arc<ChClient>> {
let clients = self.ch_clients.read().await;
clients
.get(connection_id)
.cloned()
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))
}
pub async fn is_read_only(&self, id: &str) -> bool {
let map = self.read_only.read().await;
map.get(id).copied().unwrap_or(true)
@@ -100,9 +66,42 @@ impl AppState {
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
}
/// Returns the Greenplum major version (6 or 7) for a connection, or None
/// for non-GP connections / when the version string couldn't be parsed.
pub async fn get_gp_major(&self, id: &str) -> Option<u8> {
self.gp_majors.read().await.get(id).copied()
pub async fn get_schema_cache(&self, connection_id: &str) -> Option<String> {
let cache = self.schema_cache.read().await;
cache.get(connection_id).and_then(|entry| {
if entry.cached_at.elapsed() < SCHEMA_CACHE_TTL {
Some(entry.schema_text.clone())
} else {
None
}
})
}
pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) {
let mut cache = self.schema_cache.write().await;
// Evict stale entries to prevent unbounded memory growth
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
// If still at capacity, remove the oldest entry
if cache.len() >= SCHEMA_CACHE_MAX_SIZE {
if let Some(oldest_key) = cache
.iter()
.min_by_key(|(_, e)| e.cached_at)
.map(|(k, _)| k.clone())
{
cache.remove(&oldest_key);
}
}
cache.insert(
connection_id,
SchemaCacheEntry {
schema_text,
cached_at: Instant::now(),
},
);
}
pub async fn invalidate_schema_cache(&self, connection_id: &str) {
let mut cache = self.schema_cache.write().await;
cache.remove(connection_id);
}
}

View File

@@ -1,11 +1,95 @@
use std::collections::{HashMap, HashSet, VecDeque};
pub fn escape_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Topological sort of tables based on foreign key dependencies.
/// Returns tables in insertion order: parents before children.
pub fn topological_sort_tables(
fk_edges: &[(String, String, String, String)], // (schema, table, ref_schema, ref_table)
target_tables: &[(String, String)],
) -> Vec<(String, String)> {
let mut graph: HashMap<(String, String), HashSet<(String, String)>> = HashMap::new();
let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
// Initialize all target tables
for t in target_tables {
graph.entry(t.clone()).or_default();
in_degree.entry(t.clone()).or_insert(0);
}
let target_set: HashSet<(String, String)> = target_tables.iter().cloned().collect();
// Build edges: parent -> child (child depends on parent)
for (schema, table, ref_schema, ref_table) in fk_edges {
let child = (schema.clone(), table.clone());
let parent = (ref_schema.clone(), ref_table.clone());
if child == parent {
continue; // self-referencing
}
if !target_set.contains(&child) || !target_set.contains(&parent) {
continue;
}
if graph
.entry(parent.clone())
.or_default()
.insert(child.clone())
{
*in_degree.entry(child).or_insert(0) += 1;
}
}
// Kahn's algorithm
let mut initial: Vec<(String, String)> = in_degree
.iter()
.filter(|(_, &deg)| deg == 0)
.map(|(k, _)| k.clone())
.collect();
initial.sort(); // deterministic order
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.clone());
if let Some(neighbors) = graph.get(&node) {
let mut new_ready: Vec<(String, String)> = neighbors
.iter()
.filter(|neighbor| {
if let Some(deg) = in_degree.get_mut(*neighbor) {
*deg -= 1;
*deg == 0
} else {
false
}
})
.cloned()
.collect();
new_ready.sort();
queue.extend(new_ready);
}
}
// Add any remaining tables (cycles) at the end
for t in target_tables {
if !result.contains(t) {
result.push(t.clone());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// ── escape_ident ──────────────────────────────────────────
#[test]
fn escape_ident_simple_name() {
assert_eq!(escape_ident("users"), "\"users\"");
@@ -65,4 +149,70 @@ mod tests {
fn escape_ident_newline() {
assert_eq!(escape_ident("a\nb"), "\"a\nb\"");
}
// ── topological_sort_tables ───────────────────────────────
#[test]
fn topo_sort_no_edges() {
let tables = vec![("public".into(), "b".into()), ("public".into(), "a".into())];
let result = topological_sort_tables(&[], &tables);
assert_eq!(result.len(), 2);
assert!(result.contains(&("public".into(), "a".into())));
assert!(result.contains(&("public".into(), "b".into())));
}
#[test]
fn topo_sort_simple_dependency() {
let edges = vec![(
"public".into(),
"orders".into(),
"public".into(),
"users".into(),
)];
let tables = vec![
("public".into(), "orders".into()),
("public".into(), "users".into()),
];
let result = topological_sort_tables(&edges, &tables);
let user_pos = result.iter().position(|t| t.1 == "users").unwrap();
let order_pos = result.iter().position(|t| t.1 == "orders").unwrap();
assert!(user_pos < order_pos, "users must come before orders");
}
#[test]
fn topo_sort_self_reference() {
let edges = vec![(
"public".into(),
"employees".into(),
"public".into(),
"employees".into(),
)];
let tables = vec![("public".into(), "employees".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 1);
}
#[test]
fn topo_sort_cycle() {
let edges = vec![
("public".into(), "a".into(), "public".into(), "b".into()),
("public".into(), "b".into(), "public".into(), "a".into()),
];
let tables = vec![("public".into(), "a".into()), ("public".into(), "b".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 2);
}
#[test]
fn topo_sort_edge_outside_target_set_ignored() {
let edges = vec![(
"public".into(),
"orders".into(),
"public".into(),
"external".into(),
)];
let tables = vec![("public".into(), "orders".into())];
let result = topological_sort_tables(&edges, &tables);
assert_eq!(result.len(), 1);
}
}

View File

@@ -5,7 +5,7 @@
"identifier": "com.tusk.dbm",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5174",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},

103
src/components/ai/AiBar.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AiSettingsPopover } from "./AiSettingsPopover";
import { useGenerateSql } from "@/hooks/use-ai";
import { Sparkles, Loader2, X, Eraser } from "lucide-react";
import { toast } from "sonner";
interface Props {
connectionId: string;
onSqlGenerated: (sql: string) => void;
onClose: () => void;
onExecute?: () => void;
}
export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Props) {
const [prompt, setPrompt] = useState("");
const generateMutation = useGenerateSql();
const handleGenerate = () => {
if (!prompt.trim() || generateMutation.isPending) return;
generateMutation.mutate(
{ connectionId, prompt },
{
onSuccess: (sql) => {
onSqlGenerated(sql);
},
onError: (err) => {
toast.error("AI generation failed", { description: String(err) });
},
}
);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
onExecute?.();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
handleGenerate();
return;
}
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
return (
<div className="tusk-ai-bar flex items-center gap-2 px-2 py-1.5 tusk-fade-in">
<Sparkles className="h-3.5 w-3.5 shrink-0 tusk-ai-icon" />
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the query you want..."
className="h-7 min-w-0 flex-1 border-tusk-purple/20 bg-tusk-purple/5 text-xs placeholder:text-muted-foreground/40 focus:border-tusk-purple/40 focus:ring-tusk-purple/20"
autoFocus
disabled={generateMutation.isPending}
/>
<Button
size="xs"
variant="ghost"
className="gap-1 text-[11px] text-tusk-purple hover:bg-tusk-purple/10 hover:text-tusk-purple"
onClick={handleGenerate}
disabled={generateMutation.isPending || !prompt.trim()}
>
{generateMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
"Generate"
)}
</Button>
{prompt.trim() && (
<Button
size="icon-xs"
variant="ghost"
onClick={() => setPrompt("")}
title="Clear prompt"
disabled={generateMutation.isPending}
className="text-muted-foreground"
>
<Eraser className="h-3 w-3" />
</Button>
)}
<AiSettingsPopover />
<Button
size="icon-xs"
variant="ghost"
onClick={onClose}
title="Close AI bar"
className="text-muted-foreground"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}

View File

@@ -7,85 +7,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useFireworksModels,
useOllamaModels,
useOpenRouterModels,
} from "@/hooks/use-ai";
import { useOllamaModels } from "@/hooks/use-ai";
import { RefreshCw, Loader2 } from "lucide-react";
import type { AiProvider, OllamaModel } from "@/types";
interface Props {
provider: AiProvider;
ollamaUrl: string;
onOllamaUrlChange: (url: string) => void;
fireworksApiKey: string;
onFireworksApiKeyChange: (key: string) => void;
openrouterApiKey: string;
onOpenRouterApiKeyChange: (key: string) => void;
model: string;
onModelChange: (model: string) => void;
}
export function AiSettingsFields({
provider,
ollamaUrl,
onOllamaUrlChange,
fireworksApiKey,
onFireworksApiKeyChange,
openrouterApiKey,
onOpenRouterApiKeyChange,
model,
onModelChange,
}: Props) {
if (provider === "fireworks") {
return (
<FireworksFields
apiKey={fireworksApiKey}
onApiKeyChange={onFireworksApiKeyChange}
model={model}
onModelChange={onModelChange}
/>
);
}
if (provider === "openrouter") {
return (
<OpenRouterFields
apiKey={openrouterApiKey}
onApiKeyChange={onOpenRouterApiKeyChange}
model={model}
onModelChange={onModelChange}
/>
);
}
return (
<OllamaFields
ollamaUrl={ollamaUrl}
onOllamaUrlChange={onOllamaUrlChange}
model={model}
onModelChange={onModelChange}
/>
);
}
function OllamaFields({
ollamaUrl,
onOllamaUrlChange,
model,
onModelChange,
}: {
ollamaUrl: string;
onOllamaUrlChange: (url: string) => void;
model: string;
onModelChange: (model: string) => void;
}) {
const {
data: models,
isLoading,
isError,
refetch,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useOllamaModels(ollamaUrl);
return (
@@ -100,171 +42,41 @@ function OllamaFields({
/>
</div>
<ModelDropdown
models={models}
loading={isLoading}
errored={isError}
errorText="Cannot connect to Ollama"
onRefresh={() => refetch()}
model={model}
onModelChange={onModelChange}
/>
</>
);
}
function FireworksFields({
apiKey,
onApiKeyChange,
model,
onModelChange,
}: {
apiKey: string;
onApiKeyChange: (key: string) => void;
model: string;
onModelChange: (model: string) => void;
}) {
const {
data: models,
isLoading,
isError,
refetch,
} = useFireworksModels(apiKey);
return (
<>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Fireworks API key</label>
<Input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="fw_..."
className="h-8 text-xs"
autoComplete="off"
/>
<p className="text-[10px] text-muted-foreground/70">
Stored locally; sent only to api.fireworks.ai.
</p>
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Model</label>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => refetchModels()}
disabled={modelsLoading}
title="Refresh models"
>
{modelsLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
{modelsError ? (
<p className="text-xs text-destructive">Cannot connect to Ollama</p>
) : (
<Select value={model} onValueChange={onModelChange}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{models?.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<ModelDropdown
models={models}
loading={isLoading}
errored={isError}
errorText="Cannot reach Fireworks (check API key)"
onRefresh={() => refetch()}
model={model}
onModelChange={onModelChange}
emptyHint={apiKey.trim() ? "Click ↻ to load models" : "Enter API key first"}
/>
</>
);
}
function OpenRouterFields({
apiKey,
onApiKeyChange,
model,
onModelChange,
}: {
apiKey: string;
onApiKeyChange: (key: string) => void;
model: string;
onModelChange: (model: string) => void;
}) {
const {
data: models,
isLoading,
isError,
refetch,
} = useOpenRouterModels(apiKey);
return (
<>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">OpenRouter API key</label>
<Input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="sk-or-..."
className="h-8 text-xs"
autoComplete="off"
/>
<p className="text-[10px] text-muted-foreground/70">
Stored locally; sent only to openrouter.ai.
</p>
</div>
<ModelDropdown
models={models}
loading={isLoading}
errored={isError}
errorText="Cannot reach OpenRouter (check API key)"
onRefresh={() => refetch()}
model={model}
onModelChange={onModelChange}
emptyHint={apiKey.trim() ? "Click ↻ to load models" : "Enter API key first"}
/>
</>
);
}
function ModelDropdown({
models,
loading,
errored,
errorText,
onRefresh,
model,
onModelChange,
emptyHint,
}: {
models: OllamaModel[] | undefined;
loading: boolean;
errored: boolean;
errorText: string;
onRefresh: () => void;
model: string;
onModelChange: (model: string) => void;
emptyHint?: string;
}) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Model</label>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={onRefresh}
disabled={loading}
title="Refresh models"
>
{loading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
{errored ? (
<p className="text-xs text-destructive">{errorText}</p>
) : (
<Select value={model} onValueChange={onModelChange}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder={emptyHint ?? "Select a model"} />
</SelectTrigger>
<SelectContent>
{models?.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}

View File

@@ -4,67 +4,25 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { useAiSettings, useSaveAiSettings } from "@/hooks/use-ai";
import { Settings } from "lucide-react";
import { toast } from "sonner";
import { AiSettingsFields } from "./AiSettingsFields";
import type { AiProvider } from "@/types";
const SUPPORTED_PROVIDERS: { value: AiProvider; label: string }[] = [
{ value: "ollama", label: "Ollama (local)" },
{ value: "fireworks", label: "Fireworks AI" },
{ value: "openrouter", label: "OpenRouter" },
];
export function AiSettingsPopover() {
const { data: settings } = useAiSettings();
const saveMutation = useSaveAiSettings();
const [provider, setProvider] = useState<AiProvider | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [fireworksKey, setFireworksKey] = useState<string | null>(null);
const [openrouterKey, setOpenrouterKey] = useState<string | null>(null);
const [model, setModel] = useState<string | null>(null);
const currentProvider: AiProvider =
provider ?? settings?.provider ?? "ollama";
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
const currentFireworksKey =
fireworksKey ?? settings?.fireworks_api_key ?? "";
const currentOpenrouterKey =
openrouterKey ?? settings?.openrouter_api_key ?? "";
const currentModel = model ?? settings?.model ?? "";
const handleProviderChange = (next: AiProvider) => {
if (next === currentProvider) return;
setProvider(next);
// Model lists differ between providers — drop the previous selection.
setModel("");
};
const handleSave = () => {
saveMutation.mutate(
{
provider: currentProvider,
ollama_url: currentUrl,
fireworks_api_key:
currentProvider === "fireworks"
? currentFireworksKey.trim() || undefined
: settings?.fireworks_api_key,
openrouter_api_key:
currentProvider === "openrouter"
? currentOpenrouterKey.trim() || undefined
: settings?.openrouter_api_key,
model: currentModel,
},
{ provider: "ollama", ollama_url: currentUrl, model: currentModel },
{
onSuccess: () => toast.success("AI settings saved"),
onError: (err) =>
@@ -89,35 +47,11 @@ export function AiSettingsPopover() {
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="flex flex-col gap-3">
<h4 className="text-sm font-medium">AI Settings</h4>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Provider</label>
<Select
value={currentProvider}
onValueChange={(v) => handleProviderChange(v as AiProvider)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h4 className="text-sm font-medium">Ollama Settings</h4>
<AiSettingsFields
provider={currentProvider}
ollamaUrl={currentUrl}
onOllamaUrlChange={setUrl}
fireworksApiKey={currentFireworksKey}
onFireworksApiKeyChange={setFireworksKey}
openrouterApiKey={currentOpenrouterKey}
onOpenRouterApiKeyChange={setOpenrouterKey}
model={currentModel}
onModelChange={setModel}
/>

View File

@@ -1,64 +0,0 @@
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Send } from "lucide-react";
interface Props {
onSend: (text: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatComposer({ onSend, disabled, placeholder }: Props) {
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
const handleSend = () => {
if (disabled) return;
const text = value.trim();
if (!text) return;
onSend(text);
setValue("");
requestAnimationFrame(() => {
if (ref.current) ref.current.style.height = "auto";
});
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const autoresize = (el: HTMLTextAreaElement) => {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
return (
<div className="flex items-end gap-2">
<textarea
ref={ref}
className="flex-1 resize-none rounded-md border border-border/50 bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20 disabled:opacity-60"
rows={1}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
setValue(e.target.value);
autoresize(e.target);
}}
onKeyDown={handleKeyDown}
/>
<Button
size="sm"
onClick={handleSend}
disabled={disabled || !value.trim()}
className="h-8 gap-1.5"
>
<Send className="h-3.5 w-3.5" />
Send
</Button>
</div>
);
}

View File

@@ -1,290 +0,0 @@
import { useState } from "react";
import { ResultsTable } from "@/components/results/ResultsTable";
import { ExportDialog } from "@/components/export/ExportDialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChevronDown,
ChevronRight,
AlertCircle,
Sparkles,
User,
Database,
Maximize2,
Download,
} from "lucide-react";
import type { ChatMessage } from "@/types";
import { getToolMeta, isQueryResultTool } from "./tool-registry";
interface Props {
message: ChatMessage;
}
export function ChatMessageView({ message }: Props) {
switch (message.role) {
case "user":
return <UserBubble text={message.text} />;
case "assistant":
return <AssistantBubble text={message.text} />;
case "tool_call":
return <ToolCallBlock tool={message.tool} inputJson={message.input_json} />;
case "tool_result":
return (
<ToolResultBlock
tool={message.tool}
isError={message.is_error}
text={message.text ?? null}
result={message.result ?? null}
/>
);
}
}
function UserBubble({ text }: { text: string }) {
return (
<div className="flex gap-2">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-3 w-3" />
</span>
<div className="flex-1 whitespace-pre-wrap rounded-md bg-accent/30 px-3 py-2 text-sm">
{text}
</div>
</div>
);
}
function AssistantBubble({ text }: { text: string }) {
return (
<div className="flex gap-2">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-3 w-3" />
</span>
<div className="flex-1 whitespace-pre-wrap text-sm leading-relaxed">{text}</div>
</div>
);
}
function ToolCallBlock({ tool, inputJson }: { tool: string; inputJson: string }) {
const [expanded, setExpanded] = useState(false);
const meta = getToolMeta(tool);
const preview = previewFromJson(tool, inputJson);
const Icon = meta.icon;
const showSqlPreview = (tool === "run_query" || tool === "explain_query") && preview;
return (
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Icon className="h-3 w-3" />
<span className="font-medium">{meta.label}</span>
{preview && (
<span className="ml-1 truncate text-muted-foreground/70">{preview}</span>
)}
</button>
{expanded && (
<div className="border-t border-border/30 p-2">
{showSqlPreview ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px]">
{preview}
</pre>
) : (
<pre className="overflow-x-auto rounded bg-background/60 p-2 font-mono text-[11px]">
{prettyJson(inputJson)}
</pre>
)}
</div>
)}
</div>
);
}
function previewFromJson(tool: string, inputJson: string): string | null {
try {
const parsed = JSON.parse(inputJson) as Record<string, unknown>;
return getToolMeta(tool).preview(parsed);
} catch {
return null;
}
}
function ToolResultBlock({
tool,
isError,
text,
result,
}: {
tool: string;
isError: boolean;
text: string | null;
result: { columns: string[]; types: string[]; rows: unknown[][]; row_count: number; execution_time_ms: number } | null;
}) {
if (isError) {
return (
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">{getToolMeta(tool).label} failed</div>
{text && <div className="mt-1 whitespace-pre-wrap text-muted-foreground">{text}</div>}
</div>
</div>
);
}
// Tools that produce a QueryResult (rendered as a table): run_query, sample_data.
if (isQueryResultTool(tool) && result) {
return <RunQueryResultBlock result={result} />;
}
// Everything else falls back to a collapsible text block.
return <TextToolResult tool={tool} text={text} />;
}
function RunQueryResultBlock({
result,
}: {
result: {
columns: string[];
types: string[];
rows: unknown[][];
row_count: number;
execution_time_ms: number;
};
}) {
const cap = 100;
const [fullOpen, setFullOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const previewRows = result.rows.slice(0, cap);
const hasMore = result.rows.length > cap;
return (
<>
<div className="ml-8 overflow-hidden rounded-md border border-border/40 bg-background">
<div className="flex items-center gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
<Database className="h-3 w-3" />
<span>
{result.row_count} row{result.row_count === 1 ? "" : "s"} ·{" "}
{result.execution_time_ms} ms
</span>
{hasMore && (
<span className="text-muted-foreground/60">· showing first {cap}</span>
)}
<div className="ml-auto flex items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setFullOpen(true)}
title="Open full result"
disabled={result.rows.length === 0}
>
<Maximize2 className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setExportOpen(true)}
title="Export"
disabled={result.rows.length === 0}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
<div className="max-h-72 overflow-auto">
<ResultsTable
columns={result.columns}
types={result.types}
rows={previewRows}
/>
</div>
</div>
<Dialog open={fullOpen} onOpenChange={setFullOpen}>
<DialogContent className="flex h-[80vh] max-w-[90vw] flex-col gap-2 sm:max-w-[90vw]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Database className="h-3.5 w-3.5" />
Full result · {result.row_count} row{result.row_count === 1 ? "" : "s"} ·{" "}
{result.execution_time_ms} ms
<Button
size="xs"
variant="outline"
className="ml-auto gap-1.5"
onClick={() => setExportOpen(true)}
>
<Download className="h-3 w-3" />
Export
</Button>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border/40">
<ResultsTable
columns={result.columns}
types={result.types}
rows={result.rows}
/>
</div>
</DialogContent>
</Dialog>
<ExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
columns={result.columns}
rows={result.rows}
/>
</>
);
}
function TextToolResult({ tool, text }: { tool: string; text: string | null }) {
// Lazy preview: switch_database is short; everything else collapses by default.
const [expanded, setExpanded] = useState(tool === "switch_database");
const meta = getToolMeta(tool);
const Icon = meta.icon;
const lineCount = text ? text.split("\n").length : 0;
return (
<div className="ml-8 rounded-md border border-border/40 bg-muted/20">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Icon className="h-3 w-3" />
<span className="font-medium">{meta.label}</span>
{text && (
<span className="ml-1 text-muted-foreground/60">
{lineCount} line{lineCount === 1 ? "" : "s"}
</span>
)}
</button>
{expanded && text && (
<div className="border-t border-border/30 p-2">
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-background/60 p-2 font-mono text-[11px] leading-relaxed">
{text}
</pre>
</div>
)}
</div>
);
}
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}

View File

@@ -1,181 +0,0 @@
import { useEffect, useRef } from "react";
import { useChat } from "@/hooks/use-chat";
import { ChatComposer } from "./ChatComposer";
import { ChatMessageView } from "./ChatMessageView";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Eraser, Sparkles, Layers } from "lucide-react";
import { useAppStore } from "@/stores/app-store";
import { useAiSettings } from "@/hooks/use-ai";
import type { ContextUsage } from "@/types";
interface Props {
tabId: string;
connectionId: string;
}
export function ChatPanel({ tabId, connectionId }: Props) {
const { messages, pending, usage, send, clear, compact } = useChat(tabId, connectionId);
const dbFlavors = useAppStore((s) => s.dbFlavors);
const flavor = dbFlavors[connectionId];
const { data: aiSettings } = useAiSettings();
const aiReady = !!aiSettings?.model;
const scrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollerRef.current?.scrollTo({
top: scrollerRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages.length, pending]);
return (
<div className="flex h-full flex-col">
<div className="flex h-9 items-center justify-between border-b border-border/40 px-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-primary/70" />
<span className="font-medium">AI Assistant</span>
{flavor && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">
· {flavor}
</span>
)}
{aiSettings?.model && (
<span className="text-[10px] text-muted-foreground/60">· {aiSettings.model}</span>
)}
</div>
<div className="flex items-center gap-2">
<UsageBadge usage={usage} />
<Button
size="xs"
variant="ghost"
onClick={() => compact()}
disabled={messages.length === 0 || pending}
title="Summarize older messages to free context (also: type /compact)"
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Layers className="h-3 w-3" />
Compact
</Button>
<Button
size="xs"
variant="ghost"
onClick={clear}
disabled={messages.length === 0 || pending}
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Eraser className="h-3 w-3" />
Clear
</Button>
</div>
</div>
<div ref={scrollerRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 && !pending ? (
<EmptyState aiReady={aiReady} flavor={flavor} />
) : (
<div className="flex flex-col gap-3 px-4 py-3">
{messages.map((m) => (
<ChatMessageView key={m.id} message={m} />
))}
{pending && <PendingIndicator />}
</div>
)}
</div>
<div className="border-t border-border/40 bg-background/40 p-2">
<ChatComposer
onSend={send}
disabled={pending || !aiReady}
placeholder={
aiReady
? "Ask in plain language. /compact to summarise, /clear to wipe."
: "Configure an AI model in Settings to enable chat."
}
/>
</div>
</div>
);
}
function UsageBadge({ usage }: { usage: ContextUsage | undefined }) {
if (!usage || usage.budget_chars === 0) return null;
const ratio = Math.min(usage.used_chars / usage.budget_chars, 1.5);
const usedTok = Math.round(usage.used_chars / 3 / 100) / 10; // ~k-tokens with 1 decimal
const budgetTok = Math.round(usage.budget_chars / 3 / 100) / 10;
const percent = Math.round(ratio * 100);
let toneClass = "text-muted-foreground/70";
if (ratio >= 0.85) toneClass = "text-destructive";
else if (ratio >= 0.6) toneClass = "text-warning";
else if (ratio >= 0.3) toneClass = "text-success/80";
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
let fillClass = "bg-success/70";
if (ratio >= 0.85) fillClass = "bg-destructive";
else if (ratio >= 0.6) fillClass = "bg-warning";
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-[10px]">
<div className={trackClass}>
<div
className={fillClass}
style={{
height: "100%",
width: `${Math.min(ratio, 1) * 100}%`,
}}
/>
</div>
<span className={toneClass}>
{usedTok}k / {budgetTok}k tok · {percent}%
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
Approximate context usage. {usage.used_chars.toLocaleString()} chars sent to the model
last turn out of {usage.budget_chars.toLocaleString()} budget.
{ratio >= 0.6 && " Type /compact (or click Compact) to summarise older history."}
</TooltipContent>
</Tooltip>
);
}
function PendingIndicator() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
<span className="inline-flex gap-0.5">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70" />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:120ms]" />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:240ms]" />
</span>
Thinking...
</div>
);
}
function EmptyState({ aiReady, flavor }: { aiReady: boolean; flavor: string | undefined }) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="max-w-md space-y-3 text-center">
<Sparkles className="mx-auto h-8 w-8 text-primary/50" />
<h3 className="text-sm font-medium">Ask anything about your data</h3>
<p className="text-xs text-muted-foreground">
{aiReady
? `Connected to ${flavor ?? "database"}. Try: "How many rows in each table?", "Top 10 customers by total spend", "Show me last week's orders".`
: "Open Settings → AI to choose an Ollama model. Tusk will then assist with natural-language queries."}
</p>
{aiReady && (
<p className="text-[11px] text-muted-foreground/60">
Slash commands: <code>/compact</code> · <code>/clear</code>
</p>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More