v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)

* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish

- Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings
- Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists
- SSH/SSL runtime refinements and connection testing improvements
- Data grid: DataTypeIcon component, FK popover, table tree polish
- Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE

* chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90)

* v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling

- Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json
- Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64
  + x64), Windows, and Linux installers into a draft GitHub Release
- README: installer download table (unsigned note, per-platform files),
  "how releases are made" section
- CellEditor: constraint-aware commit — empty input on nullable columns
  becomes NULL, NOT NULL text-like types fall back to empty string, all
  other types blocked with an inline error bubble; replace "Set NULL"
  checkbox with a NULL row in the FK dropdown / empty enum option
- VirtualDataGrid: pending-edit dot → animated pending outline (ring) on
  staged cells; matching test updates
- docs-coverage test: align with rewritten README comparison table
This commit is contained in:
2026-08-04 17:20:14 +08:00
committed by GitHub
parent a9dbf60ed5
commit 002d8345ae
66 changed files with 3591 additions and 1602 deletions
+333 -374
View File
@@ -3,6 +3,37 @@ use tauri::{AppHandle, Emitter, State};
use crate::models::backup::*;
// ---------------------------------------------------------------------------
// Connection params (decoupled from store/keychain so logic is headless-testable)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct PgConnParams {
pub host: String,
pub port: i64,
pub username: String,
pub database: String,
pub password: String,
}
impl PgConnParams {
pub fn new(
host: String,
port: i64,
username: String,
database: String,
password: String,
) -> Self {
Self {
host,
port,
username,
database,
password,
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -16,6 +47,10 @@ fn get_version(tool: &str) -> Option<String> {
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
@@ -31,9 +66,237 @@ pub fn detect_pg_tools() -> PgToolStatus {
}
// ---------------------------------------------------------------------------
// pg_dump
// Core logic (headless-testable — no Tauri, no store, no keychain)
// ---------------------------------------------------------------------------
/// Builds the base connection args shared by pg_dump and pg_restore.
fn base_conn_args(conn: &PgConnParams) -> Vec<String> {
vec![
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
]
}
/// Builds pg_dump args (excluding the --file flag, which is added by the caller).
fn build_dump_args(conn: &PgConnParams, options: &BackupOptions) -> Vec<String> {
let mut args = base_conn_args(conn);
match options.format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is the default — no format flag needed
}
if options.no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = options.schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = options.tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
args
}
/// Builds pg_restore args (file path is passed positionally by the caller).
fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<String> {
let mut args = base_conn_args(conn);
match options.format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {}
}
if options.clean {
args.push("--clean".into());
args.push("--if-exists".into());
}
if let Some(ref schema) = options.schema {
args.push(format!("--schema={schema}"));
}
args
}
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump")
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
}
}
/// Runs `pg_restore` against `conn`, reading from `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
///
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
/// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain.
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new("psql")
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
format!("--port={}", conn.port),
format!("--username={}", conn.username),
format!("--dbname={}", conn.database),
format!("--file={}", options.file_path),
])
.output();
return match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
};
}
let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone());
let result = Command::new("pg_restore")
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
Err(e) => Err(e.to_string()),
}
}
/// Runs a DB-to-DB sync: `pg_dump` on `source` piped into `pg_restore` on `target`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_db_sync(
source: &PgConnParams,
target: &PgConnParams,
schema: Option<&str>,
tables: Option<&[String]>,
) -> Result<(), String> {
// --- Build pg_dump args ---
let mut dump_args = base_conn_args(source);
dump_args.push("--format=c".into()); // binary custom format for reliable piping
dump_args.push("--no-owner".into());
if let Some(schema) = schema {
dump_args.push(format!("--schema={schema}"));
}
if let Some(tables) = tables {
for t in tables {
dump_args.push(format!("--table={t}"));
}
}
// --- Build pg_restore args ---
// --clean --if-exists makes sync work into a non-empty target (the UI
// already requires a destructive-overwrite confirmation).
let mut restore_args = base_conn_args(target);
restore_args.push("--no-owner".into());
restore_args.push("--clean".into());
restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = Command::new("pg_dump")
.env("PGPASSWORD", &source.password)
.args(&dump_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start pg_dump: {e}"))?;
let dump_stdout = dump_child.stdout.take().unwrap();
let dump_stderr_reader = dump_child.stderr.take().unwrap();
// Read pg_dump stderr in a separate thread so the pipe doesn't block
let dump_stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = dump_stderr_reader
.take(10 * 1024 * 1024) // cap at 10 MiB
.read_to_string(&mut buf);
buf
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
.env("PGPASSWORD", &target.password)
.args(&restore_args)
.stdin(dump_stdout)
.output();
// Wait for pg_dump to finish
let dump_status = dump_child.wait();
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
// --- Check results ---
let dump_failed = match dump_status {
Ok(status) => !status.success(),
Err(_) => true,
};
if dump_failed {
return Err(format!("pg_dump failed: {}", sanitize_error(&dump_stderr)));
}
match restore_result {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(format!(
"pg_restore failed: {}",
sanitize_error(&String::from_utf8_lossy(&output.stderr))
)),
Err(e) => Err(format!("pg_restore failed: {e}")),
}
}
// ---------------------------------------------------------------------------
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
// ---------------------------------------------------------------------------
fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) {
let event = match result {
Ok(()) => BackupProgressEvent {
job_id: job_id.to_string(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
Err(e) => BackupProgressEvent {
job_id: job_id.to_string(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e),
},
};
let _ = app_handle.emit("backup-progress", event);
}
#[tauri::command]
pub async fn pg_dump(
connection_id: String,
@@ -45,10 +308,7 @@ pub async fn pg_dump(
// Get connection from store (scope the std::sync::Mutex lock guard)
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
@@ -57,112 +317,30 @@ pub async fn pg_dump(
};
// Get password from keychain
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields before moving into spawn_blocking
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let no_owner = options.no_owner;
let schema = options.schema.clone();
let tables = options.tables.clone();
let params = PgConnParams::new(
conn.host.clone(),
conn.port.unwrap_or(5432),
conn.username.unwrap_or_else(|| "postgres".into()),
conn.database.unwrap_or_else(|| "postgres".into()),
password,
);
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is the default — no format flag needed
}
if no_owner {
args.push("--no-owner".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
args.push(format!("--table={t}"));
}
}
args.push(format!("--file={file_path}"));
let result = Command::new("pg_dump")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
let result = run_pg_dump(&params, &options);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// pg_restore
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn pg_restore(
connection_id: String,
@@ -173,10 +351,7 @@ pub async fn pg_restore(
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
@@ -184,105 +359,30 @@ pub async fn pg_restore(
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
let password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&connection_id,
)
.unwrap_or_default()
.unwrap_or_default();
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
let host = conn.host.clone();
let port = conn.port.unwrap_or(5432);
let username = conn.username.unwrap_or_else(|| "postgres".into());
let database = conn.database.unwrap_or_else(|| "postgres".into());
let file_path = options.file_path.clone();
let format = options.format.clone();
let clean = options.clean;
let schema = options.schema.clone();
let params = PgConnParams::new(
conn.host.clone(),
conn.port.unwrap_or(5432),
conn.username.unwrap_or_else(|| "postgres".into()),
conn.database.unwrap_or_else(|| "postgres".into()),
password,
);
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
let mut args: Vec<String> = vec![
format!("--host={host}"),
format!("--port={port}"),
format!("--username={username}"),
format!("--dbname={database}"),
];
match format.as_str() {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {}
}
if clean {
args.push("--clean".into());
args.push("--if-exists".into());
}
if let Some(ref schema) = schema {
args.push(format!("--schema={schema}"));
}
args.push(file_path.clone());
let result = Command::new("pg_restore")
.env("PGPASSWORD", &password)
.args(&args)
.output();
match result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(
crate::commands::test_connection::sanitize_error(&stderr),
),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(e.to_string()),
},
);
}
}
let result = run_pg_restore(&params, &options);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// db_sync (pg_dump | pg_restore via Unix pipe)
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn db_sync(
options: SyncOptions,
@@ -293,10 +393,7 @@ pub async fn db_sync(
// Get both connections from store
let (source_conn, target_conn) = {
let store = state
.db_store
.lock()
.map_err(|e| e.to_string())?;
let store = state.db_store.lock().map_err(|e| e.to_string())?;
let connections = store.get_connections().map_err(|e| e.to_string())?;
let src = connections
@@ -325,190 +422,52 @@ pub async fn db_sync(
};
// Get passwords
let src_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&source_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
let src_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password = crate::commands::keychain::get_connection_password_internal(
&app_handle,
&target_conn.id,
)
.unwrap_or_default()
.unwrap_or_default();
let tgt_password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
.unwrap_or_default()
.unwrap_or_default();
// Extract connection fields
let src_host = source_conn.host.clone();
let src_port = source_conn.port.unwrap_or(5432);
let src_username = source_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let src_database = source_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let source = PgConnParams::new(
source_conn.host.clone(),
source_conn.port.unwrap_or(5432),
source_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into()),
source_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into()),
src_password,
);
let tgt_host = target_conn.host.clone();
let tgt_port = target_conn.port.unwrap_or(5432);
let tgt_username = target_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into());
let tgt_database = target_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into());
let target = PgConnParams::new(
target_conn.host.clone(),
target_conn.port.unwrap_or(5432),
target_conn
.username
.clone()
.unwrap_or_else(|| "postgres".into()),
target_conn
.database
.clone()
.unwrap_or_else(|| "postgres".into()),
tgt_password,
);
let schema = options.schema.clone();
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
tokio::task::spawn_blocking(move || {
// --- Build pg_dump args ---
let mut dump_args: Vec<String> = vec![
format!("--host={src_host}"),
format!("--port={src_port}"),
format!("--username={src_username}"),
format!("--dbname={src_database}"),
"--format=c".into(), // binary custom format for reliable piping
"--no-owner".into(),
];
if let Some(ref schema) = schema {
dump_args.push(format!("--schema={schema}"));
}
if let Some(ref tables) = tables {
for t in tables {
dump_args.push(format!("--table={t}"));
}
}
// --- Build pg_restore args ---
let restore_args: Vec<String> = vec![
format!("--host={tgt_host}"),
format!("--port={tgt_port}"),
format!("--username={tgt_username}"),
format!("--dbname={tgt_database}"),
"--no-owner".into(),
];
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = match Command::new("pg_dump")
.env("PGPASSWORD", &src_password)
.args(&dump_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("Failed to start pg_dump: {e}")),
},
);
return;
}
};
let dump_stdout = dump_child.stdout.take().unwrap();
let dump_stderr_reader = dump_child.stderr.take().unwrap();
// Read pg_dump stderr in a separate thread so the pipe doesn't block
let dump_stderr_handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = String::new();
let _ = dump_stderr_reader
.take(10 * 1024 * 1024) // cap at 10 MiB
.read_to_string(&mut buf);
buf
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
.env("PGPASSWORD", &tgt_password)
.args(&restore_args)
.stdin(dump_stdout)
.output();
// Wait for pg_dump to finish
let dump_status = dump_child.wait();
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
// --- Check results ---
let dump_failed = match dump_status {
Ok(status) => !status.success(),
Err(_) => true,
};
if dump_failed {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_dump failed: {}",
crate::commands::test_connection::sanitize_error(&dump_stderr)
)),
},
);
return;
}
match restore_result {
Ok(output) if output.status.success() => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "completed".into(),
progress: Some(1.0),
output_line: None,
error: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!(
"pg_restore failed: {}",
crate::commands::test_connection::sanitize_error(&stderr)
)),
},
);
}
Err(e) => {
let _ = app_handle.emit(
"backup-progress",
BackupProgressEvent {
job_id: job_id_clone.clone(),
status: "failed".into(),
progress: None,
output_line: None,
error: Some(format!("pg_restore failed: {e}")),
},
);
}
}
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
@@ -574,4 +533,4 @@ pub(crate) fn build_args_for_test(
#[cfg(test)]
#[path = "backup.test.rs"]
mod tests;
mod tests;