9 Commits

Author SHA1 Message Date
652937f7f5 feat: add visual filter builder with SQL fallback for table data
All checks were successful
CI / lint-and-build (push) Successful in 9m8s
Replace raw WHERE input with a dual-mode filter:
- Visual mode: column/operator/value dropdowns with AND/OR support
- SQL mode: raw WHERE clause input (auto-strips "where" prefix)
2026-04-08 15:30:29 +03:00
931e2b9408 ci: skip AppImage bundle, build only deb and rpm
All checks were successful
CI / lint-and-build (push) Successful in 9m8s
2026-04-08 12:24:23 +03:00
02ea9db25d ci: set APPIMAGE_EXTRACT_AND_RUN for linuxdeploy in container
Some checks failed
CI / lint-and-build (push) Failing after 9m42s
2026-04-08 12:02:46 +03:00
318210bdd8 ci: add xdg-utils for AppImage bundling
Some checks failed
CI / lint-and-build (push) Failing after 9m42s
2026-04-08 11:47:51 +03:00
11e35fcb5c chore: bump MSRV to 1.80.0 for LazyLock support
Some checks failed
CI / lint-and-build (push) Failing after 9m7s
2026-04-08 11:35:56 +03:00
50214fec0f perf: optimize backend — HTTP client, DB queries, error handling, and config cleanup
Some checks failed
CI / lint-and-build (push) Failing after 2m55s
2026-04-08 10:50:40 +03:00
28aa4ef8cc style: apply rustfmt to docker and snapshot commands
Some checks failed
CI / lint-and-build (push) Failing after 2m51s
2026-04-08 10:38:07 +03:00
ba9b58ff3a ci: replace actions/checkout with manual git clone for act runner
Some checks failed
CI / lint-and-build (push) Failing after 1m12s
The act-based Gitea runner executes JS actions inside the specified
container, but ubuntu:22.04 has no Node.js. Use git clone directly
to avoid the dependency.
2026-04-08 10:23:58 +03:00
33b07a31da ci: add workflow_dispatch trigger to CI workflow
Some checks failed
CI / lint-and-build (push) Failing after 2s
2026-04-08 10:09:30 +03:00
20 changed files with 720 additions and 67 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
jobs: jobs:
lint-and-build: lint-and-build:
@@ -13,16 +14,19 @@ jobs:
image: ubuntu:22.04 image: ubuntu:22.04
env: env:
DEBIAN_FRONTEND: noninteractive DEBIAN_FRONTEND: noninteractive
APPIMAGE_EXTRACT_AND_RUN: "1"
steps: steps:
- uses: actions/checkout@v4
- name: Install system dependencies - name: Install system dependencies
run: | run: |
apt-get update apt-get update
apt-get install -y \ apt-get install -y \
build-essential curl wget pkg-config \ build-essential curl wget pkg-config git ca-certificates \
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \ libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libssl-dev git ca-certificates libssl-dev xdg-utils
- name: Checkout
run: |
git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
- name: Install Node.js 22 - name: Install Node.js 22
run: | run: |
@@ -58,4 +62,4 @@ jobs:
- name: Build Tauri app - name: Build Tauri app
run: | run: |
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
npm run tauri build npm run tauri build -- --bundles deb,rpm

1
src-tauri/Cargo.lock generated
View File

@@ -4704,7 +4704,6 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",

View File

@@ -6,7 +6,7 @@ authors = ["you"]
license = "" license = ""
repository = "" repository = ""
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.80.0"
[lib] [lib]
name = "tusk_lib" name = "tusk_lib"
@@ -21,7 +21,7 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros", "process", "io-util"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["serde"] } uuid = { version = "1", features = ["serde"] }

View File

