Compare commits

..

7 Commits

Author SHA1 Message Date
2d2dcdc4a8 fix: resolve all 25 ESLint react-hooks and react-refresh violations
Replace useEffect-based state resets in dialogs with React's render-time
state adjustment pattern. Wrap ref assignments in hooks with useEffect.
Suppress known third-party library warnings (shadcn CVA exports,
TanStack Table). Remove warn downgrades from eslint config.
2026-04-08 07:41:34 +03:00
9237c7dd8e perf: optimize HTTP client, DB queries, and clean up dead code
- Make reqwest::Client a LazyLock singleton instead of per-call allocation
- Parallelize 3 independent DB queries in get_index_advisor_report with tokio::join!
- Eliminate per-iteration Vec allocation in snapshot FK dependency loop
- Hoist try_local_pg_dump() call in SampleData clone mode to avoid double execution
- Evict stale schema cache entries on write to prevent unbounded memory growth
- Remove unused ValidationReport struct and config_path field
- Rename IndexRecommendationType variants to remove redundant suffix
2026-04-06 13:13:03 +03:00
6b925d6260 style: apply rustfmt, fix clippy warnings, and minor code cleanup
Reformat Rust code with rustfmt, suppress clippy::too_many_arguments
for Tauri IPC commands, derive Default for AppSettings, fix unused
variable pattern in TableDataView, and add unit tests for utils.
2026-04-06 13:12:52 +03:00
1e002d801a chore: update build config, linting, and add test infrastructure
Replace install -D with mkdir -p + install for macOS portability,
add vitest with jsdom and testing-library, configure eslint for
react-hooks v7 warnings, and add tokio test deps for Rust.
2026-04-06 13:12:43 +03:00
f8dd94a6c7 chore: remove node_modules prerequisite from make dev 2026-04-06 10:27:24 +03:00
4e5714b291 feat: redesign UI with Twilight design system
Outfit + JetBrains Mono typography, soft dark palette with blue
undertones, electric teal primary, purple-branded AI features,
noise texture, glow effects, glassmorphism, and refined grid/tree.
2026-04-06 10:27:20 +03:00
64e27f79a4 ci: configure Gitea Actions for Docker-based runner
Some checks failed
CI / lint-and-build (push) Failing after 2s
Use explicit ubuntu:22.04 container instead of marketplace actions
that may not work on self-hosted Gitea runners. Install Node.js and
Rust toolchain directly via curl/rustup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:38:00 +03:00
58 changed files with 3273 additions and 739 deletions

View File

@@ -2,37 +2,37 @@ name: CI
on: on:
push: push:
branches: [main, master] branches: [main]
pull_request: pull_request:
branches: [main, master] branches: [main]
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
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 \
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
with: run: |
components: clippy, rustfmt 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 --component clippy,rustfmt
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
@@ -41,51 +41,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
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

View File

@@ -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:

View File

@@ -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

View File

@@ -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 }],
},
}, },
]) ])

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -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"] }

View File

