Compare commits
16 Commits
ab898262dd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d431352816 | |||
| 931e2b9408 | |||
| 02ea9db25d | |||
| 318210bdd8 | |||
| 11e35fcb5c | |||
| 50214fec0f | |||
| 28aa4ef8cc | |||
| ba9b58ff3a | |||
| 33b07a31da | |||
| 2d2dcdc4a8 | |||
| 9237c7dd8e | |||
| 6b925d6260 | |||
| 1e002d801a | |||
| f8dd94a6c7 | |||
| 4e5714b291 | |||
| 64e27f79a4 |
@@ -2,37 +2,41 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, master]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
concurrency:
|
|
||||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-test:
|
lint-and-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ubuntu:22.04
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Install system dependencies
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
apt-get update
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
apt-get install -y \
|
||||||
|
build-essential curl wget pkg-config git ca-certificates \
|
||||||
|
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||||
|
libssl-dev xdg-utils
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- name: Checkout
|
||||||
with:
|
run: |
|
||||||
components: clippy, rustfmt
|
git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Install Node.js 22
|
||||||
with:
|
run: |
|
||||||
workspaces: src-tauri
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- name: Install Rust toolchain
|
||||||
with:
|
run: |
|
||||||
node-version: 22
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --component clippy,rustfmt
|
||||||
cache: npm
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -41,51 +45,21 @@ jobs:
|
|||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Rust fmt check
|
- name: Rust fmt check
|
||||||
run: cd src-tauri && cargo fmt --check
|
run: |
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cd src-tauri && cargo fmt --check
|
||||||
|
|
||||||
- name: Rust clippy
|
- name: Rust clippy
|
||||||
run: cd src-tauri && cargo clippy -- -D warnings
|
run: |
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cd src-tauri && cargo clippy -- -D warnings
|
||||||
|
|
||||||
- name: Rust tests
|
- name: Rust tests
|
||||||
run: cd src-tauri && cargo test
|
|
||||||
|
|
||||||
- name: Frontend tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: lint-and-test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
. "$HOME/.cargo/env"
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
cd src-tauri && cargo test
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
run: npm run tauri build
|
run: |
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
- name: Upload artifacts
|
npm run tauri build -- --bundles deb,rpm
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tusk-linux-x64
|
|
||||||
path: |
|
|
||||||
src-tauri/target/release/bundle/deb/*.deb
|
|
||||||
src-tauri/target/release/bundle/rpm/*.rpm
|
|
||||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|||||||
@@ -8,30 +8,38 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ubuntu:22.04
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
apt-get update
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
apt-get install -y \
|
||||||
|
build-essential curl wget pkg-config jq file \
|
||||||
|
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||||
|
libssl-dev git ca-certificates
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- name: Install Node.js 22
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Install Rust toolchain
|
||||||
with:
|
run: |
|
||||||
workspaces: src-tauri
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
run: npm run tauri build
|
run: |
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
npm run tauri build
|
||||||
|
|
||||||
- name: Create release and upload assets
|
- name: Create release and upload assets
|
||||||
env:
|
env:
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -15,7 +15,7 @@ TARGET_DIR := $(if $(TARGET),src-tauri/target/$(TARGET)/release,src-tauri/targe
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev: node_modules ## Run app in dev mode (Vite HMR + Rust backend)
|
dev: ## Run app in dev mode (Vite HMR + Rust backend)
|
||||||
npm run tauri dev
|
npm run tauri dev
|
||||||
|
|
||||||
.PHONY: dev-frontend
|
.PHONY: dev-frontend
|
||||||
@@ -98,12 +98,14 @@ export DESKTOP_ENTRY
|
|||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build ## Build release and install to system (PREFIX=/usr/local)
|
install: build ## Build release and install to system (PREFIX=/usr/local)
|
||||||
install -Dm755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME)
|
@mkdir -p $(DESTDIR)$(BINDIR)
|
||||||
|
install -m755 $(TARGET_DIR)/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME)
|
||||||
@mkdir -p $(DESTDIR)$(DATADIR)/applications
|
@mkdir -p $(DESTDIR)$(DATADIR)/applications
|
||||||
@echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
@echo "$$DESKTOP_ENTRY" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||||
@for size in 32x32 128x128; do \
|
@for size in 32x32 128x128; do \
|
||||||
if [ -f src-tauri/icons/$$size.png ]; then \
|
if [ -f src-tauri/icons/$$size.png ]; then \
|
||||||
install -Dm644 src-tauri/icons/$$size.png \
|
mkdir -p $(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps; \
|
||||||
|
install -m644 src-tauri/icons/$$size.png \
|
||||||
$(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \
|
$(DESTDIR)$(DATADIR)/icons/hicolor/$$size/apps/$(APP_NAME).png; \
|
||||||
fi; \
|
fi; \
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'src-tauri']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -19,5 +19,8 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tusk</title>
|
<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=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1132
package-lock.json
generated
1132
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -8,6 +8,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,9 +20,9 @@
|
|||||||
"@tauri-apps/api": "^2.10.1",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.54",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2.10.0",
|
"@tauri-apps/cli": "^2.10.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -48,11 +52,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"shadcn": "^3.8.4",
|
"shadcn": "^3.8.4",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -4704,7 +4704,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ authors = ["you"]
|
|||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.80.0"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "tusk_lib"
|
name = "tusk_lib"
|
||||||
@@ -21,7 +21,7 @@ tauri-plugin-shell = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["serde"] }
|
uuid = { version = "1", features = ["serde"] }
|
||||||
@@ -35,3 +35,6 @@ rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable
|
|||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use crate::commands::data::bind_json_value;
|
|||||||
use crate::commands::queries::pg_value_to_json;
|
use crate::commands::queries::pg_value_to_json;
|
||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::ai::{
|
use crate::models::ai::{
|
||||||
AiProvider, AiSettings, GenerateDataParams, GeneratedDataPreview, GeneratedTableData,
|
AiProvider, AiSettings, DataGenProgress, GenerateDataParams, GeneratedDataPreview,
|
||||||
IndexAdvisorReport, IndexRecommendation, IndexStats,
|
GeneratedTableData, IndexAdvisorReport, IndexRecommendation, IndexStats, OllamaChatMessage,
|
||||||
OllamaChatMessage, OllamaChatRequest, OllamaChatResponse, OllamaModel, OllamaTagsResponse,
|
OllamaChatRequest, OllamaChatResponse, OllamaModel, OllamaTagsResponse, SlowQuery, TableStats,
|
||||||
SlowQuery, TableStats, ValidationRule, ValidationStatus, DataGenProgress,
|
ValidationRule, ValidationStatus,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{escape_ident, topological_sort_tables};
|
use crate::utils::{escape_ident, topological_sort_tables};
|
||||||
@@ -20,19 +20,23 @@ use tauri::{AppHandle, Emitter, Manager, State};
|
|||||||
const MAX_RETRIES: u32 = 2;
|
const MAX_RETRIES: u32 = 2;
|
||||||
const RETRY_DELAY_MS: u64 = 1000;
|
const RETRY_DELAY_MS: u64 = 1000;
|
||||||
|
|
||||||
fn http_client() -> reqwest::Client {
|
fn http_client() -> &'static reqwest::Client {
|
||||||
reqwest::Client::builder()
|
use std::sync::LazyLock;
|
||||||
.connect_timeout(Duration::from_secs(5))
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
.timeout(Duration::from_secs(300))
|
reqwest::Client::builder()
|
||||||
.build()
|
.connect_timeout(Duration::from_secs(5))
|
||||||
.unwrap_or_default()
|
.timeout(Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
&CLIENT
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_ai_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
fn get_ai_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("ai_settings.json"))
|
Ok(dir.join("ai_settings.json"))
|
||||||
}
|
}
|
||||||
@@ -65,11 +69,10 @@ pub async fn save_ai_settings(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_ollama_models(ollama_url: String) -> TuskResult<Vec<OllamaModel>> {
|
pub async fn list_ollama_models(ollama_url: String) -> TuskResult<Vec<OllamaModel>> {
|
||||||
let url = format!("{}/api/tags", ollama_url.trim_end_matches('/'));
|
let url = format!("{}/api/tags", ollama_url.trim_end_matches('/'));
|
||||||
let resp = http_client()
|
let resp =
|
||||||
.get(&url)
|
http_client().get(&url).send().await.map_err(|e| {
|
||||||
.send()
|
TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", ollama_url, e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", ollama_url, e)))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -119,7 +122,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
Err(last_error.unwrap_or_else(|| {
|
Err(last_error.unwrap_or_else(|| {
|
||||||
TuskError::Ai(format!("{} failed after {} attempts", operation, MAX_RETRIES))
|
TuskError::Ai(format!(
|
||||||
|
"{} failed after {} attempts",
|
||||||
|
operation, MAX_RETRIES
|
||||||
|
))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +170,7 @@ async fn call_ollama_chat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let model = settings.model.clone();
|
let model = settings.model.clone();
|
||||||
let url = format!(
|
let url = format!("{}/api/chat", settings.ollama_url.trim_end_matches('/'));
|
||||||
"{}/api/chat",
|
|
||||||
settings.ollama_url.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
|
|
||||||
let request = OllamaChatRequest {
|
let request = OllamaChatRequest {
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
@@ -194,10 +197,7 @@ async fn call_ollama_chat(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
TuskError::Ai(format!(
|
TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", url, e))
|
||||||
"Cannot connect to Ollama at {}: {}",
|
|
||||||
url, e
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
@@ -379,10 +379,7 @@ pub async fn fix_sql_error(
|
|||||||
schema_text
|
schema_text
|
||||||
);
|
);
|
||||||
|
|
||||||
let user_content = format!(
|
let user_content = format!("SQL query:\n{}\n\nError message:\n{}", sql, error_message);
|
||||||
"SQL query:\n{}\n\nError message:\n{}",
|
|
||||||
sql, error_message
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw = call_ollama_chat(&app, &state, system_prompt, user_content).await?;
|
let raw = call_ollama_chat(&app, &state, system_prompt, user_content).await?;
|
||||||
Ok(clean_sql_response(&raw))
|
Ok(clean_sql_response(&raw))
|
||||||
@@ -405,9 +402,15 @@ pub(crate) async fn build_schema_context(
|
|||||||
|
|
||||||
// Run all metadata queries in parallel for speed
|
// Run all metadata queries in parallel for speed
|
||||||
let (
|
let (
|
||||||
version_res, col_res, fk_res, enum_res,
|
version_res,
|
||||||
tbl_comment_res, col_comment_res, unique_res,
|
col_res,
|
||||||
varchar_res, jsonb_res,
|
fk_res,
|
||||||
|
enum_res,
|
||||||
|
tbl_comment_res,
|
||||||
|
col_comment_res,
|
||||||
|
unique_res,
|
||||||
|
varchar_res,
|
||||||
|
jsonb_res,
|
||||||
) = tokio::join!(
|
) = tokio::join!(
|
||||||
sqlx::query_scalar::<_, String>("SELECT version()").fetch_one(&pool),
|
sqlx::query_scalar::<_, String>("SELECT version()").fetch_one(&pool),
|
||||||
fetch_columns(&pool),
|
fetch_columns(&pool),
|
||||||
@@ -586,10 +589,9 @@ pub(crate) async fn build_schema_context(
|
|||||||
// Unique constraints for this table
|
// Unique constraints for this table
|
||||||
let schema_table: Vec<&str> = full_name.splitn(2, '.').collect();
|
let schema_table: Vec<&str> = full_name.splitn(2, '.').collect();
|
||||||
if schema_table.len() == 2 {
|
if schema_table.len() == 2 {
|
||||||
if let Some(uqs) = unique_map.get(&(
|
if let Some(uqs) =
|
||||||
schema_table[0].to_string(),
|
unique_map.get(&(schema_table[0].to_string(), schema_table[1].to_string()))
|
||||||
schema_table[1].to_string(),
|
{
|
||||||
)) {
|
|
||||||
for uq_cols in uqs {
|
for uq_cols in uqs {
|
||||||
output.push(format!(" UNIQUE({})", uq_cols));
|
output.push(format!(" UNIQUE({})", uq_cols));
|
||||||
}
|
}
|
||||||
@@ -609,7 +611,9 @@ pub(crate) async fn build_schema_context(
|
|||||||
let result = output.join("\n");
|
let result = output.join("\n");
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
state.set_schema_cache(connection_id.to_string(), result.clone()).await;
|
state
|
||||||
|
.set_schema_cache(connection_id.to_string(), result.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -931,10 +935,7 @@ async fn fetch_jsonb_keys(
|
|||||||
|
|
||||||
let query = parts.join(" UNION ALL ");
|
let query = parts.join(" UNION ALL ");
|
||||||
|
|
||||||
let rows = match sqlx::query(&query)
|
let rows = match sqlx::query(&query).fetch_all(pool).await {
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to fetch JSONB keys: {}", e);
|
log::warn!("Failed to fetch JSONB keys: {}", e);
|
||||||
@@ -1033,6 +1034,26 @@ fn simplify_default(raw: &str) -> String {
|
|||||||
s.to_string()
|
s.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_select_statement(sql: &str) -> TuskResult<()> {
|
||||||
|
let sql_upper = sql.trim().to_uppercase();
|
||||||
|
if !sql_upper.starts_with("SELECT") {
|
||||||
|
return Err(TuskError::Validation(
|
||||||
|
"Validation query must be a SELECT statement".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_index_ddl(ddl: &str) -> TuskResult<()> {
|
||||||
|
let ddl_upper = ddl.trim().to_uppercase();
|
||||||
|
if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") {
|
||||||
|
return Err(TuskError::Validation(
|
||||||
|
"Only CREATE INDEX and DROP INDEX statements are allowed".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn clean_sql_response(raw: &str) -> String {
|
fn clean_sql_response(raw: &str) -> String {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
// Remove markdown code fences
|
// Remove markdown code fences
|
||||||
@@ -1098,18 +1119,13 @@ pub async fn run_validation_rule(
|
|||||||
sql: String,
|
sql: String,
|
||||||
sample_limit: Option<u32>,
|
sample_limit: Option<u32>,
|
||||||
) -> TuskResult<ValidationRule> {
|
) -> TuskResult<ValidationRule> {
|
||||||
let sql_upper = sql.trim().to_uppercase();
|
validate_select_statement(&sql)?;
|
||||||
if !sql_upper.starts_with("SELECT") {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Validation query must be a SELECT statement".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let limit = sample_limit.unwrap_or(10);
|
let limit = sample_limit.unwrap_or(10);
|
||||||
let _start = Instant::now();
|
let _start = Instant::now();
|
||||||
|
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
sqlx::query("SET TRANSACTION READ ONLY")
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
@@ -1199,7 +1215,13 @@ pub async fn suggest_validation_rules(
|
|||||||
schema_text
|
schema_text
|
||||||
);
|
);
|
||||||
|
|
||||||
let raw = call_ollama_chat(&app, &state, system_prompt, "Suggest validation rules".to_string()).await?;
|
let raw = call_ollama_chat(
|
||||||
|
&app,
|
||||||
|
&state,
|
||||||
|
system_prompt,
|
||||||
|
"Suggest validation rules".to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let cleaned = raw.trim();
|
let cleaned = raw.trim();
|
||||||
let json_start = cleaned.find('[').unwrap_or(0);
|
let json_start = cleaned.find('[').unwrap_or(0);
|
||||||
@@ -1207,7 +1229,10 @@ pub async fn suggest_validation_rules(
|
|||||||
let json_str = &cleaned[json_start..json_end];
|
let json_str = &cleaned[json_start..json_end];
|
||||||
|
|
||||||
let rules: Vec<String> = serde_json::from_str(json_str).map_err(|e| {
|
let rules: Vec<String> = serde_json::from_str(json_str).map_err(|e| {
|
||||||
TuskError::Ai(format!("Failed to parse AI response as JSON array: {}. Response: {}", e, cleaned))
|
TuskError::Ai(format!(
|
||||||
|
"Failed to parse AI response as JSON array: {}. Response: {}",
|
||||||
|
e, cleaned
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(rules)
|
Ok(rules)
|
||||||
@@ -1226,13 +1251,16 @@ pub async fn generate_test_data_preview(
|
|||||||
) -> TuskResult<GeneratedDataPreview> {
|
) -> TuskResult<GeneratedDataPreview> {
|
||||||
let pool = state.get_pool(¶ms.connection_id).await?;
|
let pool = state.get_pool(¶ms.connection_id).await?;
|
||||||
|
|
||||||
let _ = app.emit("datagen-progress", DataGenProgress {
|
let _ = app.emit(
|
||||||
gen_id: gen_id.clone(),
|
"datagen-progress",
|
||||||
stage: "schema".to_string(),
|
DataGenProgress {
|
||||||
percent: 10,
|
gen_id: gen_id.clone(),
|
||||||
message: "Building schema context...".to_string(),
|
stage: "schema".to_string(),
|
||||||
detail: None,
|
percent: 10,
|
||||||
});
|
message: "Building schema context...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let schema_text = build_schema_context(&state, ¶ms.connection_id).await?;
|
let schema_text = build_schema_context(&state, ¶ms.connection_id).await?;
|
||||||
|
|
||||||
@@ -1255,7 +1283,14 @@ pub async fn generate_test_data_preview(
|
|||||||
|
|
||||||
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|fk| (fk.schema.clone(), fk.table.clone(), fk.ref_schema.clone(), fk.ref_table.clone()))
|
.map(|fk| {
|
||||||
|
(
|
||||||
|
fk.schema.clone(),
|
||||||
|
fk.table.clone(),
|
||||||
|
fk.ref_schema.clone(),
|
||||||
|
fk.ref_table.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
||||||
@@ -1266,13 +1301,16 @@ pub async fn generate_test_data_preview(
|
|||||||
|
|
||||||
let row_count = params.row_count.min(1000);
|
let row_count = params.row_count.min(1000);
|
||||||
|
|
||||||
let _ = app.emit("datagen-progress", DataGenProgress {
|
let _ = app.emit(
|
||||||
gen_id: gen_id.clone(),
|
"datagen-progress",
|
||||||
stage: "generating".to_string(),
|
DataGenProgress {
|
||||||
percent: 30,
|
gen_id: gen_id.clone(),
|
||||||
message: "AI is generating test data...".to_string(),
|
stage: "generating".to_string(),
|
||||||
detail: None,
|
percent: 30,
|
||||||
});
|
message: "AI is generating test data...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let tables_desc: Vec<String> = sorted_tables
|
let tables_desc: Vec<String> = sorted_tables
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1329,13 +1367,16 @@ pub async fn generate_test_data_preview(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let _ = app.emit("datagen-progress", DataGenProgress {
|
let _ = app.emit(
|
||||||
gen_id: gen_id.clone(),
|
"datagen-progress",
|
||||||
stage: "parsing".to_string(),
|
DataGenProgress {
|
||||||
percent: 80,
|
gen_id: gen_id.clone(),
|
||||||
message: "Parsing generated data...".to_string(),
|
stage: "parsing".to_string(),
|
||||||
detail: None,
|
percent: 80,
|
||||||
});
|
message: "Parsing generated data...".to_string(),
|
||||||
|
detail: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Parse JSON response
|
// Parse JSON response
|
||||||
let cleaned = raw.trim();
|
let cleaned = raw.trim();
|
||||||
@@ -1343,9 +1384,13 @@ pub async fn generate_test_data_preview(
|
|||||||
let json_end = cleaned.rfind('}').map(|i| i + 1).unwrap_or(cleaned.len());
|
let json_end = cleaned.rfind('}').map(|i| i + 1).unwrap_or(cleaned.len());
|
||||||
let json_str = &cleaned[json_start..json_end];
|
let json_str = &cleaned[json_start..json_end];
|
||||||
|
|
||||||
let data_map: HashMap<String, Vec<HashMap<String, Value>>> =
|
let data_map: HashMap<String, Vec<HashMap<String, Value>>> = serde_json::from_str(json_str)
|
||||||
serde_json::from_str(json_str).map_err(|e| {
|
.map_err(|e| {
|
||||||
TuskError::Ai(format!("Failed to parse generated data: {}. Response: {}", e, &cleaned[..cleaned.len().min(500)]))
|
TuskError::Ai(format!(
|
||||||
|
"Failed to parse generated data: {}. Response: {}",
|
||||||
|
e,
|
||||||
|
&cleaned[..cleaned.len().min(500)]
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut tables = Vec::new();
|
let mut tables = Vec::new();
|
||||||
@@ -1362,7 +1407,12 @@ pub async fn generate_test_data_preview(
|
|||||||
|
|
||||||
let rows: Vec<Vec<Value>> = rows_data
|
let rows: Vec<Vec<Value>> = rows_data
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row_map| columns.iter().map(|col| row_map.get(col).cloned().unwrap_or(Value::Null)).collect())
|
.map(|row_map| {
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.map(|col| row_map.get(col).cloned().unwrap_or(Value::Null))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let count = rows.len() as u32;
|
let count = rows.len() as u32;
|
||||||
@@ -1378,13 +1428,20 @@ pub async fn generate_test_data_preview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = app.emit("datagen-progress", DataGenProgress {
|
let _ = app.emit(
|
||||||
gen_id: gen_id.clone(),
|
"datagen-progress",
|
||||||
stage: "done".to_string(),
|
DataGenProgress {
|
||||||
percent: 100,
|
gen_id: gen_id.clone(),
|
||||||
message: "Data generation complete".to_string(),
|
stage: "done".to_string(),
|
||||||
detail: Some(format!("{} rows across {} tables", total_rows, tables.len())),
|
percent: 100,
|
||||||
});
|
message: "Data generation complete".to_string(),
|
||||||
|
detail: Some(format!(
|
||||||
|
"{} rows across {} tables",
|
||||||
|
total_rows,
|
||||||
|
tables.len()
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(GeneratedDataPreview {
|
Ok(GeneratedDataPreview {
|
||||||
tables,
|
tables,
|
||||||
@@ -1404,7 +1461,7 @@ pub async fn insert_generated_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
// Defer constraints for circular FKs
|
// Defer constraints for circular FKs
|
||||||
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
||||||
@@ -1466,20 +1523,38 @@ pub async fn get_index_advisor_report(
|
|||||||
) -> TuskResult<IndexAdvisorReport> {
|
) -> TuskResult<IndexAdvisorReport> {
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
// Fetch table stats
|
// Fetch table stats, index stats, and slow queries concurrently
|
||||||
let table_stats_rows = sqlx::query(
|
let (table_stats_res, index_stats_res, slow_queries_res) = tokio::join!(
|
||||||
"SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \
|
sqlx::query(
|
||||||
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \
|
"SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \
|
||||||
pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \
|
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \
|
||||||
FROM pg_stat_user_tables \
|
pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \
|
||||||
ORDER BY seq_scan DESC \
|
FROM pg_stat_user_tables \
|
||||||
LIMIT 50"
|
ORDER BY seq_scan DESC \
|
||||||
)
|
LIMIT 50"
|
||||||
.fetch_all(&pool)
|
)
|
||||||
.await
|
.fetch_all(&pool),
|
||||||
.map_err(TuskError::Database)?;
|
sqlx::query(
|
||||||
|
"SELECT schemaname, relname, indexrelname, idx_scan, \
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, \
|
||||||
|
pg_get_indexdef(indexrelid) AS definition \
|
||||||
|
FROM pg_stat_user_indexes \
|
||||||
|
ORDER BY idx_scan ASC \
|
||||||
|
LIMIT 50",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool),
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT query, calls, total_exec_time, mean_exec_time, rows \
|
||||||
|
FROM pg_stat_statements \
|
||||||
|
WHERE calls > 0 \
|
||||||
|
ORDER BY mean_exec_time DESC \
|
||||||
|
LIMIT 20",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool),
|
||||||
|
);
|
||||||
|
|
||||||
let table_stats: Vec<TableStats> = table_stats_rows
|
let table_stats: Vec<TableStats> = table_stats_res
|
||||||
|
.map_err(TuskError::Database)?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| TableStats {
|
.map(|r| TableStats {
|
||||||
schema: r.get(0),
|
schema: r.get(0),
|
||||||
@@ -1492,20 +1567,8 @@ pub async fn get_index_advisor_report(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Fetch index stats
|
let index_stats: Vec<IndexStats> = index_stats_res
|
||||||
let index_stats_rows = sqlx::query(
|
.map_err(TuskError::Database)?
|
||||||
"SELECT schemaname, relname, indexrelname, idx_scan, \
|
|
||||||
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, \
|
|
||||||
pg_get_indexdef(indexrelid) AS definition \
|
|
||||||
FROM pg_stat_user_indexes \
|
|
||||||
ORDER BY idx_scan ASC \
|
|
||||||
LIMIT 50"
|
|
||||||
)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(TuskError::Database)?;
|
|
||||||
|
|
||||||
let index_stats: Vec<IndexStats> = index_stats_rows
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| IndexStats {
|
.map(|r| IndexStats {
|
||||||
schema: r.get(0),
|
schema: r.get(0),
|
||||||
@@ -1517,17 +1580,7 @@ pub async fn get_index_advisor_report(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Fetch slow queries (graceful if pg_stat_statements not available)
|
let (slow_queries, has_pg_stat_statements) = match slow_queries_res {
|
||||||
let (slow_queries, has_pg_stat_statements) = match sqlx::query(
|
|
||||||
"SELECT query, calls, total_exec_time, mean_exec_time, rows \
|
|
||||||
FROM pg_stat_statements \
|
|
||||||
WHERE calls > 0 \
|
|
||||||
ORDER BY mean_exec_time DESC \
|
|
||||||
LIMIT 20"
|
|
||||||
)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let queries: Vec<SlowQuery> = rows
|
let queries: Vec<SlowQuery> = rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1551,7 +1604,13 @@ pub async fn get_index_advisor_report(
|
|||||||
for ts in &table_stats {
|
for ts in &table_stats {
|
||||||
stats_text.push_str(&format!(
|
stats_text.push_str(&format!(
|
||||||
" {}.{}: seq_scan={}, idx_scan={}, rows={}, size={}, idx_size={}\n",
|
" {}.{}: seq_scan={}, idx_scan={}, rows={}, size={}, idx_size={}\n",
|
||||||
ts.schema, ts.table, ts.seq_scan, ts.idx_scan, ts.n_live_tup, ts.table_size, ts.index_size
|
ts.schema,
|
||||||
|
ts.table,
|
||||||
|
ts.seq_scan,
|
||||||
|
ts.idx_scan,
|
||||||
|
ts.n_live_tup,
|
||||||
|
ts.table_size,
|
||||||
|
ts.index_size
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,7 +1627,10 @@ pub async fn get_index_advisor_report(
|
|||||||
for sq in &slow_queries {
|
for sq in &slow_queries {
|
||||||
stats_text.push_str(&format!(
|
stats_text.push_str(&format!(
|
||||||
" calls={}, mean={:.1}ms, total={:.1}ms, rows={}: {}\n",
|
" calls={}, mean={:.1}ms, total={:.1}ms, rows={}: {}\n",
|
||||||
sq.calls, sq.mean_time_ms, sq.total_time_ms, sq.rows,
|
sq.calls,
|
||||||
|
sq.mean_time_ms,
|
||||||
|
sq.total_time_ms,
|
||||||
|
sq.rows,
|
||||||
sq.query.chars().take(200).collect::<String>()
|
sq.query.chars().take(200).collect::<String>()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1635,12 +1697,7 @@ pub async fn apply_index_recommendation(
|
|||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ddl_upper = ddl.trim().to_uppercase();
|
validate_index_ddl(&ddl)?;
|
||||||
if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") {
|
|
||||||
return Err(TuskError::Custom(
|
|
||||||
"Only CREATE INDEX and DROP INDEX statements are allowed".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = state.get_pool(&connection_id).await?;
|
let pool = state.get_pool(&connection_id).await?;
|
||||||
|
|
||||||
@@ -1655,3 +1712,151 @@ pub async fn apply_index_recommendation(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── validate_select_statement ─────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_valid_simple() {
|
||||||
|
assert!(validate_select_statement("SELECT 1").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_valid_with_leading_whitespace() {
|
||||||
|
assert!(validate_select_statement(" SELECT * FROM users").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_valid_lowercase() {
|
||||||
|
assert!(validate_select_statement("select * from users").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_rejects_insert() {
|
||||||
|
assert!(validate_select_statement("INSERT INTO users VALUES (1)").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_rejects_delete() {
|
||||||
|
assert!(validate_select_statement("DELETE FROM users").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_rejects_drop() {
|
||||||
|
assert!(validate_select_statement("DROP TABLE users").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_rejects_empty() {
|
||||||
|
assert!(validate_select_statement("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_rejects_whitespace_only() {
|
||||||
|
assert!(validate_select_statement(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This test documents a known weakness — SELECT prefix allows injection
|
||||||
|
#[test]
|
||||||
|
fn select_allows_semicolon_after_select() {
|
||||||
|
// "SELECT 1; DROP TABLE users" starts with SELECT — passes validation
|
||||||
|
// This is a known limitation documented in the review
|
||||||
|
assert!(validate_select_statement("SELECT 1; DROP TABLE users").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validate_index_ddl ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_valid_create_index() {
|
||||||
|
assert!(validate_index_ddl("CREATE INDEX idx_name ON users(email)").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_valid_create_index_concurrently() {
|
||||||
|
assert!(validate_index_ddl("CREATE INDEX CONCURRENTLY idx ON t(c)").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_valid_drop_index() {
|
||||||
|
assert!(validate_index_ddl("DROP INDEX idx_name").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_valid_with_leading_whitespace() {
|
||||||
|
assert!(validate_index_ddl(" CREATE INDEX idx ON t(c)").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_valid_lowercase() {
|
||||||
|
assert!(validate_index_ddl("create index idx on t(c)").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_rejects_create_table() {
|
||||||
|
assert!(validate_index_ddl("CREATE TABLE evil(id int)").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_rejects_drop_table() {
|
||||||
|
assert!(validate_index_ddl("DROP TABLE users").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_rejects_alter_table() {
|
||||||
|
assert!(validate_index_ddl("ALTER TABLE users ADD COLUMN x int").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ddl_rejects_empty() {
|
||||||
|
assert!(validate_index_ddl("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Documents bypass weakness — semicolon after valid prefix
|
||||||
|
#[test]
|
||||||
|
fn ddl_allows_semicolon_injection() {
|
||||||
|
// "CREATE INDEX x ON t(c); DROP TABLE users" — passes validation
|
||||||
|
// Mitigated by sqlx single-statement execution
|
||||||
|
assert!(validate_index_ddl("CREATE INDEX x ON t(c); DROP TABLE users").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clean_sql_response ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_plain() {
|
||||||
|
assert_eq!(clean_sql_response("SELECT 1"), "SELECT 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_with_fences() {
|
||||||
|
assert_eq!(clean_sql_response("```sql\nSELECT 1\n```"), "SELECT 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_with_generic_fences() {
|
||||||
|
assert_eq!(clean_sql_response("```\nSELECT 1\n```"), "SELECT 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_with_postgresql_fences() {
|
||||||
|
assert_eq!(
|
||||||
|
clean_sql_response("```postgresql\nSELECT 1\n```"),
|
||||||
|
"SELECT 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_with_whitespace() {
|
||||||
|
assert_eq!(clean_sql_response(" SELECT 1 "), "SELECT 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_sql_no_fences_multiline() {
|
||||||
|
assert_eq!(
|
||||||
|
clean_sql_response("SELECT\n *\nFROM users"),
|
||||||
|
"SELECT\n *\nFROM users"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::Pat
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("connections.json"))
|
Ok(dir.join("connections.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::time::Instant;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn get_table_data(
|
pub async fn get_table_data(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -28,6 +29,7 @@ pub async fn get_table_data(
|
|||||||
let mut where_clause = String::new();
|
let mut where_clause = String::new();
|
||||||
if let Some(ref f) = filter {
|
if let Some(ref f) = filter {
|
||||||
if !f.trim().is_empty() {
|
if !f.trim().is_empty() {
|
||||||
|
validate_filter(f)?;
|
||||||
where_clause = format!(" WHERE {}", f);
|
where_clause = format!(" WHERE {}", f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,7 @@ pub async fn get_table_data(
|
|||||||
|
|
||||||
// Always run table data queries in a read-only transaction to prevent
|
// Always run table data queries in a read-only transaction to prevent
|
||||||
// writable CTEs or other mutation via the raw filter parameter.
|
// writable CTEs or other mutation via the raw filter parameter.
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
sqlx::query("SET TRANSACTION READ ONLY")
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
@@ -129,6 +131,7 @@ pub async fn get_table_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn update_row(
|
pub async fn update_row(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -244,7 +247,7 @@ pub async fn delete_rows(
|
|||||||
let mut total_affected: u64 = 0;
|
let mut total_affected: u64 = 0;
|
||||||
|
|
||||||
// Wrap all deletes in a transaction for atomicity
|
// Wrap all deletes in a transaction for atomicity
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
// Fallback: use ctids for row identification
|
// Fallback: use ctids for row identification
|
||||||
@@ -283,6 +286,75 @@ pub async fn delete_rows(
|
|||||||
Ok(total_affected)
|
Ok(total_affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rejects filter strings that contain SQL statements capable of mutating data.
|
||||||
|
/// This blocks writable CTEs and other injection attempts that could bypass
|
||||||
|
/// SET TRANSACTION READ ONLY (which PostgreSQL does not enforce inside CTEs
|
||||||
|
/// in all versions).
|
||||||
|
fn validate_filter(filter: &str) -> TuskResult<()> {
|
||||||
|
let upper = filter.to_ascii_uppercase();
|
||||||
|
// Remove string literals to avoid false positives on keywords inside quoted values
|
||||||
|
let sanitized = remove_string_literals(&upper);
|
||||||
|
|
||||||
|
const FORBIDDEN: &[&str] = &[
|
||||||
|
"INSERT ",
|
||||||
|
"UPDATE ",
|
||||||
|
"DELETE ",
|
||||||
|
"DROP ",
|
||||||
|
"ALTER ",
|
||||||
|
"TRUNCATE ",
|
||||||
|
"CREATE ",
|
||||||
|
"GRANT ",
|
||||||
|
"REVOKE ",
|
||||||
|
"COPY ",
|
||||||
|
"EXECUTE ",
|
||||||
|
"CALL ",
|
||||||
|
];
|
||||||
|
for kw in FORBIDDEN {
|
||||||
|
if sanitized.contains(kw) {
|
||||||
|
return Err(TuskError::Validation(format!(
|
||||||
|
"Filter contains forbidden SQL keyword: {}",
|
||||||
|
kw.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sanitized.contains("INTO ") && sanitized.contains("SELECT ") {
|
||||||
|
return Err(TuskError::Validation(
|
||||||
|
"Filter contains forbidden SELECT INTO clause".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the contents of single-quoted string literals with spaces so that
|
||||||
|
/// keyword detection does not trigger on values like `status = 'DELETE_PENDING'`.
|
||||||
|
fn remove_string_literals(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut in_quote = false;
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\'' {
|
||||||
|
if in_quote {
|
||||||
|
// Check for escaped quote ('')
|
||||||
|
if chars.peek() == Some(&'\'') {
|
||||||
|
chars.next();
|
||||||
|
result.push(' ');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
in_quote = false;
|
||||||
|
result.push('\'');
|
||||||
|
} else {
|
||||||
|
in_quote = true;
|
||||||
|
result.push('\'');
|
||||||
|
}
|
||||||
|
} else if in_quote {
|
||||||
|
result.push(' ');
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn bind_json_value<'q>(
|
pub(crate) fn bind_json_value<'q>(
|
||||||
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||||
value: &'q Value,
|
value: &'q Value,
|
||||||
|
|||||||
@@ -63,10 +63,7 @@ fn shell_escape(s: &str) -> String {
|
|||||||
|
|
||||||
/// Validate pg_version matches a safe pattern (e.g. "16", "16.2", "17.1")
|
/// Validate pg_version matches a safe pattern (e.g. "16", "16.2", "17.1")
|
||||||
fn validate_pg_version(version: &str) -> TuskResult<()> {
|
fn validate_pg_version(version: &str) -> TuskResult<()> {
|
||||||
let is_valid = !version.is_empty()
|
let is_valid = !version.is_empty() && version.chars().all(|c| c.is_ascii_digit() || c == '.');
|
||||||
&& version
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_digit() || c == '.');
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
return Err(docker_err(format!(
|
return Err(docker_err(format!(
|
||||||
"Invalid pg_version '{}': must contain only digits and dots (e.g. '16', '16.2')",
|
"Invalid pg_version '{}': must contain only digits and dots (e.g. '16', '16.2')",
|
||||||
@@ -116,7 +113,9 @@ pub async fn check_docker(state: State<'_, Arc<AppState>>) -> TuskResult<DockerS
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_tusk_containers(state: State<'_, Arc<AppState>>) -> TuskResult<Vec<TuskContainer>> {
|
pub async fn list_tusk_containers(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
) -> TuskResult<Vec<TuskContainer>> {
|
||||||
let output = docker_cmd(&state)
|
let output = docker_cmd(&state)
|
||||||
.await
|
.await
|
||||||
.args([
|
.args([
|
||||||
@@ -234,8 +233,8 @@ async fn check_docker_internal(docker_host: &Option<String>) -> TuskResult<Docke
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
let daemon_running = !stderr.contains("Cannot connect")
|
let daemon_running =
|
||||||
&& !stderr.contains("connection refused");
|
!stderr.contains("Cannot connect") && !stderr.contains("connection refused");
|
||||||
Ok(DockerStatus {
|
Ok(DockerStatus {
|
||||||
installed: true,
|
installed: true,
|
||||||
daemon_running,
|
daemon_running,
|
||||||
@@ -266,7 +265,14 @@ async fn do_clone(
|
|||||||
let docker_host = state.docker_host.read().await.clone();
|
let docker_host = state.docker_host.read().await.clone();
|
||||||
|
|
||||||
// Step 1: Check Docker
|
// Step 1: Check Docker
|
||||||
emit_progress(app, clone_id, "checking", 5, "Checking Docker availability...", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"checking",
|
||||||
|
5,
|
||||||
|
"Checking Docker availability...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
let status = check_docker_internal(&docker_host).await?;
|
let status = check_docker_internal(&docker_host).await?;
|
||||||
if !status.installed || !status.daemon_running {
|
if !status.installed || !status.daemon_running {
|
||||||
let msg = status
|
let msg = status
|
||||||
@@ -282,23 +288,45 @@ async fn do_clone(
|
|||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => find_free_port().await?,
|
None => find_free_port().await?,
|
||||||
};
|
};
|
||||||
emit_progress(app, clone_id, "port", 10, &format!("Using port {}", host_port), None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"port",
|
||||||
|
10,
|
||||||
|
&format!("Using port {}", host_port),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: Create container
|
// Step 3: Create container
|
||||||
emit_progress(app, clone_id, "container", 20, "Creating PostgreSQL container...", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"container",
|
||||||
|
20,
|
||||||
|
"Creating PostgreSQL container...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
let pg_password = params.postgres_password.as_deref().unwrap_or("tusk");
|
let pg_password = params.postgres_password.as_deref().unwrap_or("tusk");
|
||||||
let image = format!("postgres:{}", params.pg_version);
|
let image = format!("postgres:{}", params.pg_version);
|
||||||
|
|
||||||
let create_output = docker_cmd_sync(&docker_host)
|
let create_output = docker_cmd_sync(&docker_host)
|
||||||
.args([
|
.args([
|
||||||
"run", "-d",
|
"run",
|
||||||
"--name", ¶ms.container_name,
|
"-d",
|
||||||
"-p", &format!("{}:5432", host_port),
|
"--name",
|
||||||
"-e", &format!("POSTGRES_PASSWORD={}", pg_password),
|
¶ms.container_name,
|
||||||
"-l", "tusk.managed=true",
|
"-p",
|
||||||
"-l", &format!("tusk.source-db={}", params.source_database),
|
&format!("{}:5432", host_port),
|
||||||
"-l", &format!("tusk.source-connection={}", params.source_connection_id),
|
"-e",
|
||||||
"-l", &format!("tusk.pg-version={}", params.pg_version),
|
&format!("POSTGRES_PASSWORD={}", pg_password),
|
||||||
|
"-l",
|
||||||
|
"tusk.managed=true",
|
||||||
|
"-l",
|
||||||
|
&format!("tusk.source-db={}", params.source_database),
|
||||||
|
"-l",
|
||||||
|
&format!("tusk.source-connection={}", params.source_connection_id),
|
||||||
|
"-l",
|
||||||
|
&format!("tusk.pg-version={}", params.pg_version),
|
||||||
&image,
|
&image,
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
@@ -306,24 +334,56 @@ async fn do_clone(
|
|||||||
.map_err(|e| docker_err(format!("Failed to create container: {}", e)))?;
|
.map_err(|e| docker_err(format!("Failed to create container: {}", e)))?;
|
||||||
|
|
||||||
if !create_output.status.success() {
|
if !create_output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&create_output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&create_output.stderr)
|
||||||
emit_progress(app, clone_id, "error", 20, &format!("Failed to create container: {}", stderr), None);
|
.trim()
|
||||||
return Err(docker_err(format!("Failed to create container: {}", stderr)));
|
.to_string();
|
||||||
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"error",
|
||||||
|
20,
|
||||||
|
&format!("Failed to create container: {}", stderr),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return Err(docker_err(format!(
|
||||||
|
"Failed to create container: {}",
|
||||||
|
stderr
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let container_id = String::from_utf8_lossy(&create_output.stdout).trim().to_string();
|
let container_id = String::from_utf8_lossy(&create_output.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Step 4: Wait for PostgreSQL to be ready
|
// Step 4: Wait for PostgreSQL to be ready
|
||||||
emit_progress(app, clone_id, "waiting", 30, "Waiting for PostgreSQL to be ready...", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"waiting",
|
||||||
|
30,
|
||||||
|
"Waiting for PostgreSQL to be ready...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
wait_for_pg_ready(&docker_host, ¶ms.container_name, 30).await?;
|
wait_for_pg_ready(&docker_host, ¶ms.container_name, 30).await?;
|
||||||
emit_progress(app, clone_id, "waiting", 35, "PostgreSQL is ready", None);
|
emit_progress(app, clone_id, "waiting", 35, "PostgreSQL is ready", None);
|
||||||
|
|
||||||
// Step 5: Create target database
|
// Step 5: Create target database
|
||||||
emit_progress(app, clone_id, "database", 35, &format!("Creating database '{}'...", params.source_database), None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"database",
|
||||||
|
35,
|
||||||
|
&format!("Creating database '{}'...", params.source_database),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let create_db_output = docker_cmd_sync(&docker_host)
|
let create_db_output = docker_cmd_sync(&docker_host)
|
||||||
.args([
|
.args([
|
||||||
"exec", ¶ms.container_name,
|
"exec",
|
||||||
"psql", "-U", "postgres", "-c",
|
¶ms.container_name,
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
"postgres",
|
||||||
|
"-c",
|
||||||
&format!("CREATE DATABASE {}", escape_ident(¶ms.source_database)),
|
&format!("CREATE DATABASE {}", escape_ident(¶ms.source_database)),
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
@@ -331,20 +391,42 @@ async fn do_clone(
|
|||||||
.map_err(|e| docker_err(format!("Failed to create database: {}", e)))?;
|
.map_err(|e| docker_err(format!("Failed to create database: {}", e)))?;
|
||||||
|
|
||||||
if !create_db_output.status.success() {
|
if !create_db_output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&create_db_output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&create_db_output.stderr)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
if !stderr.contains("already exists") {
|
if !stderr.contains("already exists") {
|
||||||
emit_progress(app, clone_id, "error", 35, &format!("Failed to create database: {}", stderr), None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"error",
|
||||||
|
35,
|
||||||
|
&format!("Failed to create database: {}", stderr),
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Err(docker_err(format!("Failed to create database: {}", stderr)));
|
return Err(docker_err(format!("Failed to create database: {}", stderr)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Get source connection URL (using the specific database to clone)
|
// Step 6: Get source connection URL (using the specific database to clone)
|
||||||
emit_progress(app, clone_id, "dump", 40, "Preparing data transfer...", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"dump",
|
||||||
|
40,
|
||||||
|
"Preparing data transfer...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
let source_config = load_connection_config(app, ¶ms.source_connection_id)?;
|
let source_config = load_connection_config(app, ¶ms.source_connection_id)?;
|
||||||
let source_url = source_config.connection_url_for_db(¶ms.source_database);
|
let source_url = source_config.connection_url_for_db(¶ms.source_database);
|
||||||
emit_progress(
|
emit_progress(
|
||||||
app, clone_id, "dump", 40,
|
app,
|
||||||
&format!("Source: {}@{}:{}/{}", source_config.user, source_config.host, source_config.port, params.source_database),
|
clone_id,
|
||||||
|
"dump",
|
||||||
|
40,
|
||||||
|
&format!(
|
||||||
|
"Source: {}@{}:{}/{}",
|
||||||
|
source_config.user, source_config.host, source_config.port, params.source_database
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,23 +434,84 @@ async fn do_clone(
|
|||||||
match params.clone_mode {
|
match params.clone_mode {
|
||||||
CloneMode::SchemaOnly => {
|
CloneMode::SchemaOnly => {
|
||||||
emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None);
|
emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None);
|
||||||
transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?;
|
transfer_schema_only(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
&source_url,
|
||||||
|
¶ms.container_name,
|
||||||
|
¶ms.source_database,
|
||||||
|
¶ms.pg_version,
|
||||||
|
&docker_host,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
CloneMode::FullClone => {
|
CloneMode::FullClone => {
|
||||||
emit_progress(app, clone_id, "transfer", 45, "Performing full database clone...", None);
|
emit_progress(
|
||||||
transfer_full_clone(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?;
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
45,
|
||||||
|
"Performing full database clone...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
transfer_full_clone(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
&source_url,
|
||||||
|
¶ms.container_name,
|
||||||
|
¶ms.source_database,
|
||||||
|
¶ms.pg_version,
|
||||||
|
&docker_host,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
CloneMode::SampleData => {
|
CloneMode::SampleData => {
|
||||||
|
let has_local = try_local_pg_dump().await;
|
||||||
emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None);
|
emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None);
|
||||||
transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?;
|
transfer_schema_only_with(
|
||||||
emit_progress(app, clone_id, "transfer", 65, "Copying sample data...", None);
|
app,
|
||||||
|
clone_id,
|
||||||
|
&source_url,
|
||||||
|
¶ms.container_name,
|
||||||
|
¶ms.source_database,
|
||||||
|
¶ms.pg_version,
|
||||||
|
&docker_host,
|
||||||
|
has_local,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
65,
|
||||||
|
"Copying sample data...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
let sample_rows = params.sample_rows.unwrap_or(1000);
|
let sample_rows = params.sample_rows.unwrap_or(1000);
|
||||||
transfer_sample_data(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, sample_rows, &docker_host).await?;
|
transfer_sample_data_with(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
&source_url,
|
||||||
|
¶ms.container_name,
|
||||||
|
¶ms.source_database,
|
||||||
|
¶ms.pg_version,
|
||||||
|
sample_rows,
|
||||||
|
&docker_host,
|
||||||
|
has_local,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Save connection in Tusk
|
// Step 8: Save connection in Tusk
|
||||||
emit_progress(app, clone_id, "connection", 90, "Saving connection...", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"connection",
|
||||||
|
90,
|
||||||
|
"Saving connection...",
|
||||||
|
None,
|
||||||
|
);
|
||||||
let connection_id = uuid::Uuid::new_v4().to_string();
|
let connection_id = uuid::Uuid::new_v4().to_string();
|
||||||
let new_config = ConnectionConfig {
|
let new_config = ConnectionConfig {
|
||||||
id: connection_id.clone(),
|
id: connection_id.clone(),
|
||||||
@@ -407,7 +550,14 @@ async fn do_clone(
|
|||||||
connection_url,
|
connection_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
emit_progress(app, clone_id, "done", 100, "Clone completed successfully!", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"done",
|
||||||
|
100,
|
||||||
|
"Clone completed successfully!",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -424,7 +574,11 @@ async fn find_free_port() -> TuskResult<u16> {
|
|||||||
Ok(port)
|
Ok(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_pg_ready(docker_host: &Option<String>, container_name: &str, timeout_secs: u64) -> TuskResult<()> {
|
async fn wait_for_pg_ready(
|
||||||
|
docker_host: &Option<String>,
|
||||||
|
container_name: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> TuskResult<()> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||||
|
|
||||||
@@ -466,7 +620,13 @@ fn docker_host_flag(docker_host: &Option<String>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the pg_dump portion of a shell command
|
/// Build the pg_dump portion of a shell command
|
||||||
fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source_url: &str, docker_host: &Option<String>) -> String {
|
fn pg_dump_shell_cmd(
|
||||||
|
has_local: bool,
|
||||||
|
pg_version: &str,
|
||||||
|
extra_args: &str,
|
||||||
|
source_url: &str,
|
||||||
|
docker_host: &Option<String>,
|
||||||
|
) -> String {
|
||||||
let escaped_url = shell_escape(source_url);
|
let escaped_url = shell_escape(source_url);
|
||||||
if has_local {
|
if has_local {
|
||||||
format!("pg_dump {} '{}'", extra_args, escaped_url)
|
format!("pg_dump {} '{}'", extra_args, escaped_url)
|
||||||
@@ -503,7 +663,8 @@ async fn run_pipe_cmd(
|
|||||||
if !stderr.is_empty() {
|
if !stderr.is_empty() {
|
||||||
// Truncate for progress display (full log can be long)
|
// Truncate for progress display (full log can be long)
|
||||||
let short = if stderr.len() > 500 {
|
let short = if stderr.len() > 500 {
|
||||||
let truncated = stderr.char_indices()
|
let truncated = stderr
|
||||||
|
.char_indices()
|
||||||
.nth(500)
|
.nth(500)
|
||||||
.map(|(i, _)| &stderr[..i])
|
.map(|(i, _)| &stderr[..i])
|
||||||
.unwrap_or(&stderr);
|
.unwrap_or(&stderr);
|
||||||
@@ -511,33 +672,57 @@ async fn run_pipe_cmd(
|
|||||||
} else {
|
} else {
|
||||||
stderr.clone()
|
stderr.clone()
|
||||||
};
|
};
|
||||||
emit_progress(app, clone_id, "transfer", 55, &format!("{}: stderr output", label), Some(&short));
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
55,
|
||||||
|
&format!("{}: stderr output", label),
|
||||||
|
Some(&short),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count DDL statements in stdout for feedback
|
// Count DDL statements in stdout for feedback
|
||||||
if !stdout.is_empty() {
|
if !stdout.is_empty() {
|
||||||
let creates = stdout.lines()
|
let creates = stdout
|
||||||
|
.lines()
|
||||||
.filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER") || l.starts_with("SET"))
|
.filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER") || l.starts_with("SET"))
|
||||||
.count();
|
.count();
|
||||||
if creates > 0 {
|
if creates > 0 {
|
||||||
emit_progress(app, clone_id, "transfer", 58, &format!("Applied {} SQL statements", creates), None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
58,
|
||||||
|
&format!("Applied {} SQL statements", creates),
|
||||||
|
None,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let code = output.status.code().unwrap_or(-1);
|
let code = output.status.code().unwrap_or(-1);
|
||||||
emit_progress(
|
emit_progress(
|
||||||
app, clone_id, "transfer", 55,
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
55,
|
||||||
&format!("{} exited with code {}", label, code),
|
&format!("{} exited with code {}", label, code),
|
||||||
Some(&stderr),
|
Some(&stderr),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only hard-fail on connection / fatal errors
|
// Only hard-fail on connection / fatal errors
|
||||||
if stderr.contains("FATAL") || stderr.contains("could not connect")
|
if stderr.contains("FATAL")
|
||||||
|| stderr.contains("No such file") || stderr.contains("password authentication failed")
|
|| stderr.contains("could not connect")
|
||||||
|| stderr.contains("does not exist") || (stdout.is_empty() && stderr.is_empty())
|
|| stderr.contains("No such file")
|
||||||
|
|| stderr.contains("password authentication failed")
|
||||||
|
|| stderr.contains("does not exist")
|
||||||
|
|| (stdout.is_empty() && stderr.is_empty())
|
||||||
{
|
{
|
||||||
return Err(docker_err(format!("{} failed (exit {}): {}", label, code, stderr)));
|
return Err(docker_err(format!(
|
||||||
|
"{} failed (exit {}): {}",
|
||||||
|
label, code, stderr
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,20 +739,71 @@ async fn transfer_schema_only(
|
|||||||
docker_host: &Option<String>,
|
docker_host: &Option<String>,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
let has_local = try_local_pg_dump().await;
|
let has_local = try_local_pg_dump().await;
|
||||||
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
|
transfer_schema_only_with(
|
||||||
emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for schema...", label), None);
|
app,
|
||||||
|
clone_id,
|
||||||
|
source_url,
|
||||||
|
container_name,
|
||||||
|
database,
|
||||||
|
pg_version,
|
||||||
|
docker_host,
|
||||||
|
has_local,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only --no-owner --no-acl", source_url, docker_host);
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn transfer_schema_only_with(
|
||||||
|
app: &AppHandle,
|
||||||
|
clone_id: &str,
|
||||||
|
source_url: &str,
|
||||||
|
container_name: &str,
|
||||||
|
database: &str,
|
||||||
|
pg_version: &str,
|
||||||
|
docker_host: &Option<String>,
|
||||||
|
has_local: bool,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let label = if has_local {
|
||||||
|
"local pg_dump"
|
||||||
|
} else {
|
||||||
|
"Docker-based pg_dump"
|
||||||
|
};
|
||||||
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
48,
|
||||||
|
&format!("Using {} for schema...", label),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let dump_cmd = pg_dump_shell_cmd(
|
||||||
|
has_local,
|
||||||
|
pg_version,
|
||||||
|
"--schema-only --no-owner --no-acl",
|
||||||
|
source_url,
|
||||||
|
docker_host,
|
||||||
|
);
|
||||||
let escaped_db = shell_escape(database);
|
let escaped_db = shell_escape(database);
|
||||||
let host_flag = docker_host_flag(docker_host);
|
let host_flag = docker_host_flag(docker_host);
|
||||||
let pipe_cmd = format!(
|
let pipe_cmd = format!(
|
||||||
"{} | docker {} exec -i '{}' psql -U postgres -d '{}'",
|
"{} | docker {} exec -i '{}' psql -U postgres -d '{}'",
|
||||||
dump_cmd, host_flag, shell_escape(container_name), escaped_db
|
dump_cmd,
|
||||||
|
host_flag,
|
||||||
|
shell_escape(container_name),
|
||||||
|
escaped_db
|
||||||
);
|
);
|
||||||
|
|
||||||
run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?;
|
run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?;
|
||||||
|
|
||||||
emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
60,
|
||||||
|
"Schema transferred successfully",
|
||||||
|
None,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,16 +817,36 @@ async fn transfer_full_clone(
|
|||||||
docker_host: &Option<String>,
|
docker_host: &Option<String>,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
let has_local = try_local_pg_dump().await;
|
let has_local = try_local_pg_dump().await;
|
||||||
let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" };
|
let label = if has_local {
|
||||||
emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for full clone...", label), None);
|
"local pg_dump"
|
||||||
|
} else {
|
||||||
|
"Docker-based pg_dump"
|
||||||
|
};
|
||||||
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
48,
|
||||||
|
&format!("Using {} for full clone...", label),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
// Use plain text format piped to psql (more reliable than -Fc | pg_restore through docker exec)
|
// Use plain text format piped to psql (more reliable than -Fc | pg_restore through docker exec)
|
||||||
let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--no-owner --no-acl", source_url, docker_host);
|
let dump_cmd = pg_dump_shell_cmd(
|
||||||
|
has_local,
|
||||||
|
pg_version,
|
||||||
|
"--no-owner --no-acl",
|
||||||
|
source_url,
|
||||||
|
docker_host,
|
||||||
|
);
|
||||||
let escaped_db = shell_escape(database);
|
let escaped_db = shell_escape(database);
|
||||||
let host_flag = docker_host_flag(docker_host);
|
let host_flag = docker_host_flag(docker_host);
|
||||||
let pipe_cmd = format!(
|
let pipe_cmd = format!(
|
||||||
"{} | docker {} exec -i '{}' psql -U postgres -d '{}'",
|
"{} | docker {} exec -i '{}' psql -U postgres -d '{}'",
|
||||||
dump_cmd, host_flag, shell_escape(container_name), escaped_db
|
dump_cmd,
|
||||||
|
host_flag,
|
||||||
|
shell_escape(container_name),
|
||||||
|
escaped_db
|
||||||
);
|
);
|
||||||
|
|
||||||
run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?;
|
run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?;
|
||||||
@@ -599,7 +855,8 @@ async fn transfer_full_clone(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn transfer_sample_data(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn transfer_sample_data_with(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
clone_id: &str,
|
clone_id: &str,
|
||||||
source_url: &str,
|
source_url: &str,
|
||||||
@@ -608,6 +865,7 @@ async fn transfer_sample_data(
|
|||||||
pg_version: &str,
|
pg_version: &str,
|
||||||
sample_rows: u32,
|
sample_rows: u32,
|
||||||
docker_host: &Option<String>,
|
docker_host: &Option<String>,
|
||||||
|
has_local: bool,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
// List tables from the target (schema already transferred)
|
// List tables from the target (schema already transferred)
|
||||||
let target_output = docker_cmd_sync(docker_host)
|
let target_output = docker_cmd_sync(docker_host)
|
||||||
@@ -622,21 +880,37 @@ async fn transfer_sample_data(
|
|||||||
.map_err(|e| docker_err(format!("Failed to list tables: {}", e)))?;
|
.map_err(|e| docker_err(format!("Failed to list tables: {}", e)))?;
|
||||||
|
|
||||||
let tables_str = String::from_utf8_lossy(&target_output.stdout);
|
let tables_str = String::from_utf8_lossy(&target_output.stdout);
|
||||||
let tables: Vec<&str> = tables_str.lines().filter(|l| !l.trim().is_empty()).collect();
|
let tables: Vec<&str> = tables_str
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.collect();
|
||||||
let total = tables.len();
|
let total = tables.len();
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
emit_progress(app, clone_id, "transfer", 85, "No tables to copy data for", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
85,
|
||||||
|
"No tables to copy data for",
|
||||||
|
None,
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_local = try_local_pg_dump().await;
|
|
||||||
|
|
||||||
for (i, qualified_table) in tables.iter().enumerate() {
|
for (i, qualified_table) in tables.iter().enumerate() {
|
||||||
let pct = 65 + ((i * 20) / total.max(1)).min(20) as u8;
|
let pct = 65 + ((i * 20) / total.max(1)).min(20) as u8;
|
||||||
emit_progress(
|
emit_progress(
|
||||||
app, clone_id, "transfer", pct,
|
app,
|
||||||
&format!("Copying sample data: {} ({}/{})", qualified_table, i + 1, total),
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
pct,
|
||||||
|
&format!(
|
||||||
|
"Copying sample data: {} ({}/{})",
|
||||||
|
qualified_table,
|
||||||
|
i + 1,
|
||||||
|
total
|
||||||
|
),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -680,17 +954,17 @@ async fn transfer_sample_data(
|
|||||||
source_cmd, host_flag, escaped_container, escaped_db, copy_in_sql
|
source_cmd, host_flag, escaped_container, escaped_db, copy_in_sql
|
||||||
);
|
);
|
||||||
|
|
||||||
let output = Command::new("bash")
|
let output = Command::new("bash").args(["-c", &pipe_cmd]).output().await;
|
||||||
.args(["-c", &pipe_cmd])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if !stderr.is_empty() && (stderr.contains("ERROR") || stderr.contains("FATAL")) {
|
if !stderr.is_empty() && (stderr.contains("ERROR") || stderr.contains("FATAL")) {
|
||||||
emit_progress(
|
emit_progress(
|
||||||
app, clone_id, "transfer", pct,
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
pct,
|
||||||
&format!("Warning: {}", qualified_table),
|
&format!("Warning: {}", qualified_table),
|
||||||
Some(&stderr),
|
Some(&stderr),
|
||||||
);
|
);
|
||||||
@@ -698,7 +972,10 @@ async fn transfer_sample_data(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
emit_progress(
|
emit_progress(
|
||||||
app, clone_id, "transfer", pct,
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
pct,
|
||||||
&format!("Warning: failed to copy {}: {}", qualified_table, e),
|
&format!("Warning: failed to copy {}: {}", qualified_table, e),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -706,7 +983,14 @@ async fn transfer_sample_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_progress(app, clone_id, "transfer", 85, "Sample data transfer completed", None);
|
emit_progress(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
"transfer",
|
||||||
|
85,
|
||||||
|
"Sample data transfer completed",
|
||||||
|
None,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,8 +1060,159 @@ pub async fn remove_container(state: State<'_, Arc<AppState>>, name: String) ->
|
|||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(docker_err(format!("Failed to remove container: {}", stderr)));
|
return Err(docker_err(format!(
|
||||||
|
"Failed to remove container: {}",
|
||||||
|
stderr
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── validate_container_name ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_valid_simple() {
|
||||||
|
assert!(validate_container_name("mycontainer").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_valid_with_dots_dashes_underscores() {
|
||||||
|
assert!(validate_container_name("my-container_v1.2").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_valid_starts_with_digit() {
|
||||||
|
assert!(validate_container_name("1container").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_empty() {
|
||||||
|
assert!(validate_container_name("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_starts_with_dash() {
|
||||||
|
assert!(validate_container_name("-bad").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_starts_with_dot() {
|
||||||
|
assert!(validate_container_name(".bad").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_starts_with_underscore() {
|
||||||
|
assert!(validate_container_name("_bad").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_with_spaces() {
|
||||||
|
assert!(validate_container_name("bad name").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_with_unicode() {
|
||||||
|
assert!(validate_container_name("контейнер").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_with_special_chars() {
|
||||||
|
assert!(validate_container_name("bad;name").is_err());
|
||||||
|
assert!(validate_container_name("bad/name").is_err());
|
||||||
|
assert!(validate_container_name("bad:name").is_err());
|
||||||
|
assert!(validate_container_name("bad@name").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_name_with_shell_injection() {
|
||||||
|
assert!(validate_container_name("x; rm -rf /").is_err());
|
||||||
|
assert!(validate_container_name("x$(whoami)").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validate_pg_version ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_valid_major() {
|
||||||
|
assert!(validate_pg_version("16").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_valid_major_minor() {
|
||||||
|
assert!(validate_pg_version("16.2").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_valid_three_parts() {
|
||||||
|
assert!(validate_pg_version("17.1.0").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_empty() {
|
||||||
|
assert!(validate_pg_version("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_with_letters() {
|
||||||
|
assert!(validate_pg_version("16beta1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_with_injection() {
|
||||||
|
assert!(validate_pg_version("16; rm -rf").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_version_only_dots() {
|
||||||
|
// Current impl allows dots-only — this documents the behavior
|
||||||
|
assert!(validate_pg_version("...").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shell_escape ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_no_quotes() {
|
||||||
|
assert_eq!(shell_escape("hello"), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_with_single_quote() {
|
||||||
|
assert_eq!(shell_escape("it's"), "it'\\''s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_multiple_quotes() {
|
||||||
|
assert_eq!(shell_escape("a'b'c"), "a'\\''b'\\''c");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shell_escape_double ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_double_no_special() {
|
||||||
|
assert_eq!(shell_escape_double("hello"), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_double_with_backslash() {
|
||||||
|
assert_eq!(shell_escape_double(r"a\b"), r"a\\b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_double_with_dollar() {
|
||||||
|
assert_eq!(shell_escape_double("$HOME"), "\\$HOME");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_double_with_backtick() {
|
||||||
|
assert_eq!(shell_escape_double("`whoami`"), "\\`whoami\\`");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_escape_double_with_double_quote() {
|
||||||
|
assert_eq!(shell_escape_double(r#"say "hi""#), r#"say \"hi\""#);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub async fn export_csv(
|
|||||||
let mut wtr = csv::Writer::from_writer(file);
|
let mut wtr = csv::Writer::from_writer(file);
|
||||||
|
|
||||||
wtr.write_record(&columns)
|
wtr.write_record(&columns)
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let record: Vec<String> = row
|
let record: Vec<String> = row
|
||||||
@@ -27,10 +27,10 @@ pub async fn export_csv(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
wtr.write_record(&record)
|
wtr.write_record(&record)
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
wtr.flush().map_err(|e| TuskError::Custom(e.to_string()))?;
|
wtr.flush().map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn get_history_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("query_history.json"))
|
Ok(dir.join("query_history.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,11 +110,8 @@ pub async fn drop_database(
|
|||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
// Terminate active connections to the target database
|
// Terminate active connections to the target database
|
||||||
let terminate_sql = format!(
|
sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1::name AND pid <> pg_backend_pid()")
|
||||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
|
.bind(&name)
|
||||||
name.replace('\'', "''")
|
|
||||||
);
|
|
||||||
sqlx::query(&terminate_sql)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
@@ -336,10 +333,7 @@ pub async fn alter_role(
|
|||||||
options.push(format!("CONNECTION LIMIT {}", limit));
|
options.push(format!("CONNECTION LIMIT {}", limit));
|
||||||
}
|
}
|
||||||
if let Some(ref valid_until) = params.valid_until {
|
if let Some(ref valid_until) = params.valid_until {
|
||||||
options.push(format!(
|
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
|
||||||
"VALID UNTIL '{}'",
|
|
||||||
valid_until.replace('\'', "''")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !options.is_empty() {
|
if !options.is_empty() {
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ pub mod management;
|
|||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod snapshot;
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -43,20 +43,16 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
|||||||
}
|
}
|
||||||
"DATE" => try_get!(chrono::NaiveDate),
|
"DATE" => try_get!(chrono::NaiveDate),
|
||||||
"TIME" => try_get!(chrono::NaiveTime),
|
"TIME" => try_get!(chrono::NaiveTime),
|
||||||
"BYTEA" => {
|
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||||
match row.try_get::<Option<Vec<u8>>, _>(index) {
|
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
Ok(None) => return Value::Null,
|
||||||
Ok(None) => return Value::Null,
|
Err(_) => {}
|
||||||
Err(_) => {}
|
},
|
||||||
}
|
"OID" => match row.try_get::<Option<i32>, _>(index) {
|
||||||
}
|
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
||||||
"OID" => {
|
Ok(None) => return Value::Null,
|
||||||
match row.try_get::<Option<i32>, _>(index) {
|
Err(_) => {}
|
||||||
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
},
|
||||||
Ok(None) => return Value::Null,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"VOID" => return Value::Null,
|
"VOID" => return Value::Null,
|
||||||
// Array types (PG prefixes array type names with underscore)
|
// Array types (PG prefixes array type names with underscore)
|
||||||
"_BOOL" => try_get!(Vec<bool>),
|
"_BOOL" => try_get!(Vec<bool>),
|
||||||
@@ -124,7 +120,11 @@ pub async fn execute_query_core(
|
|||||||
|
|
||||||
let result_rows: Vec<Vec<Value>> = rows
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let row_count = result_rows.len();
|
let row_count = result_rows.len();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("saved_queries.json"))
|
Ok(dir.join("saved_queries.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ pub async fn list_databases(
|
|||||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_schemas_core(
|
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
||||||
state: &AppState,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<Vec<String>> {
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
@@ -593,11 +590,13 @@ pub async fn get_schema_erd(
|
|||||||
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
|
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
|
||||||
for row in &col_rows {
|
for row in &col_rows {
|
||||||
let table_name: String = row.get(0);
|
let table_name: String = row.get(0);
|
||||||
let entry = tables_map.entry(table_name.clone()).or_insert_with(|| ErdTable {
|
let entry = tables_map
|
||||||
schema: schema.clone(),
|
.entry(table_name.clone())
|
||||||
name: table_name,
|
.or_insert_with(|| ErdTable {
|
||||||
columns: Vec::new(),
|
schema: schema.clone(),
|
||||||
});
|
name: table_name,
|
||||||
|
columns: Vec::new(),
|
||||||
|
});
|
||||||
entry.columns.push(ErdColumn {
|
entry.columns.push(ErdColumn {
|
||||||
name: row.get(1),
|
name: row.get(1),
|
||||||
data_type: row.get(2),
|
data_type: row.get(2),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ fn get_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("app_settings.json"))
|
Ok(dir.join("app_settings.json"))
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ pub async fn save_app_settings(
|
|||||||
let connections_path = app
|
let connections_path = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?
|
.map_err(|e| TuskError::Config(e.to_string()))?
|
||||||
.join("connections.json");
|
.join("connections.json");
|
||||||
|
|
||||||
let mcp_state = state.inner().clone();
|
let mcp_state = state.inner().clone();
|
||||||
|
|||||||
@@ -46,12 +46,13 @@ pub async fn create_snapshot(
|
|||||||
|
|
||||||
if params.include_dependencies {
|
if params.include_dependencies {
|
||||||
for fk in &fk_rows {
|
for fk in &fk_rows {
|
||||||
for (schema, table) in ¶ms.tables.iter().map(|t| (t.schema.clone(), t.table.clone())).collect::<Vec<_>>() {
|
if target_tables
|
||||||
if &fk.schema == schema && &fk.table == table {
|
.iter()
|
||||||
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
.any(|(s, t)| s == &fk.schema && t == &fk.table)
|
||||||
if !target_tables.contains(&parent) {
|
{
|
||||||
target_tables.push(parent);
|
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
||||||
}
|
if !target_tables.contains(&parent) {
|
||||||
|
target_tables.push(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,11 +61,18 @@ pub async fn create_snapshot(
|
|||||||
// FK-based topological sort
|
// FK-based topological sort
|
||||||
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
let fk_edges: Vec<(String, String, String, String)> = fk_rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|fk| (fk.schema.clone(), fk.table.clone(), fk.ref_schema.clone(), fk.ref_table.clone()))
|
.map(|fk| {
|
||||||
|
(
|
||||||
|
fk.schema.clone(),
|
||||||
|
fk.table.clone(),
|
||||||
|
fk.ref_schema.clone(),
|
||||||
|
fk.ref_table.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
let sorted_tables = topological_sort_tables(&fk_edges, &target_tables);
|
||||||
|
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
sqlx::query("SET TRANSACTION READ ONLY")
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
@@ -107,7 +115,11 @@ pub async fn create_snapshot(
|
|||||||
|
|
||||||
let data_rows: Vec<Vec<Value>> = rows
|
let data_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let row_count = data_rows.len() as u64;
|
let row_count = data_rows.len() as u64;
|
||||||
@@ -207,7 +219,7 @@ pub async fn restore_snapshot(
|
|||||||
let snapshot: Snapshot = serde_json::from_str(&data)?;
|
let snapshot: Snapshot = serde_json::from_str(&data)?;
|
||||||
|
|
||||||
let pool = state.get_pool(¶ms.connection_id).await?;
|
let pool = state.get_pool(¶ms.connection_id).await?;
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -313,7 +325,7 @@ pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>>
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?
|
.map_err(|e| TuskError::Config(e.to_string()))?
|
||||||
.join("snapshots");
|
.join("snapshots");
|
||||||
|
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ pub enum TuskError {
|
|||||||
#[error("Docker error: {0}")]
|
#[error("Docker error: {0}")]
|
||||||
Docker(String),
|
Docker(String),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("Export error: {0}")]
|
||||||
|
Export(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,13 @@ pub fn run() {
|
|||||||
let mcp_state = state.clone();
|
let mcp_state = state.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
*mcp_state.mcp_running.write().await = true;
|
*mcp_state.mcp_running.write().await = true;
|
||||||
if let Err(e) =
|
if let Err(e) = mcp::start_mcp_server(
|
||||||
mcp::start_mcp_server(mcp_state.clone(), connections_path, mcp_port, shutdown_rx)
|
mcp_state.clone(),
|
||||||
.await
|
connections_path,
|
||||||
|
mcp_port,
|
||||||
|
shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
log::error!("MCP server error: {}", e);
|
log::error!("MCP server error: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ impl TuskMcpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "List all configured database connections with their active/read-only status")]
|
#[tool(
|
||||||
|
description = "List all configured database connections with their active/read-only status"
|
||||||
|
)]
|
||||||
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
||||||
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
||||||
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
||||||
@@ -110,9 +112,8 @@ impl TuskMcpServer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&statuses).map_err(|e| {
|
let json = serde_json::to_string_pretty(&statuses)
|
||||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
.map_err(|e| McpError::internal_error(format!("Serialization error: {}", e), None))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||||
}
|
}
|
||||||
@@ -165,7 +166,9 @@ impl TuskMcpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Describe table columns: name, type, nullable, primary key, default value")]
|
#[tool(
|
||||||
|
description = "Describe table columns: name, type, nullable, primary key, default value"
|
||||||
|
)]
|
||||||
async fn describe_table(
|
async fn describe_table(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<DescribeTableParam>,
|
Parameters(params): Parameters<DescribeTableParam>,
|
||||||
|
|||||||
@@ -83,16 +83,6 @@ pub struct ValidationRule {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ValidationReport {
|
|
||||||
pub rules: Vec<ValidationRule>,
|
|
||||||
pub total_rules: usize,
|
|
||||||
pub passed: usize,
|
|
||||||
pub failed: usize,
|
|
||||||
pub errors: usize,
|
|
||||||
pub execution_time_ms: u128,
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Wave 2: Data Generator ---
|
// --- Wave 2: Data Generator ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -165,9 +155,12 @@ pub struct SlowQuery {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum IndexRecommendationType {
|
pub enum IndexRecommendationType {
|
||||||
CreateIndex,
|
#[serde(rename = "create_index")]
|
||||||
DropIndex,
|
Create,
|
||||||
ReplaceIndex,
|
#[serde(rename = "drop_index")]
|
||||||
|
Drop,
|
||||||
|
#[serde(rename = "replace_index")]
|
||||||
|
Replace,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ impl ConnectionConfig {
|
|||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
|
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
|
||||||
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
|
| '+' | ',' | ';' | '=' | '%' | ' ' => {
|
||||||
format!("%{:02X}", c as u8)
|
format!("%{:02X}", c as u8)
|
||||||
}
|
}
|
||||||
_ => c.to_string(),
|
_ => c.to_string(),
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub mcp: McpSettings,
|
pub mcp: McpSettings,
|
||||||
pub docker: DockerSettings,
|
pub docker: DockerSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
mcp: McpSettings::default(),
|
|
||||||
docker: DockerSettings::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct McpSettings {
|
pub struct McpSettings {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use crate::models::ai::AiSettings;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::{watch, RwLock};
|
use tokio::sync::{watch, RwLock};
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ pub struct SchemaCacheEntry {
|
|||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||||
pub config_path: RwLock<Option<PathBuf>>,
|
|
||||||
pub read_only: RwLock<HashMap<String, bool>>,
|
pub read_only: RwLock<HashMap<String, bool>>,
|
||||||
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
||||||
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
|
pub schema_cache: RwLock<HashMap<String, SchemaCacheEntry>>,
|
||||||
@@ -33,13 +31,13 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
||||||
|
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (mcp_shutdown_tx, _) = watch::channel(false);
|
let (mcp_shutdown_tx, _) = watch::channel(false);
|
||||||
Self {
|
Self {
|
||||||
pools: RwLock::new(HashMap::new()),
|
pools: RwLock::new(HashMap::new()),
|
||||||
config_path: RwLock::new(None),
|
|
||||||
read_only: RwLock::new(HashMap::new()),
|
read_only: RwLock::new(HashMap::new()),
|
||||||
db_flavors: RwLock::new(HashMap::new()),
|
db_flavors: RwLock::new(HashMap::new()),
|
||||||
schema_cache: RwLock::new(HashMap::new()),
|
schema_cache: RwLock::new(HashMap::new()),
|
||||||
@@ -81,6 +79,18 @@ impl AppState {
|
|||||||
|
|
||||||
pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) {
|
pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) {
|
||||||
let mut cache = self.schema_cache.write().await;
|
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(
|
cache.insert(
|
||||||
connection_id,
|
connection_id,
|
||||||
SchemaCacheEntry {
|
SchemaCacheEntry {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
pub fn escape_ident(name: &str) -> String {
|
pub fn escape_ident(name: &str) -> String {
|
||||||
format!("\"{}\"", name.replace('"', "\"\""))
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
@@ -34,33 +34,43 @@ pub fn topological_sort_tables(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if graph.entry(parent.clone()).or_default().insert(child.clone()) {
|
if graph
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(child.clone())
|
||||||
|
{
|
||||||
*in_degree.entry(child).or_insert(0) += 1;
|
*in_degree.entry(child).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kahn's algorithm
|
// Kahn's algorithm
|
||||||
let mut queue: Vec<(String, String)> = in_degree
|
let mut initial: Vec<(String, String)> = in_degree
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, °)| deg == 0)
|
.filter(|(_, °)| deg == 0)
|
||||||
.map(|(k, _)| k.clone())
|
.map(|(k, _)| k.clone())
|
||||||
.collect();
|
.collect();
|
||||||
queue.sort(); // deterministic order
|
initial.sort(); // deterministic order
|
||||||
|
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
while let Some(node) = queue.pop() {
|
while let Some(node) = queue.pop_front() {
|
||||||
result.push(node.clone());
|
result.push(node.clone());
|
||||||
if let Some(neighbors) = graph.get(&node) {
|
if let Some(neighbors) = graph.get(&node) {
|
||||||
for neighbor in neighbors {
|
let mut new_ready: Vec<(String, String)> = neighbors
|
||||||
if let Some(deg) = in_degree.get_mut(neighbor) {
|
.iter()
|
||||||
*deg -= 1;
|
.filter(|neighbor| {
|
||||||
if *deg == 0 {
|
if let Some(deg) = in_degree.get_mut(*neighbor) {
|
||||||
queue.push(neighbor.clone());
|
*deg -= 1;
|
||||||
queue.sort();
|
*deg == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
new_ready.sort();
|
||||||
|
queue.extend(new_ready);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +83,136 @@ pub fn topological_sort_tables(
|
|||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── escape_ident ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_simple_name() {
|
||||||
|
assert_eq!(escape_ident("users"), "\"users\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_double_quotes() {
|
||||||
|
assert_eq!(escape_ident(r#"my"table"#), r#""my""table""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_empty_string() {
|
||||||
|
assert_eq!(escape_ident(""), r#""""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_spaces() {
|
||||||
|
assert_eq!(escape_ident("my table"), "\"my table\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_semicolon() {
|
||||||
|
assert_eq!(escape_ident("users; DROP TABLE"), "\"users; DROP TABLE\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_single_quotes() {
|
||||||
|
assert_eq!(escape_ident("it's"), "\"it's\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_backslash() {
|
||||||
|
assert_eq!(escape_ident(r"back\slash"), r#""back\slash""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_unicode() {
|
||||||
|
assert_eq!(escape_ident("таблица"), "\"таблица\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_multiple_double_quotes() {
|
||||||
|
assert_eq!(escape_ident(r#"a""b"#), r#""a""""b""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_reserved_word() {
|
||||||
|
assert_eq!(escape_ident("select"), "\"select\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_null_byte() {
|
||||||
|
assert_eq!(escape_ident("a\0b"), "\"a\0b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function App() {
|
|||||||
}, [handleNewQuery, handleCloseTab]);
|
}, [handleNewQuery, handleCloseTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="tusk-noise flex h-screen flex-col">
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup orientation="horizontal">
|
<ResizablePanelGroup orientation="horizontal">
|
||||||
|
|||||||
@@ -52,21 +52,21 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b bg-muted/50 px-2 py-1">
|
<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 text-purple-500" />
|
<Sparkles className="h-3.5 w-3.5 shrink-0 tusk-ai-icon" />
|
||||||
<Input
|
<Input
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Describe the query you want..."
|
placeholder="Describe the query you want..."
|
||||||
className="h-7 min-w-0 flex-1 text-xs"
|
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
|
autoFocus
|
||||||
disabled={generateMutation.isPending}
|
disabled={generateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px] text-tusk-purple hover:bg-tusk-purple/10 hover:text-tusk-purple"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={generateMutation.isPending || !prompt.trim()}
|
disabled={generateMutation.isPending || !prompt.trim()}
|
||||||
>
|
>
|
||||||
@@ -78,23 +78,23 @@ export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Prop
|
|||||||
</Button>
|
</Button>
|
||||||
{prompt.trim() && (
|
{prompt.trim() && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => setPrompt("")}
|
onClick={() => setPrompt("")}
|
||||||
title="Clear prompt"
|
title="Clear prompt"
|
||||||
disabled={generateMutation.isPending}
|
disabled={generateMutation.isPending}
|
||||||
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Eraser className="h-3 w-3" />
|
<Eraser className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<AiSettingsPopover />
|
<AiSettingsPopover />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close AI bar"
|
title="Close AI bar"
|
||||||
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -99,7 +99,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|||||||
const saveMutation = useSaveConnection();
|
const saveMutation = useSaveConnection();
|
||||||
const testMutation = useTestConnection();
|
const testMutation = useTestConnection();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prev, setPrev] = useState<{ open: boolean; connection: typeof connection }>({ open: false, connection: null });
|
||||||
|
if (open !== prev.open || connection !== prev.connection) {
|
||||||
|
setPrev({ open, connection });
|
||||||
if (open) {
|
if (open) {
|
||||||
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
|
const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() };
|
||||||
setForm(config);
|
setForm(config);
|
||||||
@@ -107,7 +109,7 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
|
|||||||
setDsn(connection ? buildDsn(config) : "");
|
setDsn(connection ? buildDsn(config) : "");
|
||||||
setDsnError(null);
|
setDsnError(null);
|
||||||
}
|
}
|
||||||
}, [open, connection]);
|
}
|
||||||
|
|
||||||
const update = (field: keyof ConnectionConfig, value: string | number) => {
|
const update = (field: keyof ConnectionConfig, value: string | number) => {
|
||||||
setForm((f) => ({ ...f, [field]: value }));
|
setForm((f) => ({ ...f, [field]: value }));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -54,7 +54,9 @@ export function GenerateDataDialog({
|
|||||||
reset,
|
reset,
|
||||||
} = useDataGenerator();
|
} = useDataGenerator();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setStep("config");
|
setStep("config");
|
||||||
setRowCount(10);
|
setRowCount(10);
|
||||||
@@ -62,7 +64,7 @@ export function GenerateDataDialog({
|
|||||||
setCustomInstructions("");
|
setCustomInstructions("");
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}, [open, reset]);
|
}
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
const genId = crypto.randomUUID();
|
const genId = crypto.randomUUID();
|
||||||
|
|||||||
@@ -112,11 +112,13 @@ export function CloneDatabaseDialog({
|
|||||||
useCloneToDocker();
|
useCloneToDocker();
|
||||||
|
|
||||||
// Reset state when dialog opens
|
// Reset state when dialog opens
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState<{ open: boolean; database: string }>({ open: false, database: "" });
|
||||||
|
if (open !== prevOpen.open || database !== prevOpen.database) {
|
||||||
|
setPrevOpen({ open, database });
|
||||||
if (open) {
|
if (open) {
|
||||||
setStep("config");
|
setStep("config");
|
||||||
setContainerName(
|
setContainerName(
|
||||||
`tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${Date.now().toString(36)}`
|
`tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`
|
||||||
);
|
);
|
||||||
setPgVersion("16");
|
setPgVersion("16");
|
||||||
setPortMode("auto");
|
setPortMode("auto");
|
||||||
@@ -127,10 +129,12 @@ export function CloneDatabaseDialog({
|
|||||||
setLogOpen(false);
|
setLogOpen(false);
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}, [open, database, reset]);
|
}
|
||||||
|
|
||||||
// Accumulate progress events into log
|
// Accumulate progress events into log
|
||||||
useEffect(() => {
|
const [prevProgress, setPrevProgress] = useState(progress);
|
||||||
|
if (progress !== prevProgress) {
|
||||||
|
setPrevProgress(progress);
|
||||||
if (progress) {
|
if (progress) {
|
||||||
setLogEntries((prev) => {
|
setLogEntries((prev) => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
@@ -143,7 +147,7 @@ export function CloneDatabaseDialog({
|
|||||||
setStep("done");
|
setStep("done");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [progress]);
|
}
|
||||||
|
|
||||||
// Auto-scroll log to bottom
|
// Auto-scroll log to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useCallback, useEffect, useState } from "react";
|
import { useMemo, useCallback, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -111,12 +111,14 @@ export function ErdDiagram({ connectionId, schema }: Props) {
|
|||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
const [edges, setEdges] = useState<Edge[]>([]);
|
const [edges, setEdges] = useState<Edge[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevLayout, setPrevLayout] = useState(layout);
|
||||||
|
if (layout !== prevLayout) {
|
||||||
|
setPrevLayout(layout);
|
||||||
if (layout) {
|
if (layout) {
|
||||||
setNodes(layout.nodes);
|
setNodes(layout.nodes);
|
||||||
setEdges(layout.edges);
|
setEdges(layout.edges);
|
||||||
}
|
}
|
||||||
}, [layout]);
|
}
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||||
|
|||||||
@@ -36,24 +36,24 @@ export function ReadOnlyToggle() {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className={`h-7 gap-1.5 text-xs font-medium ${
|
className={`gap-1.5 font-medium ${
|
||||||
isReadOnly
|
isReadOnly
|
||||||
? "text-yellow-600 dark:text-yellow-500"
|
? "text-amber-500 hover:bg-amber-500/10 hover:text-amber-500"
|
||||||
: "text-green-600 dark:text-green-500"
|
: "text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
>
|
>
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="h-3.5 w-3.5" />
|
<Lock className="h-3 w-3" />
|
||||||
Read-Only
|
<span className="text-[11px] tracking-wide">Read-Only</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LockOpen className="h-3.5 w-3.5" />
|
<LockOpen className="h-3 w-3" />
|
||||||
Read-Write
|
<span className="text-[11px] tracking-wide">Read-Write</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SchemaTree } from "@/components/schema/SchemaTree";
|
|||||||
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
||||||
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
||||||
import { AdminPanel } from "@/components/management/AdminPanel";
|
import { AdminPanel } from "@/components/management/AdminPanel";
|
||||||
import { Search, RefreshCw } from "lucide-react";
|
import { Search, RefreshCw, Layers, Clock, Bookmark, Shield } from "lucide-react";
|
||||||
|
|
||||||
type SidebarView = "schema" | "history" | "saved" | "admin";
|
type SidebarView = "schema" | "history" | "saved" | "admin";
|
||||||
|
|
||||||
@@ -20,6 +20,13 @@ const SCHEMA_QUERY_KEYS = [
|
|||||||
"functions", "sequences", "completionSchema", "column-details",
|
"functions", "sequences", "completionSchema", "column-details",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_TABS: { id: SidebarView; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: "schema", label: "Schema", icon: <Layers className="h-3.5 w-3.5" /> },
|
||||||
|
{ id: "history", label: "History", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||||
|
{ id: "saved", label: "Saved", icon: <Bookmark className="h-3.5 w-3.5" /> },
|
||||||
|
{ id: "admin", label: "Admin", icon: <Shield className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [view, setView] = useState<SidebarView>("schema");
|
const [view, setView] = useState<SidebarView>("schema");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -32,58 +39,33 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-card">
|
<div className="flex h-full flex-col" style={{ background: "var(--sidebar)" }}>
|
||||||
<div className="flex border-b text-xs">
|
{/* Sidebar navigation tabs */}
|
||||||
<button
|
<div className="flex border-b border-border/50">
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
{SIDEBAR_TABS.map((tab) => (
|
||||||
view === "schema"
|
<button
|
||||||
? "bg-background text-foreground"
|
key={tab.id}
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className={`relative flex flex-1 items-center justify-center gap-1.5 px-2 py-2 text-[11px] font-medium tracking-wide transition-colors ${
|
||||||
}`}
|
view === tab.id
|
||||||
onClick={() => setView("schema")}
|
? "text-foreground tusk-sidebar-tab-active"
|
||||||
>
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
Schema
|
}`}
|
||||||
</button>
|
onClick={() => setView(tab.id)}
|
||||||
<button
|
>
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
{tab.icon}
|
||||||
view === "history"
|
<span className="hidden min-[220px]:inline">{tab.label}</span>
|
||||||
? "bg-background text-foreground"
|
</button>
|
||||||
: "text-muted-foreground hover:text-foreground"
|
))}
|
||||||
}`}
|
|
||||||
onClick={() => setView("history")}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
|
||||||
view === "saved"
|
|
||||||
? "bg-background text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setView("saved")}
|
|
||||||
>
|
|
||||||
Saved
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
|
||||||
view === "admin"
|
|
||||||
? "bg-background text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setView("admin")}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "schema" ? (
|
{view === "schema" ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1 p-2">
|
<div className="flex items-center gap-1 p-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search objects..."
|
placeholder="Search objects..."
|
||||||
className="h-7 pl-7 text-xs"
|
className="h-7 border-border/40 bg-background/50 pl-7 text-xs placeholder:text-muted-foreground/40 focus:border-primary/40 focus:ring-primary/20"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -94,6 +76,7 @@ export function Sidebar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
onClick={handleRefreshSchema}
|
onClick={handleRefreshSchema}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useMcpStatus } from "@/hooks/use-settings";
|
import { useMcpStatus } from "@/hooks/use-settings";
|
||||||
import { Circle } from "lucide-react";
|
|
||||||
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -31,51 +30,67 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-6 items-center justify-between border-t bg-card px-3 text-[11px] text-muted-foreground">
|
<div className="tusk-status-bar flex h-6 items-center justify-between px-3 text-[11px] text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
{activeConn?.color ? (
|
{activeConn?.color ? (
|
||||||
<span
|
<span
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
className="inline-block h-2 w-2 rounded-full ring-1 ring-white/10"
|
||||||
style={{ backgroundColor: activeConn.color }}
|
style={{ backgroundColor: activeConn.color }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Circle
|
<span
|
||||||
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isConnected
|
||||||
|
? "bg-emerald-500 shadow-[0_0_6px_theme(--color-emerald-500/40)]"
|
||||||
|
: "bg-muted-foreground/30"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeConn ? activeConn.name : "No connection"}
|
<span className="font-medium">
|
||||||
|
{activeConn ? activeConn.name : "No connection"}
|
||||||
|
</span>
|
||||||
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
<EnvironmentBadge environment={activeConn?.environment} size="sm" />
|
||||||
</span>
|
</span>
|
||||||
{isConnected && activeConnectionId && (
|
{isConnected && activeConnectionId && (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${
|
className={`rounded px-1 py-px text-[10px] font-semibold tracking-wide ${
|
||||||
(readOnlyMap[activeConnectionId] ?? true)
|
(readOnlyMap[activeConnectionId] ?? true)
|
||||||
? "text-yellow-600 dark:text-yellow-500"
|
? "bg-amber-500/10 text-amber-500"
|
||||||
: "text-green-600 dark:text-green-500"
|
: "bg-emerald-500/10 text-emerald-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"}
|
{(readOnlyMap[activeConnectionId] ?? true) ? "READ" : "WRITE"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pgVersion && (
|
{pgVersion && (
|
||||||
<span className="hidden sm:inline">{formatDbVersion(pgVersion)}</span>
|
<span className="hidden text-muted-foreground/60 sm:inline font-mono text-[10px]">
|
||||||
|
{formatDbVersion(pgVersion)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{rowCount != null && <span>{rowCount.toLocaleString()} rows</span>}
|
{rowCount != null && (
|
||||||
{executionTime != null && <span>{executionTime} ms</span>}
|
<span className="font-mono">
|
||||||
|
{rowCount.toLocaleString()} <span className="text-muted-foreground/50">rows</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{executionTime != null && (
|
||||||
|
<span className="font-mono">
|
||||||
|
{executionTime} <span className="text-muted-foreground/50">ms</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="flex items-center gap-1 cursor-default">
|
<span className="flex items-center gap-1.5 cursor-default">
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
className={`inline-block h-1.5 w-1.5 rounded-full transition-colors ${
|
||||||
mcpStatus?.running
|
mcpStatus?.running
|
||||||
? "bg-green-500"
|
? "bg-emerald-500 shadow-[0_0_4px_theme(--color-emerald-500/40)]"
|
||||||
: "bg-muted-foreground/30"
|
: "bg-muted-foreground/20"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span>MCP</span>
|
<span className="tracking-wide">MCP</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|||||||
@@ -23,48 +23,55 @@ export function TabBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b bg-card">
|
<div className="border-b border-border/40" style={{ background: "var(--card)" }}>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => {
|
||||||
<div
|
const isActive = activeTabId === tab.id;
|
||||||
key={tab.id}
|
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
|
||||||
className={`group flex h-8 cursor-pointer items-center gap-1.5 border-r px-3 text-xs ${
|
|
||||||
activeTabId === tab.id
|
return (
|
||||||
? "bg-background text-foreground"
|
<div
|
||||||
: "text-muted-foreground hover:text-foreground"
|
key={tab.id}
|
||||||
}`}
|
className={`group relative flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs transition-colors ${
|
||||||
onClick={() => setActiveTabId(tab.id)}
|
isActive
|
||||||
>
|
? "bg-background text-foreground tusk-tab-active"
|
||||||
{(() => {
|
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground/80"
|
||||||
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
|
}`}
|
||||||
return tabColor ? (
|
onClick={() => setActiveTabId(tab.id)}
|
||||||
|
>
|
||||||
|
{tabColor && (
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 shrink-0 rounded-full"
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: tabColor }}
|
style={{ backgroundColor: tabColor }}
|
||||||
/>
|
/>
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{iconMap[tab.type]}
|
|
||||||
<span className="max-w-[150px] truncate">
|
|
||||||
{tab.title}
|
|
||||||
{tab.database && (
|
|
||||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
|
||||||
{tab.database}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
<span className="opacity-60">{iconMap[tab.type]}</span>
|
||||||
<button
|
<span className="max-w-[150px] truncate font-medium">
|
||||||
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
|
{tab.title}
|
||||||
onClick={(e) => {
|
{tab.database && (
|
||||||
e.stopPropagation();
|
<span className="ml-1 text-[10px] font-normal text-muted-foreground/60">
|
||||||
closeTab(tab.id);
|
{tab.database}
|
||||||
}}
|
</span>
|
||||||
>
|
)}
|
||||||
<X className="h-3 w-3" />
|
</span>
|
||||||
</button>
|
<button
|
||||||
</div>
|
className="ml-1 rounded-sm p-0.5 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-60 hover:!opacity-100"
|
||||||
))}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeTab(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right separator between tabs */}
|
||||||
|
{!isActive && (
|
||||||
|
<div className="absolute right-0 top-1.5 bottom-1.5 w-px bg-border/30" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
|
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
|
||||||
import { ConnectionList } from "@/components/connections/ConnectionList";
|
import { ConnectionList } from "@/components/connections/ConnectionList";
|
||||||
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
|
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
|
||||||
@@ -61,70 +60,73 @@ export function Toolbar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="flex h-10 items-center gap-2 border-b px-3 bg-card"
|
className="tusk-toolbar tusk-conn-strip flex h-10 items-center gap-1.5 px-3"
|
||||||
style={{ borderLeftWidth: activeColor ? 3 : 0, borderLeftColor: activeColor }}
|
style={{
|
||||||
|
"--strip-width": activeColor ? "3px" : "0px",
|
||||||
|
"--strip-color": activeColor ?? "transparent",
|
||||||
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setListOpen(true)}
|
onClick={() => setListOpen(true)}
|
||||||
>
|
>
|
||||||
<Database className="h-3.5 w-3.5" />
|
<Database className="h-3.5 w-3.5" />
|
||||||
Connections
|
<span className="text-xs font-medium">Connections</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<ConnectionSelector />
|
<ConnectionSelector />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
onClick={handleReconnect}
|
onClick={handleReconnect}
|
||||||
disabled={!activeConnectionId || reconnectMutation.isPending}
|
disabled={!activeConnectionId || reconnectMutation.isPending}
|
||||||
title="Reconnect"
|
title="Reconnect"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<ReadOnlyToggle />
|
<ReadOnlyToggle />
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<div className="mx-1 h-4 w-px bg-border" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleNewQuery}
|
onClick={handleNewQuery}
|
||||||
disabled={!activeConnectionId}
|
disabled={!activeConnectionId}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
New Query
|
<span className="text-xs font-medium">New Query</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="xs"
|
||||||
className="h-7 gap-1.5"
|
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleNewLookup}
|
onClick={handleNewLookup}
|
||||||
disabled={!activeConnectionId}
|
disabled={!activeConnectionId}
|
||||||
>
|
>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
Entity Lookup
|
<span className="text-xs font-medium">Lookup</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-xs"
|
||||||
className="h-7 w-7 p-0"
|
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +34,9 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
|
|||||||
|
|
||||||
const alterMutation = useAlterRole();
|
const alterMutation = useAlterRole();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prev, setPrev] = useState<{ open: boolean; role: typeof role }>({ open: false, role: null });
|
||||||
|
if (open !== prev.open || role !== prev.role) {
|
||||||
|
setPrev({ open, role });
|
||||||
if (open && role) {
|
if (open && role) {
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setLogin(role.can_login);
|
setLogin(role.can_login);
|
||||||
@@ -47,7 +49,7 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop
|
|||||||
setValidUntil(role.valid_until ?? "");
|
setValidUntil(role.valid_until ?? "");
|
||||||
setRenameTo("");
|
setRenameTo("");
|
||||||
}
|
}
|
||||||
}, [open, role]);
|
}
|
||||||
|
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -35,7 +35,9 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
const { data: roles } = useRoles(open ? connectionId : null);
|
const { data: roles } = useRoles(open ? connectionId : null);
|
||||||
const createMutation = useCreateDatabase();
|
const createMutation = useCreateDatabase();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
setName("");
|
||||||
setOwner("__default__");
|
setOwner("__default__");
|
||||||
@@ -43,7 +45,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
setEncoding("UTF8");
|
setEncoding("UTF8");
|
||||||
setConnectionLimit(-1);
|
setConnectionLimit(-1);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +34,9 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
|
|||||||
const { data: roles } = useRoles(open ? connectionId : null);
|
const { data: roles } = useRoles(open ? connectionId : null);
|
||||||
const createMutation = useCreateRole();
|
const createMutation = useCreateRole();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
setName("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -48,7 +50,7 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) {
|
|||||||
setValidUntil("");
|
setValidUntil("");
|
||||||
setInRoles([]);
|
setInRoles([]);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -61,14 +61,16 @@ export function GrantRevokeDialog({
|
|||||||
);
|
);
|
||||||
const grantRevokeMutation = useGrantRevoke();
|
const grantRevokeMutation = useGrantRevoke();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setAction("GRANT");
|
setAction("GRANT");
|
||||||
setRoleName("");
|
setRoleName("");
|
||||||
setPrivileges([]);
|
setPrivileges([]);
|
||||||
setWithGrantOption(false);
|
setWithGrantOption(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
|
const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE;
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ export function ResultsTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`truncate px-2 py-1 ${
|
className={`truncate px-2 py-1 font-mono text-[12px] ${
|
||||||
value === null
|
value === null
|
||||||
? "italic text-muted-foreground"
|
? "tusk-grid-cell-null"
|
||||||
: isHighlighted
|
: isHighlighted
|
||||||
? "bg-yellow-900/30"
|
? "tusk-grid-cell-highlight"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={() =>
|
onDoubleClick={() =>
|
||||||
@@ -77,6 +77,7 @@ export function ResultsTable({
|
|||||||
[colNames, onCellDoubleClick, highlightedCells]
|
[colNames, onCellDoubleClick, highlightedCells]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
@@ -108,7 +109,6 @@ export function ResultsTable({
|
|||||||
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
|
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
if (externalSort) {
|
if (externalSort) {
|
||||||
// Cycle: none → ASC → DESC → none
|
|
||||||
if (externalSort.column !== colName) {
|
if (externalSort.column !== colName) {
|
||||||
externalSort.onSort(colName, "ASC");
|
externalSort.onSort(colName, "ASC");
|
||||||
} else if (externalSort.direction === "ASC") {
|
} else if (externalSort.direction === "ASC") {
|
||||||
@@ -141,16 +141,16 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="h-full select-text overflow-auto">
|
<div ref={parentRef} className="h-full select-text overflow-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-10 flex bg-card border-b">
|
<div className="tusk-grid-header sticky top-0 z-10 flex">
|
||||||
{table.getHeaderGroups().map((headerGroup) =>
|
{table.getHeaderGroups().map((headerGroup) =>
|
||||||
headerGroup.headers.map((header) => (
|
headerGroup.headers.map((header) => (
|
||||||
<div
|
<div
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className="relative shrink-0 select-none border-r px-2 py-1.5 text-left text-xs font-medium text-muted-foreground"
|
className="relative shrink-0 select-none border-r border-border/30 px-2 py-1.5 text-left text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
style={{ width: header.getSize(), minWidth: header.getSize() }}
|
style={{ width: header.getSize(), minWidth: header.getSize() }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
|
className="flex cursor-pointer items-center gap-1 transition-colors hover:text-foreground"
|
||||||
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
|
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -158,10 +158,10 @@ export function ResultsTable({
|
|||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
|
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
|
||||||
<ArrowUp className="h-3 w-3" />
|
<ArrowUp className="h-3 w-3 text-primary" />
|
||||||
)}
|
)}
|
||||||
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
|
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
|
||||||
<ArrowDown className="h-3 w-3" />
|
<ArrowDown className="h-3 w-3 text-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Resize handle */}
|
{/* Resize handle */}
|
||||||
@@ -169,7 +169,7 @@ export function ResultsTable({
|
|||||||
onMouseDown={header.getResizeHandler()}
|
onMouseDown={header.getResizeHandler()}
|
||||||
onTouchStart={header.getResizeHandler()}
|
onTouchStart={header.getResizeHandler()}
|
||||||
onDoubleClick={() => header.column.resetSize()}
|
onDoubleClick={() => header.column.resetSize()}
|
||||||
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary ${
|
className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none transition-colors hover:bg-primary/60 ${
|
||||||
header.column.getIsResizing() ? "bg-primary" : ""
|
header.column.getIsResizing() ? "bg-primary" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +190,7 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="absolute left-0 flex hover:bg-accent/50"
|
className="tusk-grid-row absolute left-0 flex transition-colors"
|
||||||
style={{
|
style={{
|
||||||
top: `${virtualRow.start}px`,
|
top: `${virtualRow.start}px`,
|
||||||
height: `${virtualRow.size}px`,
|
height: `${virtualRow.size}px`,
|
||||||
@@ -201,7 +201,7 @@ export function ResultsTable({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="shrink-0 border-b border-r text-xs"
|
className="shrink-0 border-b border-r border-border/20 text-xs"
|
||||||
style={{ width: w, minWidth: w }}
|
style={{ width: w, minWidth: w }}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,9 +23,11 @@ export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const saveMutation = useSaveQuery();
|
const saveMutation = useSaveQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) setName("");
|
if (open) setName("");
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function TableSizeInfo({ item }: { item: SchemaObject }) {
|
|||||||
if (item.row_count != null) parts.push(formatCount(item.row_count));
|
if (item.row_count != null) parts.push(formatCount(item.row_count));
|
||||||
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
|
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
|
||||||
return (
|
return (
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
|
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/50">
|
||||||
{parts.join(", ")}
|
{parts.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -71,15 +71,18 @@ export function SchemaTree() {
|
|||||||
|
|
||||||
if (!activeConnectionId) {
|
if (!activeConnectionId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 p-6 text-center">
|
||||||
Connect to a database to browse schema.
|
<Database className="h-8 w-8 text-muted-foreground/20" />
|
||||||
|
<p className="text-sm text-muted-foreground/60">
|
||||||
|
Connect to a database to browse schema
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!databases || databases.length === 0) {
|
if (!databases || databases.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground/60">
|
||||||
No databases found.
|
No databases found.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -218,22 +221,26 @@ function DatabaseNode({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium ${
|
className={`flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium ${
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
<span className="text-muted-foreground/50">
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{expanded ? (
|
||||||
) : (
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<HardDrive
|
<HardDrive
|
||||||
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground/50"}`}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="ml-auto text-[10px] text-primary">active</span>
|
<span className="ml-auto rounded-sm bg-primary/10 px-1 py-px text-[9px] font-semibold tracking-wider text-primary uppercase">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -316,7 +323,7 @@ function DatabaseNode({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{expanded && !isActive && (
|
{expanded && !isActive && (
|
||||||
<div className="ml-6 py-1 text-xs text-muted-foreground">
|
<div className="ml-6 py-1 text-xs text-muted-foreground/50">
|
||||||
{isSwitching ? "Switching..." : "Click to switch to this database"}
|
{isSwitching ? "Switching..." : "Click to switch to this database"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -339,7 +346,7 @@ function SchemasForCurrentDb({
|
|||||||
|
|
||||||
if (!schemas || schemas.length === 0) {
|
if (!schemas || schemas.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-1 text-xs text-muted-foreground">No schemas found.</div>
|
<div className="py-1 text-xs text-muted-foreground/50">No schemas found.</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,18 +386,20 @@ function SchemaNode({
|
|||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none font-medium"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
|
<span className="text-muted-foreground/50">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
<FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
<Database className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
)}
|
|
||||||
{expanded ? (
|
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
)}
|
)}
|
||||||
<span>{schema}</span>
|
<span>{schema}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,10 +451,10 @@ function SchemaNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryIcons = {
|
const categoryIcons = {
|
||||||
tables: <Table2 className="h-3.5 w-3.5 text-blue-400" />,
|
tables: <Table2 className="h-3.5 w-3.5 text-sky-400/80" />,
|
||||||
views: <Eye className="h-3.5 w-3.5 text-green-400" />,
|
views: <Eye className="h-3.5 w-3.5 text-emerald-400/80" />,
|
||||||
functions: <FunctionSquare className="h-3.5 w-3.5 text-purple-400" />,
|
functions: <FunctionSquare className="h-3.5 w-3.5 text-violet-400/80" />,
|
||||||
sequences: <Hash className="h-3.5 w-3.5 text-orange-400" />,
|
sequences: <Hash className="h-3.5 w-3.5 text-amber-400/80" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CategoryNode({
|
function CategoryNode({
|
||||||
@@ -498,18 +507,24 @@ function CategoryNode({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
<span className="text-muted-foreground/50">
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{expanded ? (
|
||||||
) : (
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
) : (
|
||||||
)}
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">
|
<span className="truncate font-medium">
|
||||||
{label}
|
{label}
|
||||||
{items ? ` (${items.length})` : ""}
|
{items && (
|
||||||
|
<span className="ml-1 font-normal text-muted-foreground/40">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -522,12 +537,12 @@ function CategoryNode({
|
|||||||
<ContextMenu key={item.name}>
|
<ContextMenu key={item.name}>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
|
||||||
onDoubleClick={() => onOpenTable(item.name)}
|
onDoubleClick={() => onOpenTable(item.name)}
|
||||||
>
|
>
|
||||||
<span className="w-3.5 shrink-0" />
|
<span className="w-3.5 shrink-0" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate text-foreground/80">{item.name}</span>
|
||||||
{category === "tables" && <TableSizeInfo item={item} />}
|
{category === "tables" && <TableSizeInfo item={item} />}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -561,11 +576,11 @@ function CategoryNode({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-sm transition-colors hover:bg-accent/50 cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<span className="w-3.5 shrink-0" />
|
<span className="w-3.5 shrink-0" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate text-foreground/80">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -52,21 +52,25 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Sync form with loaded settings
|
// Sync form with loaded settings
|
||||||
useEffect(() => {
|
const [prevAppSettings, setPrevAppSettings] = useState(appSettings);
|
||||||
|
if (appSettings !== prevAppSettings) {
|
||||||
|
setPrevAppSettings(appSettings);
|
||||||
if (appSettings) {
|
if (appSettings) {
|
||||||
setMcpEnabled(appSettings.mcp.enabled);
|
setMcpEnabled(appSettings.mcp.enabled);
|
||||||
setMcpPort(appSettings.mcp.port);
|
setMcpPort(appSettings.mcp.port);
|
||||||
setDockerHost(appSettings.docker.host);
|
setDockerHost(appSettings.docker.host);
|
||||||
setDockerRemoteUrl(appSettings.docker.remote_url ?? "");
|
setDockerRemoteUrl(appSettings.docker.remote_url ?? "");
|
||||||
}
|
}
|
||||||
}, [appSettings]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevAiSettings, setPrevAiSettings] = useState(aiSettings);
|
||||||
|
if (aiSettings !== prevAiSettings) {
|
||||||
|
setPrevAiSettings(aiSettings);
|
||||||
if (aiSettings) {
|
if (aiSettings) {
|
||||||
setOllamaUrl(aiSettings.ollama_url);
|
setOllamaUrl(aiSettings.ollama_url);
|
||||||
setAiModel(aiSettings.model);
|
setAiModel(aiSettings.model);
|
||||||
}
|
}
|
||||||
}, [aiSettings]);
|
}
|
||||||
|
|
||||||
const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`;
|
const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -43,7 +43,9 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
|
|
||||||
const { create, result, error, isCreating, progress, reset } = useCreateSnapshot();
|
const { create, result, error, isCreating, progress, reset } = useCreateSnapshot();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setStep("config");
|
setStep("config");
|
||||||
setName(`snapshot-${new Date().toISOString().slice(0, 10)}`);
|
setName(`snapshot-${new Date().toISOString().slice(0, 10)}`);
|
||||||
@@ -51,20 +53,23 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props
|
|||||||
setIncludeDeps(true);
|
setIncludeDeps(true);
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, [open, reset]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevSchemas, setPrevSchemas] = useState(schemas);
|
||||||
|
if (schemas !== prevSchemas) {
|
||||||
|
setPrevSchemas(schemas);
|
||||||
if (schemas && schemas.length > 0 && !selectedSchema) {
|
if (schemas && schemas.length > 0 && !selectedSchema) {
|
||||||
setSelectedSchema(schemas.find((s) => s === "public") || schemas[0]);
|
setSelectedSchema(schemas.find((s) => s === "public") || schemas[0]);
|
||||||
}
|
}
|
||||||
}, [schemas, selectedSchema]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevProgress, setPrevProgress] = useState(progress);
|
||||||
|
if (progress !== prevProgress) {
|
||||||
|
setPrevProgress(progress);
|
||||||
if (progress?.stage === "done" || progress?.stage === "error") {
|
if (progress?.stage === "done" || progress?.stage === "error") {
|
||||||
setStep("done");
|
setStep("done");
|
||||||
}
|
}
|
||||||
}, [progress]);
|
}
|
||||||
|
|
||||||
const handleToggleTable = (tableName: string) => {
|
const handleToggleTable = (tableName: string) => {
|
||||||
setSelectedTables((prev) => {
|
setSelectedTables((prev) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -38,7 +38,9 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop
|
|||||||
const readMeta = useReadSnapshotMetadata();
|
const readMeta = useReadSnapshotMetadata();
|
||||||
const { restore, rowsRestored, error, isRestoring, progress, reset } = useRestoreSnapshot();
|
const { restore, rowsRestored, error, isRestoring, progress, reset } = useRestoreSnapshot();
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setStep("select");
|
setStep("select");
|
||||||
setFilePath(null);
|
setFilePath(null);
|
||||||
@@ -46,13 +48,15 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop
|
|||||||
setTruncate(false);
|
setTruncate(false);
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}, [open, reset]);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevProgress, setPrevProgress] = useState(progress);
|
||||||
|
if (progress !== prevProgress) {
|
||||||
|
setPrevProgress(progress);
|
||||||
if (progress?.stage === "done" || progress?.stage === "error") {
|
if (progress?.stage === "done" || progress?.stage === "error") {
|
||||||
setStep("done");
|
setStep("done");
|
||||||
}
|
}
|
||||||
}, [progress]);
|
}
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
const handleSelectFile = async () => {
|
||||||
const selected = await openFile({
|
const selected = await openFile({
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
for (const [_key, change] of pendingChanges) {
|
for (const [, change] of pendingChanges) {
|
||||||
const row = data.rows[change.rowIndex];
|
const row = data.rows[change.rowIndex];
|
||||||
const colName = data.columns[change.colIndex];
|
const colName = data.columns[change.colIndex];
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
@@ -5,19 +6,19 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-default",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-sm shadow-destructive/20 hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-border/60 bg-transparent shadow-xs hover:bg-accent/50 hover:text-accent-foreground hover:border-border",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground/50 selection:bg-primary/20 selection:text-foreground border-border/50 h-9 w-full min-w-0 rounded-md border bg-background/50 px-3 py-1 text-base shadow-xs transition-all duration-150 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-primary/40 focus-visible:ring-primary/15 focus-visible:ring-[3px] focus-visible:bg-background/80",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|||||||
@@ -255,11 +255,12 @@ export function WorkspacePanel({
|
|||||||
<ResizablePanelGroup orientation="vertical">
|
<ResizablePanelGroup orientation="vertical">
|
||||||
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 border-b px-2 py-1">
|
{/* Editor action bar */}
|
||||||
|
<div className="flex items-center gap-1 border-b border-border/40 px-2 py-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px] text-primary hover:bg-primary/10 hover:text-primary"
|
||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
disabled={queryMutation.isPending || !sqlValue.trim()}
|
disabled={queryMutation.isPending || !sqlValue.trim()}
|
||||||
>
|
>
|
||||||
@@ -271,9 +272,9 @@ export function WorkspacePanel({
|
|||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleExplain}
|
onClick={handleExplain}
|
||||||
disabled={queryMutation.isPending || !sqlValue.trim()}
|
disabled={queryMutation.isPending || !sqlValue.trim()}
|
||||||
>
|
>
|
||||||
@@ -285,9 +286,9 @@ export function WorkspacePanel({
|
|||||||
Explain
|
Explain
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleFormat}
|
onClick={handleFormat}
|
||||||
disabled={!sqlValue.trim()}
|
disabled={!sqlValue.trim()}
|
||||||
title="Format SQL (Shift+Alt+F)"
|
title="Format SQL (Shift+Alt+F)"
|
||||||
@@ -296,9 +297,9 @@ export function WorkspacePanel({
|
|||||||
Format
|
Format
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={() => setSaveDialogOpen(true)}
|
onClick={() => setSaveDialogOpen(true)}
|
||||||
disabled={!sqlValue.trim()}
|
disabled={!sqlValue.trim()}
|
||||||
title="Save query"
|
title="Save query"
|
||||||
@@ -306,20 +307,24 @@ export function WorkspacePanel({
|
|||||||
<Bookmark className="h-3 w-3" />
|
<Bookmark className="h-3 w-3" />
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* AI actions group — purple-branded */}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant={aiBarOpen ? "secondary" : "ghost"}
|
variant={aiBarOpen ? "secondary" : "ghost"}
|
||||||
className="h-6 gap-1 text-xs"
|
className={`gap-1 text-[11px] ${aiBarOpen ? "text-tusk-purple" : ""}`}
|
||||||
onClick={() => setAiBarOpen(!aiBarOpen)}
|
onClick={() => setAiBarOpen(!aiBarOpen)}
|
||||||
title="AI SQL Generator"
|
title="AI SQL Generator"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className={`h-3 w-3 ${aiBarOpen ? "tusk-ai-icon" : ""}`} />
|
||||||
AI
|
AI
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 gap-1 text-xs"
|
className="gap-1 text-[11px]"
|
||||||
onClick={handleAiExplain}
|
onClick={handleAiExplain}
|
||||||
disabled={isAiLoading || !sqlValue.trim()}
|
disabled={isAiLoading || !sqlValue.trim()}
|
||||||
title="Explain query with AI"
|
title="Explain query with AI"
|
||||||
@@ -331,35 +336,42 @@ export function WorkspacePanel({
|
|||||||
)}
|
)}
|
||||||
AI Explain
|
AI Explain
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{result && result.columns.length > 0 && (
|
{result && result.columns.length > 0 && (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="mx-1 h-3.5 w-px bg-border/40" />
|
||||||
<Button
|
<DropdownMenu>
|
||||||
size="sm"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
className="h-6 gap-1 text-xs"
|
size="xs"
|
||||||
>
|
variant="ghost"
|
||||||
<Download className="h-3 w-3" />
|
className="gap-1 text-[11px]"
|
||||||
Export
|
>
|
||||||
</Button>
|
<Download className="h-3 w-3" />
|
||||||
</DropdownMenuTrigger>
|
Export
|
||||||
<DropdownMenuContent align="start">
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
</DropdownMenuTrigger>
|
||||||
Export CSV
|
<DropdownMenuContent align="start">
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||||
<DropdownMenuItem onClick={() => handleExport("json")}>
|
Export CSV
|
||||||
Export JSON
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleExport("json")}>
|
||||||
</DropdownMenuContent>
|
Export JSON
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-muted-foreground">
|
|
||||||
Ctrl+Enter to execute
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<span className="text-[10px] text-muted-foreground/50 font-mono">
|
||||||
|
{"\u2318"}Enter
|
||||||
</span>
|
</span>
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
<span className="ml-2 flex items-center gap-1 rounded-sm bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-500">
|
||||||
<Lock className="h-3 w-3" />
|
<Lock className="h-2.5 w-2.5" />
|
||||||
Read-Only
|
READ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -389,35 +401,41 @@ export function WorkspacePanel({
|
|||||||
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{(explainData || result || error || aiExplanation) && (
|
{(explainData || result || error || aiExplanation) && (
|
||||||
<div className="flex shrink-0 items-center border-b text-xs">
|
<div className="flex shrink-0 items-center border-b border-border/40 text-xs">
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
||||||
resultView === "results"
|
resultView === "results"
|
||||||
? "bg-background text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultView("results")}
|
onClick={() => setResultView("results")}
|
||||||
>
|
>
|
||||||
Results
|
Results
|
||||||
|
{resultView === "results" && (
|
||||||
|
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{explainData && (
|
{explainData && (
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`relative px-3 py-1.5 font-medium transition-colors ${
|
||||||
resultView === "explain"
|
resultView === "explain"
|
||||||
? "bg-background text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground/70"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultView("explain")}
|
onClick={() => setResultView("explain")}
|
||||||
>
|
>
|
||||||
Explain
|
Explain
|
||||||
|
{resultView === "explain" && (
|
||||||
|
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 rounded-t bg-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{resultView === "results" && result && result.columns.length > 0 && (
|
{resultView === "results" && result && result.columns.length > 0 && (
|
||||||
<div className="ml-auto mr-2 flex items-center rounded-md border">
|
<div className="ml-auto mr-2 flex items-center overflow-hidden rounded border border-border/40">
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||||
resultViewMode === "table"
|
resultViewMode === "table"
|
||||||
? "bg-muted text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultViewMode("table")}
|
onClick={() => setResultViewMode("table")}
|
||||||
@@ -427,9 +445,9 @@ export function WorkspacePanel({
|
|||||||
Table
|
Table
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||||
resultViewMode === "json"
|
resultViewMode === "json"
|
||||||
? "bg-muted text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultViewMode("json")}
|
onClick={() => setResultViewMode("json")}
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export function useReconnect() {
|
|||||||
setPgVersion(version);
|
setPgVersion(version);
|
||||||
setDbFlavor(id, flavor);
|
setDbFlavor(id, flavor);
|
||||||
setCurrentDatabase(database);
|
setCurrentDatabase(database);
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries({ queryKey: ["databases"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["schemas"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["completion-schema"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,20 +36,31 @@ export function useDataGenerator() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = onDataGenProgress((p) => {
|
let mounted = true;
|
||||||
if (p.gen_id === genIdRef.current) {
|
let unlisten: (() => void) | undefined;
|
||||||
|
onDataGenProgress((p) => {
|
||||||
|
if (mounted && p.gen_id === genIdRef.current) {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
}
|
}
|
||||||
|
}).then((fn) => {
|
||||||
|
if (mounted) {
|
||||||
|
unlisten = fn;
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then((unlisten) => unlisten());
|
mounted = false;
|
||||||
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const previewRef = useRef(previewMutation);
|
const previewRef = useRef(previewMutation);
|
||||||
previewRef.current = previewMutation;
|
|
||||||
const insertRef = useRef(insertMutation);
|
const insertRef = useRef(insertMutation);
|
||||||
insertRef.current = insertMutation;
|
useEffect(() => {
|
||||||
|
previewRef.current = previewMutation;
|
||||||
|
insertRef.current = insertMutation;
|
||||||
|
});
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
previewRef.current.reset();
|
previewRef.current.reset();
|
||||||
|
|||||||
@@ -51,18 +51,29 @@ export function useCloneToDocker() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = onCloneProgress((p) => {
|
let mounted = true;
|
||||||
if (p.clone_id === cloneIdRef.current) {
|
let unlisten: (() => void) | undefined;
|
||||||
|
onCloneProgress((p) => {
|
||||||
|
if (mounted && p.clone_id === cloneIdRef.current) {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
}
|
}
|
||||||
|
}).then((fn) => {
|
||||||
|
if (mounted) {
|
||||||
|
unlisten = fn;
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then((unlisten) => unlisten());
|
mounted = false;
|
||||||
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mutationRef = useRef(mutation);
|
const mutationRef = useRef(mutation);
|
||||||
mutationRef.current = mutation;
|
useEffect(() => {
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
});
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
mutationRef.current.reset();
|
mutationRef.current.reset();
|
||||||
|
|||||||
@@ -53,18 +53,29 @@ export function useCreateSnapshot() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = onSnapshotProgress((p) => {
|
let mounted = true;
|
||||||
if (p.snapshot_id === snapshotIdRef.current) {
|
let unlisten: (() => void) | undefined;
|
||||||
|
onSnapshotProgress((p) => {
|
||||||
|
if (mounted && p.snapshot_id === snapshotIdRef.current) {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
}
|
}
|
||||||
|
}).then((fn) => {
|
||||||
|
if (mounted) {
|
||||||
|
unlisten = fn;
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then((unlisten) => unlisten());
|
mounted = false;
|
||||||
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mutationRef = useRef(mutation);
|
const mutationRef = useRef(mutation);
|
||||||
mutationRef.current = mutation;
|
useEffect(() => {
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
});
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
mutationRef.current.reset();
|
mutationRef.current.reset();
|
||||||
@@ -101,18 +112,29 @@ export function useRestoreSnapshot() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = onSnapshotProgress((p) => {
|
let mounted = true;
|
||||||
if (p.snapshot_id === snapshotIdRef.current) {
|
let unlisten: (() => void) | undefined;
|
||||||
|
onSnapshotProgress((p) => {
|
||||||
|
if (mounted && p.snapshot_id === snapshotIdRef.current) {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
}
|
}
|
||||||
|
}).then((fn) => {
|
||||||
|
if (mounted) {
|
||||||
|
unlisten = fn;
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then((unlisten) => unlisten());
|
mounted = false;
|
||||||
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mutationRef = useRef(mutation);
|
const mutationRef = useRef(mutation);
|
||||||
mutationRef.current = mutation;
|
useEffect(() => {
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
});
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
mutationRef.current.reset();
|
mutationRef.current.reset();
|
||||||
|
|||||||
217
src/stores/__tests__/app-store.test.ts
Normal file
217
src/stores/__tests__/app-store.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { useAppStore } from "../app-store";
|
||||||
|
import type { Tab } from "@/types";
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useAppStore.setState({
|
||||||
|
connections: [],
|
||||||
|
activeConnectionId: null,
|
||||||
|
currentDatabase: null,
|
||||||
|
connectedIds: new Set(),
|
||||||
|
readOnlyMap: {},
|
||||||
|
dbFlavors: {},
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
sidebarWidth: 260,
|
||||||
|
pgVersion: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeTab = (id: string, type: Tab["type"] = "query"): Tab => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: `Tab ${id}`,
|
||||||
|
connectionId: "conn-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Connections ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("connections", () => {
|
||||||
|
it("should set active connection id", () => {
|
||||||
|
useAppStore.getState().setActiveConnectionId("conn-1");
|
||||||
|
expect(useAppStore.getState().activeConnectionId).toBe("conn-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear active connection id", () => {
|
||||||
|
useAppStore.getState().setActiveConnectionId("conn-1");
|
||||||
|
useAppStore.getState().setActiveConnectionId(null);
|
||||||
|
expect(useAppStore.getState().activeConnectionId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add connected id and default to read-only", () => {
|
||||||
|
useAppStore.getState().addConnectedId("conn-1");
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.connectedIds.has("conn-1")).toBe(true);
|
||||||
|
expect(state.readOnlyMap["conn-1"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove connected id and clean up maps", () => {
|
||||||
|
const s = useAppStore.getState();
|
||||||
|
s.addConnectedId("conn-1");
|
||||||
|
s.setDbFlavor("conn-1", "postgresql");
|
||||||
|
s.setReadOnly("conn-1", false);
|
||||||
|
|
||||||
|
useAppStore.getState().removeConnectedId("conn-1");
|
||||||
|
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.connectedIds.has("conn-1")).toBe(false);
|
||||||
|
expect(state.readOnlyMap["conn-1"]).toBeUndefined();
|
||||||
|
expect(state.dbFlavors["conn-1"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect other connections on remove", () => {
|
||||||
|
const s = useAppStore.getState();
|
||||||
|
s.addConnectedId("conn-1");
|
||||||
|
s.addConnectedId("conn-2");
|
||||||
|
s.setDbFlavor("conn-1", "postgresql");
|
||||||
|
s.setDbFlavor("conn-2", "greenplum");
|
||||||
|
|
||||||
|
useAppStore.getState().removeConnectedId("conn-1");
|
||||||
|
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.connectedIds.has("conn-2")).toBe(true);
|
||||||
|
expect(state.readOnlyMap["conn-2"]).toBe(true);
|
||||||
|
expect(state.dbFlavors["conn-2"]).toBe("greenplum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle read-only mode", () => {
|
||||||
|
useAppStore.getState().addConnectedId("conn-1");
|
||||||
|
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
|
||||||
|
|
||||||
|
useAppStore.getState().setReadOnly("conn-1", false);
|
||||||
|
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(false);
|
||||||
|
|
||||||
|
useAppStore.getState().setReadOnly("conn-1", true);
|
||||||
|
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addConnectedId forces read-only true on reconnect", () => {
|
||||||
|
useAppStore.getState().addConnectedId("conn-1");
|
||||||
|
useAppStore.getState().setReadOnly("conn-1", false);
|
||||||
|
// Reconnect resets to read-only
|
||||||
|
useAppStore.getState().addConnectedId("conn-1");
|
||||||
|
expect(useAppStore.getState().readOnlyMap["conn-1"]).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("tabs", () => {
|
||||||
|
it("should add tab and set it as active", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.tabs).toHaveLength(1);
|
||||||
|
expect(state.tabs[0].id).toBe("tab-1");
|
||||||
|
expect(state.activeTabId).toBe("tab-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should activate the most recently added tab", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-2"));
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe("tab-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close tab and activate last remaining", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-2"));
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-3"));
|
||||||
|
useAppStore.getState().setActiveTabId("tab-2");
|
||||||
|
|
||||||
|
useAppStore.getState().closeTab("tab-2");
|
||||||
|
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.tabs).toHaveLength(2);
|
||||||
|
// When closing the active tab, last remaining tab becomes active
|
||||||
|
expect(state.activeTabId).toBe("tab-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set activeTabId to null when closing the only tab", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().closeTab("tab-1");
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs).toHaveLength(0);
|
||||||
|
expect(useAppStore.getState().activeTabId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve activeTabId when closing a non-active tab", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-2"));
|
||||||
|
useAppStore.getState().setActiveTabId("tab-1");
|
||||||
|
|
||||||
|
useAppStore.getState().closeTab("tab-2");
|
||||||
|
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe("tab-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update tab fields without affecting others", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-2"));
|
||||||
|
|
||||||
|
useAppStore.getState().updateTab("tab-1", { title: "Updated" });
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs[0].title).toBe("Updated");
|
||||||
|
expect(useAppStore.getState().tabs[1].title).toBe("Tab tab-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle closing non-existent tab gracefully", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("tab-1"));
|
||||||
|
useAppStore.getState().closeTab("non-existent");
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs).toHaveLength(1);
|
||||||
|
expect(useAppStore.getState().activeTabId).toBe("tab-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different tab types", () => {
|
||||||
|
useAppStore.getState().addTab(makeTab("t1", "query"));
|
||||||
|
useAppStore.getState().addTab(makeTab("t2", "table"));
|
||||||
|
useAppStore.getState().addTab(makeTab("t3", "erd"));
|
||||||
|
|
||||||
|
expect(useAppStore.getState().tabs.map((t) => t.type)).toEqual([
|
||||||
|
"query",
|
||||||
|
"table",
|
||||||
|
"erd",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Database state ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("database state", () => {
|
||||||
|
it("should set and clear current database", () => {
|
||||||
|
useAppStore.getState().setCurrentDatabase("mydb");
|
||||||
|
expect(useAppStore.getState().currentDatabase).toBe("mydb");
|
||||||
|
|
||||||
|
useAppStore.getState().setCurrentDatabase(null);
|
||||||
|
expect(useAppStore.getState().currentDatabase).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set pg version", () => {
|
||||||
|
useAppStore.getState().setPgVersion("16.2");
|
||||||
|
expect(useAppStore.getState().pgVersion).toBe("16.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set db flavor", () => {
|
||||||
|
useAppStore.getState().setDbFlavor("conn-1", "greenplum");
|
||||||
|
expect(useAppStore.getState().dbFlavors["conn-1"]).toBe("greenplum");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sidebar ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("sidebar", () => {
|
||||||
|
it("should set sidebar width", () => {
|
||||||
|
useAppStore.getState().setSidebarWidth(400);
|
||||||
|
expect(useAppStore.getState().sidebarWidth).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have default sidebar width of 260", () => {
|
||||||
|
expect(useAppStore.getState().sidebarWidth).toBe(260);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
TUSK — "Twilight" Design System
|
||||||
|
Soft dark with blue undertones and teal accents
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
@@ -43,50 +48,399 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
|
/* Custom semantic tokens */
|
||||||
|
--color-tusk-teal: var(--tusk-teal);
|
||||||
|
--color-tusk-purple: var(--tusk-purple);
|
||||||
|
--color-tusk-amber: var(--tusk-amber);
|
||||||
|
--color-tusk-rose: var(--tusk-rose);
|
||||||
|
--color-tusk-surface: var(--tusk-surface);
|
||||||
|
|
||||||
|
/* Font families */
|
||||||
|
--font-sans: "Outfit", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
/* Soft twilight palette — comfortable, not eye-straining */
|
||||||
--card: oklch(0.205 0 0);
|
--background: oklch(0.2 0.012 250);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.9 0.005 250);
|
||||||
--popover: oklch(0.205 0 0);
|
--card: oklch(0.23 0.012 250);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.9 0.005 250);
|
||||||
--primary: oklch(0.922 0 0);
|
--popover: oklch(0.25 0.014 250);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--popover-foreground: oklch(0.9 0.005 250);
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
/* Teal primary — slightly softer for the lighter background */
|
||||||
--muted: oklch(0.269 0 0);
|
--primary: oklch(0.72 0.14 170);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--primary-foreground: oklch(0.18 0.015 250);
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
/* Surfaces — gentle stepping */
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--secondary: oklch(0.27 0.012 250);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--secondary-foreground: oklch(0.85 0.008 250);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--muted: oklch(0.27 0.012 250);
|
||||||
--ring: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.62 0.015 250);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--accent: oklch(0.28 0.014 250);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--accent-foreground: oklch(0.9 0.005 250);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
/* Status */
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--destructive: oklch(0.65 0.2 15);
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
/* Borders & inputs — more visible, less transparent */
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--border: oklch(0.34 0.015 250 / 70%);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--input: oklch(0.36 0.015 250 / 60%);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--ring: oklch(0.72 0.14 170 / 40%);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
/* Chart palette */
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--chart-1: oklch(0.72 0.14 170);
|
||||||
|
--chart-2: oklch(0.68 0.14 200);
|
||||||
|
--chart-3: oklch(0.78 0.14 85);
|
||||||
|
--chart-4: oklch(0.62 0.18 290);
|
||||||
|
--chart-5: oklch(0.68 0.16 30);
|
||||||
|
|
||||||
|
/* Sidebar <20><> same family, slightly offset */
|
||||||
|
--sidebar: oklch(0.215 0.012 250);
|
||||||
|
--sidebar-foreground: oklch(0.9 0.005 250);
|
||||||
|
--sidebar-primary: oklch(0.72 0.14 170);
|
||||||
|
--sidebar-primary-foreground: oklch(0.9 0.005 250);
|
||||||
|
--sidebar-accent: oklch(0.28 0.014 250);
|
||||||
|
--sidebar-accent-foreground: oklch(0.9 0.005 250);
|
||||||
|
--sidebar-border: oklch(0.34 0.015 250 / 70%);
|
||||||
|
--sidebar-ring: oklch(0.72 0.14 170 / 40%);
|
||||||
|
|
||||||
|
/* Tusk semantic tokens */
|
||||||
|
--tusk-teal: oklch(0.72 0.14 170);
|
||||||
|
--tusk-purple: oklch(0.62 0.2 290);
|
||||||
|
--tusk-amber: oklch(0.78 0.14 85);
|
||||||
|
--tusk-rose: oklch(0.65 0.2 15);
|
||||||
|
--tusk-surface: oklch(0.26 0.012 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Base layer
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace for code and data */
|
||||||
|
code, pre, .font-mono,
|
||||||
|
[data-slot="sql-editor"],
|
||||||
|
.cm-editor {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smoother scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(0.42 0.015 250 / 45%);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(0.5 0.015 250 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Noise texture overlay — very subtle depth
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-noise::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.018;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 256px 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Glow effects — softer for lighter background
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-glow-teal {
|
||||||
|
box-shadow: 0 0 10px oklch(0.72 0.14 170 / 12%),
|
||||||
|
0 0 3px oklch(0.72 0.14 170 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-glow-purple {
|
||||||
|
box-shadow: 0 0 10px oklch(0.62 0.2 290 / 15%),
|
||||||
|
0 0 3px oklch(0.62 0.2 290 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-glow-teal-subtle {
|
||||||
|
box-shadow: 0 0 6px oklch(0.72 0.14 170 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════<E29590><E29590>═══════
|
||||||
|
Active tab indicator — top glow bar
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-tab-active {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-tab-active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, oklch(0.72 0.14 170), oklch(0.68 0.14 200));
|
||||||
|
border-radius: 0 0 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
AI feature branding — purple glow language
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-ai-bar {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(0.62 0.2 290 / 5%) 0%,
|
||||||
|
oklch(0.62 0.2 290 / 2%) 50%,
|
||||||
|
oklch(0.72 0.14 170 / 3%) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid oklch(0.62 0.2 290 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-ai-icon {
|
||||||
|
color: oklch(0.68 0.18 290);
|
||||||
|
filter: drop-shadow(0 0 3px oklch(0.62 0.2 290 / 30%));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Transitions — smooth everything
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
button, a, [role="tab"], [role="menuitem"], [data-slot="button"] {
|
||||||
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Glassmorphism for floating elements
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
[data-radix-popper-content-wrapper] [role="dialog"],
|
||||||
|
[data-radix-popper-content-wrapper] [role="listbox"],
|
||||||
|
[data-radix-popper-content-wrapper] [role="menu"],
|
||||||
|
[data-state="open"][data-side] {
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Sidebar tab active indicator
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-sidebar-tab-active {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-sidebar-tab-active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
height: 2px;
|
||||||
|
background: oklch(0.72 0.14 170);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Data grid refinements
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-grid-header {
|
||||||
|
background: oklch(0.23 0.012 250);
|
||||||
|
border-bottom: 1px solid oklch(0.34 0.015 250 / 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-grid-row:hover {
|
||||||
|
background: oklch(0.72 0.14 170 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-grid-cell-null {
|
||||||
|
color: oklch(0.5 0.015 250);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-grid-cell-highlight {
|
||||||
|
background: oklch(0.78 0.14 85 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Status bar
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-status-bar {
|
||||||
|
background: oklch(0.215 0.012 250);
|
||||||
|
border-top: 1px solid oklch(0.34 0.015 250 / 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Connection color accent strip
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-conn-strip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-conn-strip::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--strip-width, 3px);
|
||||||
|
background: var(--strip-color, transparent);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Toolbar
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tusk-toolbar {
|
||||||
|
background: oklch(0.23 0.012 250);
|
||||||
|
border-bottom: 1px solid oklch(0.34 0.015 250 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Resizable handle
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
[data-panel-group-direction="horizontal"] > [data-resize-handle] {
|
||||||
|
width: 1px !important;
|
||||||
|
background: oklch(0.34 0.015 250 / 50%);
|
||||||
|
transition: background 200ms, width 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-panel-group-direction="horizontal"] > [data-resize-handle]:hover,
|
||||||
|
[data-panel-group-direction="horizontal"] > [data-resize-handle][data-resize-handle-active] {
|
||||||
|
width: 3px !important;
|
||||||
|
background: oklch(0.72 0.14 170 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-panel-group-direction="vertical"] > [data-resize-handle] {
|
||||||
|
height: 1px !important;
|
||||||
|
background: oklch(0.34 0.015 250 / 50%);
|
||||||
|
transition: background 200ms, height 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-panel-group-direction="vertical"] > [data-resize-handle]:hover,
|
||||||
|
[data-panel-group-direction="vertical"] > [data-resize-handle][data-resize-handle-active] {
|
||||||
|
height: 3px !important;
|
||||||
|
background: oklch(0.72 0.14 170 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
CodeMirror theme overrides
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.cm-editor {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-gutters {
|
||||||
|
background: oklch(0.215 0.012 250);
|
||||||
|
border-right: 1px solid oklch(0.34 0.015 250 / 50%);
|
||||||
|
color: oklch(0.48 0.012 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-activeLineGutter {
|
||||||
|
background: oklch(0.72 0.14 170 / 8%);
|
||||||
|
color: oklch(0.65 0.015 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-activeLine {
|
||||||
|
background: oklch(0.72 0.14 170 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-cursor {
|
||||||
|
border-left-color: oklch(0.72 0.14 170);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-selectionBackground {
|
||||||
|
background: oklch(0.72 0.14 170 / 15%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-tooltip-autocomplete {
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
background: oklch(0.25 0.014 250 / 95%);
|
||||||
|
border: 1px solid oklch(0.34 0.015 250 / 70%);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 32px oklch(0 0 0 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Sonner toast styling
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
[data-sonner-toaster] [data-sonner-toast] {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Utility animations
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@keyframes tusk-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tusk-pulse-glow {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-fade-in {
|
||||||
|
animation: tusk-fade-in 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tusk-pulse-glow {
|
||||||
|
animation: tusk-pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
Selection styling
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: oklch(0.72 0.14 170 / 25%);
|
||||||
|
color: oklch(0.95 0.005 250);
|
||||||
|
}
|
||||||
|
|||||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user