@@ -36,7 +36,7 @@ fn get_ai_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Config(e.to_string()))?;
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
Ok(dir.join("ai_settings.json")) Ok(dir.join("ai_settings.json"))
} }
@@ -1037,7 +1037,7 @@ fn simplify_default(raw: &str) -> String {
fn validate_select_statement(sql: &str) -> TuskResult<()> { fn validate_select_statement(sql: &str) -> TuskResult<()> {
let sql_upper = sql.trim().to_uppercase(); let sql_upper = sql.trim().to_uppercase();
if !sql_upper.starts_with("SELECT") { if !sql_upper.starts_with("SELECT") {
return Err(TuskError::Custom( return Err(TuskError::Validation(
"Validation query must be a SELECT statement".to_string(), "Validation query must be a SELECT statement".to_string(),
)); ));
} }
@@ -1047,7 +1047,7 @@ fn validate_select_statement(sql: &str) -> TuskResult<()> {
fn validate_index_ddl(ddl: &str) -> TuskResult<()> { fn validate_index_ddl(ddl: &str) -> TuskResult<()> {
let ddl_upper = ddl.trim().to_uppercase(); let ddl_upper = ddl.trim().to_uppercase();
if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") { if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") {
return Err(TuskError::Custom( return Err(TuskError::Validation(
"Only CREATE INDEX and DROP INDEX statements are allowed".to_string(), "Only CREATE INDEX and DROP INDEX statements are allowed".to_string(),
)); ));
} }

View File

@@ -18,7 +18,7 @@ pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::Pat
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Config(e.to_string()))?;
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
Ok(dir.join("connections.json")) Ok(dir.join("connections.json"))
} }

View File

@@ -29,6 +29,7 @@ pub async fn get_table_data(
let mut where_clause = String::new(); let mut where_clause = String::new();
if let Some(ref f) = filter { if let Some(ref f) = filter {
if !f.trim().is_empty() { if !f.trim().is_empty() {
validate_filter(f)?;
where_clause = format!(" WHERE {}", f); where_clause = format!(" WHERE {}", f);
} }
} }
@@ -285,6 +286,75 @@ pub async fn delete_rows(
Ok(total_affected) Ok(total_affected)
} }
/// Rejects filter strings that contain SQL statements capable of mutating data.
/// This blocks writable CTEs and other injection attempts that could bypass
/// SET TRANSACTION READ ONLY (which PostgreSQL does not enforce inside CTEs
/// in all versions).
fn validate_filter(filter: &str) -> TuskResult<()> {
let upper = filter.to_ascii_uppercase();
// Remove string literals to avoid false positives on keywords inside quoted values
let sanitized = remove_string_literals(&upper);
const FORBIDDEN: &[&str] = &[
"INSERT ",
"UPDATE ",
"DELETE ",
"DROP ",
"ALTER ",
"TRUNCATE ",
"CREATE ",
"GRANT ",
"REVOKE ",
"COPY ",
"EXECUTE ",
"CALL ",
];
for kw in FORBIDDEN {
if sanitized.contains(kw) {
return Err(TuskError::Validation(format!(
"Filter contains forbidden SQL keyword: {}",
kw.trim()
)));
}
}
if sanitized.contains("INTO ") && sanitized.contains("SELECT ") {
return Err(TuskError::Validation(
"Filter contains forbidden SELECT INTO clause".into(),
));
}
Ok(())
}
/// Replaces the contents of single-quoted string literals with spaces so that
/// keyword detection does not trigger on values like `status = 'DELETE_PENDING'`.
fn remove_string_literals(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut in_quote = false;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\'' {
if in_quote {
// Check for escaped quote ('')
if chars.peek() == Some(&'\'') {
chars.next();
result.push(' ');
continue;
}
in_quote = false;
result.push('\'');
} else {
in_quote = true;
result.push('\'');
}
} else if in_quote {
result.push(' ');
} else {
result.push(ch);
}
}
result
}
pub(crate) fn bind_json_value<'q>( pub(crate) fn bind_json_value<'q>(
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>, query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
value: &'q Value, value: &'q Value,

View File

@@ -739,7 +739,17 @@ 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;
transfer_schema_only_with(app, clone_id, source_url, container_name, database, pg_version, docker_host, has_local).await transfer_schema_only_with(
app,
clone_id,
source_url,
container_name,
database,
pg_version,
docker_host,
has_local,
)
.await
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]