@@ -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,12 +20,16 @@ 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 {
use std::sync::LazyLock;
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder() reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5)) .connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(300)) .timeout(Duration::from_secs(300))
.build() .build()
.unwrap_or_default() .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> {
@@ -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::Custom(
"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::Custom(
"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(&params.connection_id).await?; let pool = state.get_pool(&params.connection_id).await?;
let _ = app.emit("datagen-progress", DataGenProgress { let _ = app.emit(
"datagen-progress",
DataGenProgress {
gen_id: gen_id.clone(), gen_id: gen_id.clone(),
stage: "schema".to_string(), stage: "schema".to_string(),
percent: 10, percent: 10,
message: "Building schema context...".to_string(), message: "Building schema context...".to_string(),
detail: None, detail: None,
}); },
);
let schema_text = build_schema_context(&state, &params.connection_id).await?; let schema_text = build_schema_context(&state, &params.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(
"datagen-progress",
DataGenProgress {
gen_id: gen_id.clone(), gen_id: gen_id.clone(),
stage: "generating".to_string(), stage: "generating".to_string(),
percent: 30, percent: 30,
message: "AI is generating test data...".to_string(), message: "AI is generating test data...".to_string(),
detail: None, 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(
"datagen-progress",
DataGenProgress {
gen_id: gen_id.clone(), gen_id: gen_id.clone(),
stage: "parsing".to_string(), stage: "parsing".to_string(),
percent: 80, percent: 80,
message: "Parsing generated data...".to_string(), message: "Parsing generated data...".to_string(),
detail: None, 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(
"datagen-progress",
DataGenProgress {
gen_id: gen_id.clone(), gen_id: gen_id.clone(),
stage: "done".to_string(), stage: "done".to_string(),
percent: 100, percent: 100,
message: "Data generation complete".to_string(), message: "Data generation complete".to_string(),
detail: Some(format!("{} rows across {} tables", total_rows, tables.len())), 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,8 +1523,9 @@ 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!(
sqlx::query(
"SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \ "SELECT schemaname, relname, seq_scan, idx_scan, n_live_tup, \
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \ pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS table_size, \
pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \ pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS index_size \
@@ -1475,11 +1533,28 @@ pub async fn get_index_advisor_report(
ORDER BY seq_scan DESC \ ORDER BY seq_scan DESC \
LIMIT 50" LIMIT 50"
) )
.fetch_all(&pool) .fetch_all(&pool),
.await 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),
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"
);
}
}

View File

@@ -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,
@@ -55,7 +56,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 +130,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 +246,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

View File

@@ -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", &params.container_name, "-d",
"-p", &format!("{}:5432", host_port), "--name",
"-e", &format!("POSTGRES_PASSWORD={}", pg_password), &params.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, &params.container_name, 30).await?; wait_for_pg_ready(&docker_host, &params.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", &params.container_name, "exec",
"psql", "-U", "postgres", "-c", &params.container_name,
"psql",
"-U",
"postgres",
"-c",
&format!("CREATE DATABASE {}", escape_ident(&params.source_database)), &format!("CREATE DATABASE {}", escape_ident(&params.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, &params.source_connection_id)?; let source_config = load_connection_config(app, &params.source_connection_id)?;
let source_url = source_config.connection_url_for_db(&params.source_database); let source_url = source_config.connection_url_for_db(&params.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, &params.container_name, &params.source_database, &params.pg_version, &docker_host).await?; transfer_schema_only(
app,
clone_id,
&source_url,
&params.container_name,
&params.source_database,
&params.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, &params.container_name, &params.source_database, &params.pg_version, &docker_host).await?; app,
clone_id,
"transfer",
45,
"Performing full database clone...",
None,
);
transfer_full_clone(
app,
clone_id,
&source_url,
&params.container_name,
&params.source_database,
&params.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, &params.container_name, &params.source_database, &params.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,
&params.container_name,
&params.source_database,
&params.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, &params.container_name, &params.source_database, &params.pg_version, sample_rows, &docker_host).await?; transfer_sample_data_with(
app,
clone_id,
&source_url,
&params.container_name,
&params.source_database,
&params.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,61 @@ 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(app, clone_id, source_url, container_name, database, pg_version, docker_host, has_local).await
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); #[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 +807,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 +845,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 +855,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 +870,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 +944,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 +962,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 +973,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 +1050,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\""#);
}
}

View File

@@ -336,10 +336,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() {

View File

@@ -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;

View File

@@ -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) {
"OID" => {
match row.try_get::<Option<i32>, _>(index) {
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)), Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
Ok(None) => return Value::Null, Ok(None) => return Value::Null,
Err(_) => {} 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();

View File

@@ -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,7 +590,9 @@ 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
.entry(table_name.clone())
.or_insert_with(|| ErdTable {
schema: schema.clone(), schema: schema.clone(),
name: table_name, name: table_name,
columns: Vec::new(), columns: Vec::new(),

View File

@@ -46,8 +46,7 @@ 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 &params.tables.iter().map(|t| (t.schema.clone(), t.table.clone())).collect::<Vec<_>>() { if target_tables.iter().any(|(s, t)| s == &fk.schema && t == &fk.table) {
if &fk.schema == schema && &fk.table == table {
let parent = (fk.ref_schema.clone(), fk.ref_table.clone()); let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
if !target_tables.contains(&parent) { if !target_tables.contains(&parent) {
target_tables.push(parent); target_tables.push(parent);
@@ -55,16 +54,22 @@ 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 +112,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 +216,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(&params.connection_id).await?; let pool = state.get_pool(&params.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)

View File

@@ -57,8 +57,12 @@ 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(),
connections_path,
mcp_port,
shutdown_rx,
)
.await .await
{ {
log::error!("MCP server error: {}", e); log::error!("MCP server error: {}", e);

View File

@@ -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>,

View File

@@ -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)]

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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>>,
@@ -39,7 +37,6 @@ impl AppState {
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 +78,8 @@ 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);
cache.insert( cache.insert(
connection_id, connection_id,
SchemaCacheEntry { SchemaCacheEntry {

View File

@@ -34,7 +34,11 @@ 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;
} }
} }
@@ -73,3 +77,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);
}
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 }));

View File

@@ -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();

View File

@@ -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(() => {

View File

@@ -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)),

View File

@@ -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>

View File

@@ -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 */}
<div className="flex border-b border-border/50">
{SIDEBAR_TABS.map((tab) => (
<button <button
className={`flex-1 px-3 py-1.5 font-medium ${ key={tab.id}
view === "schema" className={`relative flex flex-1 items-center justify-center gap-1.5 px-2 py-2 text-[11px] font-medium tracking-wide transition-colors ${
? "bg-background text-foreground" view === tab.id
: "text-muted-foreground hover:text-foreground" ? "text-foreground tusk-sidebar-tab-active"
: "text-muted-foreground hover:text-foreground/70"
}`} }`}
onClick={() => setView("schema")} onClick={() => setView(tab.id)}
> >
Schema {tab.icon}
</button> <span className="hidden min-[220px]:inline">{tab.label}</span>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "history"
? "bg-background text-foreground"
: "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> </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>

View File

@@ -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"
}`}
/> />
)} )}
<span className="font-medium">
{activeConn ? activeConn.name : "No connection"} {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">

View File

@@ -23,39 +23,40 @@ 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) => {
const isActive = activeTabId === tab.id;
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
return (
<div <div
key={tab.id} key={tab.id}
className={`group flex h-8 cursor-pointer items-center gap-1.5 border-r px-3 text-xs ${ className={`group relative flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs transition-colors ${
activeTabId === tab.id isActive
? "bg-background text-foreground" ? "bg-background text-foreground tusk-tab-active"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:bg-accent/30 hover:text-foreground/80"
}`} }`}
onClick={() => setActiveTabId(tab.id)} onClick={() => setActiveTabId(tab.id)}
> >
{(() => { {tabColor && (
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
return 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; )}
})()} <span className="opacity-60">{iconMap[tab.type]}</span>
{iconMap[tab.type]} <span className="max-w-[150px] truncate font-medium">
<span className="max-w-[150px] truncate">
{tab.title} {tab.title}
{tab.database && ( {tab.database && (
<span className="ml-1 text-[10px] text-muted-foreground"> <span className="ml-1 text-[10px] font-normal text-muted-foreground/60">
{tab.database} {tab.database}
</span> </span>
)} )}
</span> </span>
<button <button
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100" className="ml-1 rounded-sm p-0.5 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-60 hover:!opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
closeTab(tab.id); closeTab(tab.id);
@@ -63,8 +64,14 @@ export function TabBar() {
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </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> </div>
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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(

View File

@@ -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()) {

View File

@@ -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}
> >
<span className="text-muted-foreground/50">
{expanded ? ( {expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <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 ? ( {expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> <ChevronDown className="h-3.5 w-3.5" />
) : ( ) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" /> <ChevronRight className="h-3.5 w-3.5" />
)} )}
</span>
{expanded ? ( {expanded ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" /> <FolderOpen className="h-3.5 w-3.5 text-amber-500/70" />
) : ( ) : (
<Database className="h-3.5 w-3.5 text-muted-foreground" /> <Database className="h-3.5 w-3.5 text-muted-foreground/50" />
)} )}
<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)}
> >
<span className="text-muted-foreground/50">
{expanded ? ( {expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <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>
); );
})} })}

View File

@@ -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`;

View File

@@ -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) => {

View File

@@ -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({

View File

@@ -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];

View File

@@ -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"

View File

@@ -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: {

View File

@@ -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
)} )}

View File

@@ -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"

View File

@@ -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,13 +336,16 @@ export function WorkspacePanel({
)} )}
AI Explain AI Explain
</Button> </Button>
{result && result.columns.length > 0 && ( {result && result.columns.length > 0 && (
<>
<div className="mx-1 h-3.5 w-px bg-border/40" />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
size="sm" size="xs"
variant="ghost" variant="ghost"
className="h-6 gap-1 text-xs" className="gap-1 text-[11px]"
> >
<Download className="h-3 w-3" /> <Download className="h-3 w-3" />
Export Export
@@ -352,14 +360,18 @@ export function WorkspacePanel({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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")}

View File

@@ -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);
useEffect(() => {
previewRef.current = previewMutation;
insertRef.current = insertMutation; insertRef.current = insertMutation;
});
const reset = useCallback(() => { const reset = useCallback(() => {
previewRef.current.reset(); previewRef.current.reset();

View File

@@ -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);
useEffect(() => {
mutationRef.current = mutation; mutationRef.current = mutation;
});
const reset = useCallback(() => { const reset = useCallback(() => {
mutationRef.current.reset(); mutationRef.current.reset();

View File

@@ -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);
useEffect(() => {
mutationRef.current = mutation; 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);
useEffect(() => {
mutationRef.current = mutation; mutationRef.current = mutation;
});
const reset = useCallback(() => { const reset = useCallback(() => {
mutationRef.current.reset(); mutationRef.current.reset();

View 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);
});
});
});

View File

@@ -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
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

15
vitest.config.ts Normal file
View 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"),
},
},
});