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
+1 -1
View File
@@ -97,4 +97,4 @@ mod tests {
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
}
}
+43 -13
View File
@@ -91,19 +91,31 @@ mod tests {
assert_eq!(deserialized.port, Some(5432));
assert_eq!(deserialized.username, Some("admin".to_string()));
assert_eq!(deserialized.folder_id, Some("folder1".to_string()));
assert_eq!(deserialized.tag_ids, vec!["tag1".to_string(), "tag2".to_string()]);
assert_eq!(
deserialized.tag_ids,
vec!["tag1".to_string(), "tag2".to_string()]
);
assert_eq!(deserialized.password, Some("secret123".to_string()));
assert_eq!(deserialized.database, Some("mydb".to_string()));
assert_eq!(deserialized.ssh_host, Some("jumphost.example.com".to_string()));
assert_eq!(
deserialized.ssh_host,
Some("jumphost.example.com".to_string())
);
assert_eq!(deserialized.ssh_port, Some(2222));
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string()));
assert_eq!(
deserialized.ssh_private_key_path,
Some("/path/to/key".to_string())
);
assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string()));
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
assert_eq!(deserialized.ssl_cert_path, Some("/path/to/cert".to_string()));
assert_eq!(
deserialized.ssl_cert_path,
Some("/path/to/cert".to_string())
);
assert_eq!(deserialized.ssl_key_path, Some("/path/to/key".to_string()));
}
@@ -136,22 +148,40 @@ mod tests {
};
let json = serde_json::to_string(&conn).unwrap();
assert!(!json.contains("password"), "Connection JSON should not contain password field");
assert!(
!json.contains("password"),
"Connection JSON should not contain password field"
);
}
#[test]
fn connection_serializes_favorite_field() {
let conn = Connection {
id: "x".into(), name: "n".into(), db_type: "postgresql".into(),
host: "h".into(), port: Some(5432), username: None, database: None,
folder_id: None, keychain_ref: None, environment: None,
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
ssh_private_key_path: None, ssl_mode: None, ssl_ca_path: None,
ssl_cert_path: None, ssl_key_path: None, tag_ids: vec![],
id: "x".into(),
name: "n".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
database: None,
folder_id: None,
keychain_ref: None,
environment: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
favorite: true,
created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(),
created_at: "2024-01-01T00:00:00Z".into(),
updated_at: "2024-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(json.contains("\"favorite\":true"));
}
}
}
+24 -10
View File
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct FilterRule {
pub id: String,
pub column: String,
pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"
pub operator: String, // "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"
pub value: String,
}
@@ -14,7 +14,7 @@ pub struct FilterRule {
pub struct SortRule {
pub id: String,
pub column: String,
pub order: String, // "asc" | "desc"
pub order: String, // "asc" | "desc"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -347,11 +347,13 @@ mod tests {
fn change_drop_and_empty_roundtrip() {
let drop: Change = serde_json::from_value(serde_json::json!({
"type": "drop_table", "id": "d", "schema": "public", "table": "t"
})).unwrap();
}))
.unwrap();
assert_eq!(drop.id(), "d");
let empty: Change = serde_json::from_value(serde_json::json!({
"type": "empty_table", "id": "e", "schema": "public", "table": "t"
})).unwrap();
}))
.unwrap();
assert_eq!(empty.id(), "e");
}
@@ -376,9 +378,15 @@ mod tests {
#[test]
fn column_info_has_editability_fields() {
let c = ColumnInfo {
name: "id".into(), data_type: "integer".into(), is_nullable: false,
is_pk: true, is_fk: false, fk_ref: None, default_value: None,
editable: false, is_generated: false,
name: "id".into(),
data_type: "integer".into(),
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: None,
default_value: None,
editable: false,
is_generated: false,
};
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"editable\":false"));
@@ -513,7 +521,10 @@ mod tests {
let json = serde_json::to_string(&graph).unwrap();
assert!(json.contains("users"), "should contain table name");
assert!(json.contains("orders"), "should contain relationship source table");
assert!(
json.contains("orders"),
"should contain relationship source table"
);
assert!(json.contains("1:N"), "should contain cardinality");
assert!(json.contains("is_pk"), "should contain is_pk field");
assert!(json.contains("is_fk"), "should contain is_fk field");
@@ -552,7 +563,10 @@ mod tests {
fk_ref: None,
};
let json = serde_json::to_string(&col_none).unwrap();
assert!(json.contains("null"), "fk_ref=None should serialize as null");
assert!(
json.contains("null"),
"fk_ref=None should serialize as null"
);
// fk_ref = Some(...)
let col_some = GraphColumn {
@@ -569,4 +583,4 @@ mod tests {
assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column");
}
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ pub struct FolderInput {
pub name: String,
pub parent_id: Option<String>,
pub tag_ids: Option<Vec<String>>,
}
}
+3 -3
View File
@@ -3,15 +3,15 @@ pub mod connection;
pub mod db_viewer;
pub mod folder;
pub mod recent;
pub mod settings;
pub mod ssh;
pub mod tag;
pub mod settings;
pub use connection::{Connection, ConnectionInput};
pub use recent::RecentConnection;
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
pub use folder::{Folder, FolderInput};
pub use recent::RecentConnection;
pub use settings::Settings;
pub use ssh::SshConfig;
pub use tag::{Tag, TagInput};
pub use tag::{Tag, TagInput};
+1 -1
View File
@@ -4,4 +4,4 @@ use serde::{Deserialize, Serialize};
pub struct RecentConnection {
pub connection_id: String,
pub opened_at: String,
}
}
+1 -1
View File
@@ -19,4 +19,4 @@ pub struct Settings {
pub editor_word_wrap: String,
pub editor_minimap: bool,
pub editor_tab_size: i64,
}
}
+2 -7
View File
@@ -15,12 +15,7 @@ pub struct SshConfig {
impl SshConfig {
/// Create a new `SshConfig` with the required fields.
pub fn new(
host: String,
port: u16,
user: String,
auth_method: String,
) -> Self {
pub fn new(host: String, port: u16, user: String, auth_method: String) -> Self {
SshConfig {
host,
port,
@@ -41,4 +36,4 @@ impl SshConfig {
pub fn is_valid(&self) -> bool {
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
}
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ pub struct Tag {
pub struct TagInput {
pub name: String,
pub color: String,
}
}