View File

@@ -13,7 +13,7 @@ pub async fn export_csv(
let mut wtr = csv::Writer::from_writer(file); let mut wtr = csv::Writer::from_writer(file);
wtr.write_record(&columns) wtr.write_record(&columns)
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Export(e.to_string()))?;
for row in &rows { for row in &rows {
let record: Vec<String> = row let record: Vec<String> = row
@@ -27,10 +27,10 @@ pub async fn export_csv(
}) })
.collect(); .collect();
wtr.write_record(&record) wtr.write_record(&record)
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Export(e.to_string()))?;
} }
wtr.flush().map_err(|e| TuskError::Custom(e.to_string()))?; wtr.flush().map_err(|e| TuskError::Export(e.to_string()))?;
Ok(()) Ok(())
} }

View File

@@ -7,7 +7,7 @@ fn get_history_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Config(e.to_string()))?;
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
Ok(dir.join("query_history.json")) Ok(dir.join("query_history.json"))
} }

View File

@@ -110,11 +110,8 @@ pub async fn drop_database(
.ok_or(TuskError::NotConnected(connection_id))?; .ok_or(TuskError::NotConnected(connection_id))?;
// Terminate active connections to the target database // Terminate active connections to the target database
let terminate_sql = format!( sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1::name AND pid <> pg_backend_pid()")
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()", .bind(&name)
name.replace('\'', "''")
);
sqlx::query(&terminate_sql)
.execute(pool) .execute(pool)
.await .await
.map_err(TuskError::Database)?; .map_err(TuskError::Database)?;

View File

@@ -7,7 +7,7 @@ fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Config(e.to_string()))?;
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
Ok(dir.join("saved_queries.json")) Ok(dir.join("saved_queries.json"))
} }

View File

@@ -10,7 +10,7 @@ fn get_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?; .map_err(|e| TuskError::Config(e.to_string()))?;
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
Ok(dir.join("app_settings.json")) Ok(dir.join("app_settings.json"))
} }
@@ -61,7 +61,7 @@ pub async fn save_app_settings(
let connections_path = app let connections_path = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))? .map_err(|e| TuskError::Config(e.to_string()))?
.join("connections.json"); .join("connections.json");
let mcp_state = state.inner().clone(); let mcp_state = state.inner().clone();

View File

