diff --git a/src-tauri/src/bin/rssh/commands/group.rs b/src-tauri/src/bin/rssh/commands/group.rs index 0f2871d2..8e27a3c8 100644 --- a/src-tauri/src/bin/rssh/commands/group.rs +++ b/src-tauri/src/bin/rssh/commands/group.rs @@ -22,10 +22,20 @@ pub fn cmd_list_groups(conn: &CliCtx) -> AppResult<()> { return Ok(()); } - println!("{:<24} {:<9} ORDER", "NAME", "COLOR"); + println!("{:<24} {:<9} ORDER WAVES", "NAME", "COLOR"); println!("{}", "-".repeat(42)); for group in groups { - println!("{:<24} {:<9} {}", group.name, group.color, group.sort_order); + println!( + "{:<24} {:<9} {} {}", + group.name, + group.color, + group.sort_order, + if group.terminal_waves_enabled { + "on" + } else { + "off" + } + ); } Ok(()) } @@ -34,11 +44,13 @@ pub fn cmd_add_group(conn: &CliCtx) -> AppResult<()> { let name = prompt("Name: "); let color = prompt_default("Color (#RRGGBB)", "#4A6CF7"); let sort_order = prompt_default("Sort order", "0"); + let terminal_waves_enabled = confirm("Show group-color terminal waves", false); let group = Group { id: uuid::Uuid::new_v4().to_string(), name, color, sort_order: parse_sort_order(&sort_order)?, + terminal_waves_enabled, }; rssh_lib::db::group::insert(conn, &group)?; println!("Group '{}' created.", group.name); @@ -56,6 +68,10 @@ pub fn cmd_edit_group(conn: &CliCtx, name: &str) -> AppResult<()> { updated.color = prompt_default("Color (#RRGGBB)", ¤t.color); let sort_order = prompt_default("Sort order", ¤t.sort_order.to_string()); updated.sort_order = parse_sort_order(&sort_order)?; + updated.terminal_waves_enabled = confirm( + "Show group-color terminal waves", + current.terminal_waves_enabled, + ); rssh_lib::db::group::update(conn, &updated)?; println!("Group '{}' updated.", updated.name); diff --git a/src-tauri/src/db/group.rs b/src-tauri/src/db/group.rs index 07e21aec..ea81e7ab 100644 --- a/src-tauri/src/db/group.rs +++ b/src-tauri/src/db/group.rs @@ -73,7 +73,7 @@ fn reconcile_sync_filter_for_group_delete(conn: &rusqlite::Connection, id: &str) pub fn list(db: &Db) -> AppResult> { let conn = db.lock()?; let mut stmt = conn.prepare( - "SELECT id, name, color, sort_order FROM groups ORDER BY sort_order ASC, name ASC", + "SELECT id, name, color, sort_order, terminal_waves_enabled FROM groups ORDER BY sort_order ASC, name ASC", )?; let rows = stmt.query_map([], |row| { Ok(Group { @@ -81,6 +81,7 @@ pub fn list(db: &Db) -> AppResult> { name: row.get(1)?, color: row.get(2)?, sort_order: row.get(3)?, + terminal_waves_enabled: row.get(4)?, }) })?; Ok(rows.collect::, _>>()?) @@ -89,7 +90,7 @@ pub fn list(db: &Db) -> AppResult> { pub fn get(db: &Db, id: &str) -> AppResult { let conn = db.lock()?; conn.query_row( - "SELECT id, name, color, sort_order FROM groups WHERE id = ?1", + "SELECT id, name, color, sort_order, terminal_waves_enabled FROM groups WHERE id = ?1", params![id], |row| { Ok(Group { @@ -97,6 +98,7 @@ pub fn get(db: &Db, id: &str) -> AppResult { name: row.get(1)?, color: row.get(2)?, sort_order: row.get(3)?, + terminal_waves_enabled: row.get(4)?, }) }, ) @@ -107,9 +109,9 @@ pub fn insert_tx(conn: &rusqlite::Connection, g: &Group) -> AppResult<()> { validate_name(&g.name)?; let color = normalize_group_color(&g.color)?; conn.execute( - "INSERT INTO groups (id, name, color, sort_order) VALUES (?1, ?2, ?3, ?4) \ - ON CONFLICT(id) DO UPDATE SET name=excluded.name, color=excluded.color, sort_order=excluded.sort_order", - params![g.id, g.name, color, g.sort_order], + "INSERT INTO groups (id, name, color, sort_order, terminal_waves_enabled) VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(id) DO UPDATE SET name=excluded.name, color=excluded.color, sort_order=excluded.sort_order, terminal_waves_enabled=excluded.terminal_waves_enabled", + params![g.id, g.name, color, g.sort_order, g.terminal_waves_enabled], )?; Ok(()) } @@ -124,8 +126,8 @@ pub fn update(db: &Db, g: &Group) -> AppResult<()> { let color = normalize_group_color(&g.color)?; let conn = db.lock()?; conn.execute( - "UPDATE groups SET name=?1, color=?2, sort_order=?3 WHERE id=?4", - params![g.name, color, g.sort_order, g.id], + "UPDATE groups SET name=?1, color=?2, sort_order=?3, terminal_waves_enabled=?4 WHERE id=?5", + params![g.name, color, g.sort_order, g.terminal_waves_enabled, g.id], )?; Ok(()) } @@ -183,6 +185,7 @@ mod tests { name: name.into(), color: "#FF0000".into(), sort_order: 0, + terminal_waves_enabled: false, } } @@ -202,6 +205,24 @@ mod tests { } } + #[test] + fn group_waves_survive_insert_update_and_upsert() { + let db = Db::open_in_memory().unwrap(); + let mut group = mk_group("prod", "production"); + group.terminal_waves_enabled = true; + insert(&db, &group).unwrap(); + assert!(get(&db, "prod").unwrap().terminal_waves_enabled); + assert!(list(&db).unwrap()[0].terminal_waves_enabled); + + group.terminal_waves_enabled = false; + update(&db, &group).unwrap(); + assert!(!get(&db, "prod").unwrap().terminal_waves_enabled); + + group.terminal_waves_enabled = true; + insert(&db, &group).unwrap(); + assert!(get(&db, "prod").unwrap().terminal_waves_enabled); + } + #[test] fn insert_then_get_roundtrip() { let db = Db::open_in_memory().unwrap(); diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index d0bad7d3..c0cd54fa 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -2,7 +2,7 @@ use rusqlite::{params, Connection}; use crate::error::AppResult; -const SCHEMA_VERSION: u32 = 30; +const SCHEMA_VERSION: u32 = 32; fn column_exists(conn: &Connection, table: &str, col: &str) -> AppResult { let mut stmt = conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?; @@ -667,6 +667,31 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { )?; } + if version < 31 + && table_exists(conn, "groups")? + && !column_exists(conn, "groups", "terminal_background")? + { + conn.execute_batch("ALTER TABLE groups ADD COLUMN terminal_background TEXT DEFAULT NULL;")?; + } + + if version < 32 && table_exists(conn, "groups")? { + // Some development v31 databases already contain the boolean flag. + let needs_wave_flag = !column_exists(conn, "groups", "terminal_waves_enabled")?; + if needs_wave_flag { + conn.execute_batch( + "ALTER TABLE groups ADD COLUMN terminal_waves_enabled INTEGER NOT NULL DEFAULT 0;", + )?; + } + if column_exists(conn, "groups", "terminal_background")? { + if needs_wave_flag { + conn.execute_batch( + "UPDATE groups SET terminal_waves_enabled = (terminal_background IS NOT NULL);", + )?; + } + conn.execute_batch("ALTER TABLE groups DROP COLUMN terminal_background;")?; + } + } + if version < SCHEMA_VERSION { conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; } @@ -676,6 +701,141 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { #[cfg(test)] mod tests { + #[test] + fn migration_32_preserves_groups_and_defaults_group_waves_to_disabled() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE groups (id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL, sort_order INTEGER NOT NULL); + INSERT INTO groups VALUES ('prod', 'Production', '#FF0000', 1); + PRAGMA user_version = 30;").unwrap(); + super::migrate(&conn).unwrap(); + super::migrate(&conn).unwrap(); + let group: (String, bool) = conn + .query_row( + "SELECT name, terminal_waves_enabled FROM groups WHERE id = 'prod'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(group, ("Production".into(), false)); + assert!(conn + .execute("UPDATE groups SET terminal_waves_enabled = NULL", []) + .is_err()); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 32); + assert!(!super::column_exists(&conn, "groups", "terminal_background").unwrap()); + } + + #[test] + fn migration_32_converts_legacy_wave_colors_to_flags() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE groups ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL, + sort_order INTEGER NOT NULL, terminal_background TEXT DEFAULT NULL + ); + INSERT INTO groups VALUES + ('off', 'Disabled', '#112233', 1, NULL), + ('on', 'Enabled', '#445566', 2, '#AABBCC'); + CREATE TABLE profiles (id TEXT PRIMARY KEY, group_id TEXT REFERENCES groups(id)); + INSERT INTO profiles VALUES ('ssh', 'on'); + PRAGMA user_version = 31;", + ) + .unwrap(); + + migrate(&conn).unwrap(); + migrate(&conn).unwrap(); + + let groups: Vec<(String, String, String, i32, bool)> = conn + .prepare("SELECT id, name, color, sort_order, terminal_waves_enabled FROM groups ORDER BY id") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + groups, + vec![ + ("off".into(), "Disabled".into(), "#112233".into(), 1, false), + ("on".into(), "Enabled".into(), "#445566".into(), 2, true), + ] + ); + let profile_group: String = conn + .query_row( + "SELECT group_id FROM profiles WHERE id = 'ssh'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(profile_group, "on"); + assert!(!column_exists(&conn, "groups", "terminal_background").unwrap()); + assert!(conn + .execute("UPDATE groups SET terminal_waves_enabled = NULL", []) + .is_err()); + conn.execute( + "INSERT INTO groups (id, name, color, sort_order) VALUES ('new', 'New', '#778899', 3)", + [], + ) + .unwrap(); + let enabled: bool = conn + .query_row( + "SELECT terminal_waves_enabled FROM groups WHERE id = 'new'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(!enabled); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 32); + } + + #[test] + fn migration_32_preserves_existing_wave_flags() { + // Also cover a database where both columns are present: the flag wins. + for legacy_column in [false, true] { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE groups ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL, + sort_order INTEGER NOT NULL, + terminal_waves_enabled INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO groups VALUES + ('off', 'Disabled', '#112233', 1, 0), + ('on', 'Enabled', '#445566', 2, 1); + PRAGMA user_version = 31;", + ) + .unwrap(); + if legacy_column { + conn.execute_batch( + "ALTER TABLE groups ADD COLUMN terminal_background TEXT DEFAULT NULL; + UPDATE groups SET terminal_background = '#AABBCC' WHERE id = 'off';", + ) + .unwrap(); + } + + migrate(&conn).unwrap(); + migrate(&conn).unwrap(); + + let flags: Vec = conn + .prepare("SELECT terminal_waves_enabled FROM groups ORDER BY id") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(flags, vec![false, true]); + assert!(!column_exists(&conn, "groups", "terminal_background").unwrap()); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 32); + } + } use super::*; #[test] diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a5b22e1d..c1bb19a5 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -491,6 +491,9 @@ pub struct Group { pub name: String, pub color: String, pub sort_order: i32, + /// Enables terminal waves in the group's color. + #[serde(default)] + pub terminal_waves_enabled: bool, } // --- Highlight --- diff --git a/src-tauri/src/sync/config.rs b/src-tauri/src/sync/config.rs index 3497e44b..d09cebb5 100644 --- a/src-tauri/src/sync/config.rs +++ b/src-tauri/src/sync/config.rs @@ -870,6 +870,48 @@ mod tests { v } + #[test] + fn group_waves_import_defaults_and_boolean_exports() { + let (db, ss, dir) = fixture(); + let legacy = json!({ + "id": "legacy", "name": "Legacy", "color": "#FF0000", "sort_order": 0 + }); + let enabled = json!({ + "id": "enabled", "name": "Enabled", "color": "#00FF00", "sort_order": 1, + "terminal_waves_enabled": true + }); + let disabled = json!({ + "id": "disabled", "name": "Disabled", "color": "#0000FF", "sort_order": 2, + "terminal_waves_enabled": false + }); + merge_import( + &db, + &ss, + dir.path(), + &json!({ + "groups": [legacy.clone(), enabled.clone(), disabled.clone()] + }), + ) + .unwrap(); + let mut expected_legacy = legacy; + expected_legacy["terminal_waves_enabled"] = json!(false); + let expected = json!([expected_legacy, enabled, disabled]); + + for mode in [ + ExportMode::LocalBackup, + ExportMode::RemotePush(read_sync_prefs(&db).unwrap()), + ] { + let exported = build_payload(&db, &ss, dir.path(), &mode).unwrap(); + assert_eq!(exported["groups"], expected); + let (restored, restored_ss, restored_dir) = fixture(); + merge_import(&restored, &restored_ss, restored_dir.path(), &exported).unwrap(); + assert_eq!( + serde_json::to_value(group::list(&restored).unwrap()).unwrap(), + expected + ); + } + } + #[test] fn merge_keeps_local_only_rows() { let (db, ss, dir) = fixture(); diff --git a/src/lib/components/EnvironmentWave.svelte b/src/lib/components/EnvironmentWave.svelte new file mode 100644 index 00000000..b0530e8d --- /dev/null +++ b/src/lib/components/EnvironmentWave.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/src/lib/components/GroupManager.svelte b/src/lib/components/GroupManager.svelte index 6f401268..7db90221 100644 --- a/src/lib/components/GroupManager.svelte +++ b/src/lib/components/GroupManager.svelte @@ -6,6 +6,8 @@ import { toast } from "../stores/toast.svelte.ts"; import { t, errMsg } from "../i18n/index.svelte.ts"; import AppIcon from "./AppIcon.svelte"; + import EnvironmentWave from "./EnvironmentWave.svelte"; + import * as theme from "../themes/store.svelte.ts"; let groups = $state([]); let adding = $state(false); @@ -15,6 +17,8 @@ let formName = $state(""); let formColor = $state("#4A6CF7"); let formOrder = $state(0); + let formWavesEnabled = $state(false); + let previewTheme = $derived(theme.currentTermTheme()); let deleting = $state(null); @@ -30,6 +34,7 @@ formName = ""; formColor = "#4A6CF7"; formOrder = 0; + formWavesEnabled = false; } function startEdit(g: Group) { @@ -38,6 +43,7 @@ formName = g.name; formColor = g.color; formOrder = g.sort_order; + formWavesEnabled = g.terminal_waves_enabled; } function cancelForm() { @@ -52,6 +58,7 @@ name: formName.trim(), color: formColor, sort_order: formOrder, + terminal_waves_enabled: formWavesEnabled, }; try { await invoke("create_group", { group }); @@ -67,6 +74,7 @@ name: formName.trim(), color: formColor, sort_order: formOrder, + terminal_waves_enabled: formWavesEnabled, }; try { await invoke("update_group", { group }); @@ -86,6 +94,20 @@ } +{#snippet backgroundFields()} + +

{t("group.terminal_background_hint")}

+ {#if formWavesEnabled} +
+
{formName || t("group.name_placeholder")} ~ # {t("group.preview")}
+ +
+ {/if} +{/snippet} +
@@ -97,6 +119,7 @@ + {@render backgroundFields()}
@@ -113,6 +136,7 @@ + {@render backgroundFields()}
@@ -127,6 +151,9 @@
{g.name}
{t("group.order", { n: g.sort_order })}
+ {#if g.terminal_waves_enabled} +
{t("group.terminal_background")}: {g.color}
+ {/if}
@@ -160,6 +187,15 @@