@@ -46,7 +46,10 @@ pub async fn create_snapshot(
if params.include_dependencies { if params.include_dependencies {
for fk in &fk_rows { for fk in &fk_rows {
if target_tables.iter().any(|(s, t)| s == &fk.schema && t == &fk.table) { if target_tables
.iter()
.any(|(s, t)| s == &fk.schema && t == &fk.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);
@@ -322,7 +325,7 @@ pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>>
let dir = app let dir = app
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))? .map_err(|e| TuskError::Config(e.to_string()))?
.join("snapshots"); .join("snapshots");
if !dir.exists() { if !dir.exists() {

View File

@@ -26,6 +26,15 @@ pub enum TuskError {
#[error("Docker error: {0}")] #[error("Docker error: {0}")]
Docker(String), Docker(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Export error: {0}")]
Export(String),
#[error("{0}")] #[error("{0}")]
Custom(String), Custom(String),
} }

View File

@@ -31,6 +31,7 @@ pub struct AppState {
} }
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
impl AppState { impl AppState {
pub fn new() -> Self { pub fn new() -> Self {
@@ -80,6 +81,16 @@ impl AppState {
let mut cache = self.schema_cache.write().await; let mut cache = self.schema_cache.write().await;
// Evict stale entries to prevent unbounded memory growth // Evict stale entries to prevent unbounded memory growth
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL); cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
// If still at capacity, remove the oldest entry
if cache.len() >= SCHEMA_CACHE_MAX_SIZE {
if let Some(oldest_key) = cache
.iter()
.min_by_key(|(_, e)| e.cached_at)
.map(|(k, _)| k.clone())
{
cache.remove(&oldest_key);
}
}
cache.insert( cache.insert(
connection_id, connection_id,
SchemaCacheEntry { SchemaCacheEntry {

View File

@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet, VecDeque};
pub fn escape_ident(name: &str) -> String { pub fn escape_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\"")) format!("\"{}\"", name.replace('"', "\"\""))
@@ -44,27 +44,33 @@ pub fn topological_sort_tables(
} }
// Kahn's algorithm // Kahn's algorithm
let mut queue: Vec<(String, String)> = in_degree let mut initial: Vec<(String, String)> = in_degree
.iter() .iter()
.filter(|(_, &deg)| deg == 0) .filter(|(_, &deg)| deg == 0)
.map(|(k, _)| k.clone()) .map(|(k, _)| k.clone())
.collect(); .collect();
queue.sort(); // deterministic order initial.sort(); // deterministic order
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
let mut result = Vec::new(); let mut result = Vec::new();
while let Some(node) = queue.pop() { while let Some(node) = queue.pop_front() {
result.push(node.clone()); result.push(node.clone());
if let Some(neighbors) = graph.get(&node) { if let Some(neighbors) = graph.get(&node) {
for neighbor in neighbors { let mut new_ready: Vec<(String, String)> = neighbors
if let Some(deg) = in_degree.get_mut(neighbor) { .iter()
.filter(|neighbor| {
if let Some(deg) = in_degree.get_mut(*neighbor) {
*deg -= 1; *deg -= 1;
if *deg == 0 { *deg == 0
queue.push(neighbor.clone()); } else {
queue.sort(); false
}
}
} }
})
.cloned()
.collect();
new_ready.sort();
queue.extend(new_ready);
} }
} }

View File

@@ -22,7 +22,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"
} }
}, },
"bundle": { "bundle": {

View File

@@ -0,0 +1,549 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Plus, X, Check, ChevronsUpDown, Code, SlidersHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ColumnInfo } from "@/types";
// --- Types ---
type Conjunction = "AND" | "OR";
interface FilterCondition {
id: string;
column: string;
operator: string;
value: string;
valueTo: string;
}
interface FilterBuilderProps {
columns: ColumnInfo[];
onFilterChange: (whereClause: string | undefined) => void;
children?: React.ReactNode;
}
// --- Operator mapping by PG type ---
type TypeCategory =
| "numeric"
| "text"
| "boolean"
| "datetime"
| "uuid"
| "json"
| "default";
const TYPE_CATEGORY_MAP: Record<string, TypeCategory> = {
int2: "numeric",
int4: "numeric",
int8: "numeric",
float4: "numeric",
float8: "numeric",
numeric: "numeric",
decimal: "numeric",
money: "numeric",
serial: "numeric",
bigserial: "numeric",
smallserial: "numeric",
varchar: "text",
text: "text",
char: "text",
bpchar: "text",
name: "text",
citext: "text",
bool: "boolean",
boolean: "boolean",
timestamp: "datetime",
timestamptz: "datetime",
date: "datetime",
time: "datetime",
timetz: "datetime",
interval: "datetime",
uuid: "uuid",
json: "json",
jsonb: "json",
};
const OPERATORS_BY_CATEGORY: Record<TypeCategory, string[]> = {
numeric: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "IN", "BETWEEN"],
text: ["=", "!=", "LIKE", "ILIKE", "IS NULL", "IS NOT NULL", "IN", "~"],
boolean: ["=", "IS NULL", "IS NOT NULL"],
datetime: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "BETWEEN"],
uuid: ["=", "!=", "IS NULL", "IS NOT NULL", "IN"],
json: ["IS NULL", "IS NOT NULL"],
default: ["=", "!=", "IS NULL", "IS NOT NULL"],
};
// --- Helpers ---
function getTypeCategory(dataType: string): TypeCategory {
return TYPE_CATEGORY_MAP[dataType] ?? "default";
}
function getOperatorsForColumn(dataType: string): string[] {
return OPERATORS_BY_CATEGORY[getTypeCategory(dataType)];
}
function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}
function escapeLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function serializeConditions(
conditions: FilterCondition[],
conjunction: Conjunction,
columns: ColumnInfo[],
): string | undefined {
const parts: string[] = [];
for (const c of conditions) {
if (!c.column) continue;
const col = quoteIdentifier(c.column);
const colInfo = columns.find((ci) => ci.name === c.column);
const isBool = colInfo && getTypeCategory(colInfo.data_type) === "boolean";
if (c.operator === "IS NULL") {
parts.push(`${col} IS NULL`);
} else if (c.operator === "IS NOT NULL") {
parts.push(`${col} IS NOT NULL`);
} else if (c.operator === "IN") {
const items = c.value
.split(",")
.map((v) => v.trim())
.filter(Boolean)
.map((v) => escapeLiteral(v));
if (items.length > 0) {
parts.push(`${col} IN (${items.join(", ")})`);
}
} else if (c.operator === "BETWEEN") {
if (c.value && c.valueTo) {
parts.push(
`${col} BETWEEN ${escapeLiteral(c.value)} AND ${escapeLiteral(c.valueTo)}`,
);
}
} else if (isBool) {
if (c.value === "true" || c.value === "false") {
parts.push(`${col} ${c.operator} ${c.value}`);
}
} else {
if (c.value !== "") {
parts.push(`${col} ${c.operator} ${escapeLiteral(c.value)}`);
}
}
}
if (parts.length === 0) return undefined;
return parts.join(` ${conjunction} `);
}
function createCondition(): FilterCondition {
return {
id: crypto.randomUUID(),
column: "",
operator: "=",
value: "",
valueTo: "",
};
}
// --- Sub-components ---
function ColumnCombobox({
columns,
value,
onSelect,
}: {
columns: ColumnInfo[];
value: string;
onSelect: (column: string) => void;
}) {
const [open, setOpen] = useState(false);
const selected = columns.find((c) => c.name === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-6 w-[130px] justify-between px-2 text-xs font-normal"
>
<span className="truncate">
{selected ? selected.name : "Column..."}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search column..." className="text-xs" />
<CommandList>
<CommandEmpty>No column found.</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={(v) => {
onSelect(v);
setOpen(false);
}}
>
<Check
className={cn(
"mr-1 h-3 w-3",
value === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{col.name}</span>
<span className="ml-auto text-[10px] text-muted-foreground">
{col.data_type}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function FilterConditionRow({
condition,
columns,
onChange,
onRemove,
}: {
condition: FilterCondition;
columns: ColumnInfo[];
onChange: (updated: FilterCondition) => void;
onRemove: () => void;
}) {
const colInfo = columns.find((c) => c.name === condition.column);
const dataType = colInfo?.data_type ?? "";
const operators = getOperatorsForColumn(dataType);
const category = getTypeCategory(dataType);
const needsNoValue =
condition.operator === "IS NULL" || condition.operator === "IS NOT NULL";
const isBetween = condition.operator === "BETWEEN";
const isBoolEq = category === "boolean" && condition.operator === "=";
const handleColumnChange = (columnName: string) => {
const newColInfo = columns.find((c) => c.name === columnName);
const newType = newColInfo?.data_type ?? "";
const newOps = getOperatorsForColumn(newType);
const newOperator = newOps.includes(condition.operator)
? condition.operator
: newOps[0];
onChange({
...condition,
column: columnName,
operator: newOperator,
value: "",
valueTo: "",
});
};
return (
<div className="flex items-center gap-1">
<ColumnCombobox
columns={columns}
value={condition.column}
onSelect={handleColumnChange}
/>
<Select
value={condition.operator}
onValueChange={(op) =>
onChange({ ...condition, operator: op, value: "", valueTo: "" })
}
>
<SelectTrigger className="h-6 w-[100px] px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op} className="text-xs">
{op}
</SelectItem>
))}
</SelectContent>
</Select>
{!needsNoValue && (
isBoolEq ? (
<Select
value={condition.value}
onValueChange={(v) => onChange({ ...condition, value: v })}
>
<SelectTrigger className="h-6 w-[80px] px-2 text-xs">
<SelectValue placeholder="value" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true" className="text-xs">
true
</SelectItem>
<SelectItem value="false" className="text-xs">
false
</SelectItem>
</SelectContent>
</Select>
) : isBetween ? (
<>
<Input
className="h-6 w-[80px] text-xs"
placeholder="from"
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
/>
<span className="text-[10px] text-muted-foreground"></span>
<Input
className="h-6 w-[80px] text-xs"
placeholder="to"
value={condition.valueTo}
onChange={(e) =>
onChange({ ...condition, valueTo: e.target.value })
}
/>
</>
) : (
<Input
className="h-6 w-[120px] text-xs"
placeholder={
condition.operator === "IN" ? "val1, val2, ..." : "value"
}
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.form?.requestSubmit();
}
}}
/>
)
)}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
// --- Main component ---
type FilterMode = "visual" | "sql";
export function FilterBuilder({ columns, onFilterChange, children }: FilterBuilderProps) {
const [mode, setMode] = useState<FilterMode>("visual");
const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [conjunction, setConjunction] = useState<Conjunction>("AND");
const [rawFilter, setRawFilter] = useState("");
const handleAdd = useCallback(() => {
setConditions((prev) => [...prev, createCondition()]);
}, []);
const handleRemove = useCallback((id: string) => {
setConditions((prev) => prev.filter((c) => c.id !== id));
}, []);
const handleChange = useCallback((updated: FilterCondition) => {
setConditions((prev) =>
prev.map((c) => (c.id === updated.id ? updated : c)),
);
}, []);
const handleApplyVisual = useCallback(() => {
const clause = serializeConditions(conditions, conjunction, columns);
onFilterChange(clause);
}, [conditions, conjunction, columns, onFilterChange]);
const handleApplyRaw = useCallback(() => {
let clause = rawFilter.trim();
if (clause.toLowerCase().startsWith("where ")) {
clause = clause.slice(6).trim();
}
onFilterChange(clause || undefined);
}, [rawFilter, onFilterChange]);
const handleClear = useCallback(() => {
setConditions([]);
setRawFilter("");
onFilterChange(undefined);
}, [onFilterChange]);
const hasActiveFilter =
mode === "visual" ? conditions.length > 0 : rawFilter.trim() !== "";
return (
<>
{/* Toolbar row */}
<div className="flex items-center gap-2 border-b px-2 py-1">
{/* Mode toggle */}
<div className="flex items-center rounded-md border text-xs">
<button
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
mode === "visual"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setMode("visual")}
title="Visual filter builder"
>
<SlidersHorizontal className="h-3 w-3" />
Filter
</button>
<button
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
mode === "sql"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setMode("sql")}
title="Raw SQL WHERE clause"
>
<Code className="h-3 w-3" />
SQL
</button>
</div>
{/* Visual mode controls */}
{mode === "visual" && (
<>
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 text-xs"
onClick={handleAdd}
>
<Plus className="h-3 w-3" />
{conditions.length === 0 ? "Add filter" : "Add"}
</Button>
{conditions.length > 0 && (
<>
{conditions.length >= 2 && (
<div className="flex items-center rounded-md border text-[10px]">
<button
className={cn(
"px-1.5 py-0.5 font-medium",
conjunction === "AND"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setConjunction("AND")}
>
AND
</button>
<button
className={cn(
"px-1.5 py-0.5 font-medium",
conjunction === "OR"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setConjunction("OR")}
>
OR
</button>
</div>
)}
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={handleApplyVisual}
>
Apply
</Button>
</>
)}
</>
)}
{/* SQL mode controls */}
{mode === "sql" && (
<>
<Input
placeholder="e.g. id > 10 AND status = 'active'"
className="h-6 max-w-md flex-1 text-xs"
value={rawFilter}
onChange={(e) => setRawFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleApplyRaw()}
/>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={handleApplyRaw}
>
Apply
</Button>
</>
)}
{hasActiveFilter && (
<Button
size="sm"
variant="ghost"
className="h-6 text-xs text-muted-foreground"
onClick={handleClear}
>
Clear
</Button>
)}
{children}
</div>
{/* Visual mode: condition rows below the toolbar */}
{mode === "visual" && conditions.length > 0 && (
<div className="flex flex-col gap-1 border-b bg-muted/30 px-2 py-1.5">
{conditions.map((c) => (
<FilterConditionRow
key={c.id}
condition={c}
columns={columns}
onChange={handleChange}
onRemove={() => handleRemove(c.id)}
/>
))}
</div>
)}
</>
);
}

View File

@@ -4,13 +4,13 @@ import { ResultsTable } from "@/components/results/ResultsTable";
import { ResultsJsonView } from "@/components/results/ResultsJsonView"; import { ResultsJsonView } from "@/components/results/ResultsJsonView";
import { PaginationControls } from "./PaginationControls"; import { PaginationControls } from "./PaginationControls";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateRow as updateRowApi } from "@/lib/tauri"; import { updateRow as updateRowApi } from "@/lib/tauri";
import { getTableColumns } from "@/lib/tauri"; import { getTableColumns } from "@/lib/tauri";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react"; import { Save, RotateCcw, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
import { FilterBuilder } from "./FilterBuilder";
import { InsertRowDialog } from "./InsertRowDialog"; import { InsertRowDialog } from "./InsertRowDialog";
import { import {
DropdownMenu, DropdownMenu,
@@ -35,7 +35,6 @@ export function TableDataView({ connectionId, schema, table }: Props) {
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
const [sortColumn, setSortColumn] = useState<string | undefined>(); const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortDirection, setSortDirection] = useState<string | undefined>(); const [sortDirection, setSortDirection] = useState<string | undefined>();
const [filter, setFilter] = useState("");
const [appliedFilter, setAppliedFilter] = useState<string | undefined>(); const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
const [pendingChanges, setPendingChanges] = useState< const [pendingChanges, setPendingChanges] = useState<
Map<string, { rowIndex: number; colIndex: number; value: string | null }> Map<string, { rowIndex: number; colIndex: number; value: string | null }>
@@ -184,14 +183,24 @@ export function TableDataView({ connectionId, schema, table }: Props) {
[] []
); );
const handleApplyFilter = () => {
setAppliedFilter(filter || undefined);
setPage(1);
};
return ( return (
<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"> <FilterBuilder
columns={columnsInfo ?? data?.columns.map((name, i) => ({
name,
data_type: data.types?.[i] ?? "text",
is_nullable: true,
column_default: null,
ordinal_position: i,
character_maximum_length: null,
is_primary_key: false,
comment: null,
})) ?? []}
onFilterChange={(clause) => {
setAppliedFilter(clause);
setPage(1);
}}
>
{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="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">
<Lock className="h-3 w-3" /> <Lock className="h-3 w-3" />
@@ -206,22 +215,6 @@ export function TableDataView({ connectionId, schema, table }: Props) {
No PK using ctid No PK using ctid
</span> </span>
)} )}
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="WHERE clause (e.g. id > 10)"
className="h-6 flex-1 text-xs"
value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()}
/>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={handleApplyFilter}
>
Apply
</Button>
{data && data.columns.length > 0 && ( {data && data.columns.length > 0 && (
<> <>
<DropdownMenu> <DropdownMenu>
@@ -310,7 +303,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
</Button> </Button>
</> </>
)} )}
</div> </FilterBuilder>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{isLoading && !data ? ( {isLoading && !data ? (

View File

@@ -103,7 +103,9 @@ export function useReconnect() {
setPgVersion(version); setPgVersion(version);
setDbFlavor(id, flavor); setDbFlavor(id, flavor);
setCurrentDatabase(database); setCurrentDatabase(database);
queryClient.invalidateQueries(); queryClient.invalidateQueries({ queryKey: ["databases"] });
queryClient.invalidateQueries({ queryKey: ["schemas"] });
queryClient.invalidateQueries({ queryKey: ["completion-schema"] });
}, },
}); });
} }