diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index c0cd54fa..f15b0ff5 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 = 32; +const SCHEMA_VERSION: u32 = 33; 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")?; @@ -692,6 +692,23 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { } } + if version < 33 && table_exists(conn, "settings")? { + conn.execute_batch( + "INSERT INTO settings (key, value) VALUES ( + 'startup_action', + CASE + WHEN (SELECT value FROM settings WHERE key = 'session_restore.enabled') = 'true' + THEN 'restore' + WHEN (SELECT value FROM settings WHERE key = 'open_local_on_startup') = 'true' + THEN 'local' + ELSE 'home' + END + ) ON CONFLICT(key) DO NOTHING; + DELETE FROM settings + WHERE key IN ('session_restore.enabled', 'open_local_on_startup');", + )?; + } + if version < SCHEMA_VERSION { conn.pragma_update(None, "user_version", SCHEMA_VERSION)?; } @@ -701,6 +718,175 @@ pub fn migrate(conn: &Connection) -> AppResult<()> { #[cfg(test)] mod tests { + fn startup_settings_fixture(restore: Option<&str>, local: Option<&str>) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + PRAGMA user_version = 32;", + ) + .unwrap(); + for (key, value) in [ + ("session_restore.enabled", restore), + ("open_local_on_startup", local), + ] { + if let Some(value) = value { + conn.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .unwrap(); + } + } + conn + } + + #[test] + fn migration_33_defaults_new_databases_to_home() { + let conn = Connection::open_in_memory().unwrap(); + + migrate(&conn).unwrap(); + migrate(&conn).unwrap(); + + let action: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'startup_action'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(action, "home"); + } + + #[test] + fn migration_33_combines_startup_flags_and_preserves_the_snapshot() { + let cases = [ + (None, None, "home"), + (None, Some("false"), "home"), + (None, Some("true"), "local"), + (Some("false"), None, "home"), + (Some("false"), Some("false"), "home"), + (Some("false"), Some("true"), "local"), + (Some("true"), None, "restore"), + (Some("true"), Some("false"), "restore"), + (Some("true"), Some("true"), "restore"), + ]; + let snapshot = r#"{"version":1,"terminals":[{"type":"local"}],"active":0}"#; + for (restore, local, expected) in cases { + let conn = startup_settings_fixture(restore, local); + conn.execute( + "INSERT INTO settings (key, value) VALUES ('session_restore.snapshot', ?1)", + [snapshot], + ) + .unwrap(); + + migrate(&conn).unwrap(); + migrate(&conn).unwrap(); + + let settings: Vec<(String, String)> = conn + .prepare("SELECT key, value FROM settings ORDER BY key") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + settings, + vec![ + ("session_restore.snapshot".into(), snapshot.into()), + ("startup_action".into(), expected.into()), + ], + "restore={restore:?}, local={local:?}", + ); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + } + + #[test] + fn migration_33_preserves_existing_startup_actions_and_later_user_changes() { + for current in ["home", "local", "restore", "", "unknown"] { + let conn = startup_settings_fixture(Some("true"), Some("true")); + conn.execute( + "INSERT INTO settings (key, value) VALUES ('startup_action', ?1)", + [current], + ) + .unwrap(); + + migrate(&conn).unwrap(); + + let action: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'startup_action'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(action, current); + let count: u32 = conn + .query_row("SELECT COUNT(*) FROM settings", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + + conn.execute( + "UPDATE settings SET value = 'local' WHERE key = 'startup_action'", + [], + ) + .unwrap(); + migrate(&conn).unwrap(); + let action: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'startup_action'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(action, "local"); + } + } + + #[test] + fn migration_33_rolls_back_if_legacy_key_cleanup_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rssh.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO settings (key, value) VALUES + ('session_restore.enabled', 'true'), + ('open_local_on_startup', 'true'); + CREATE TRIGGER reject_startup_cleanup + BEFORE DELETE ON settings WHEN OLD.key = 'open_local_on_startup' + BEGIN SELECT RAISE(ABORT, 'cleanup failed'); END; + PRAGMA user_version = 32;", + ) + .unwrap(); + drop(conn); + + assert!(crate::db::Db::open(dir.path()).is_err()); + + let conn = Connection::open(path).unwrap(); + let settings: Vec<(String, String)> = conn + .prepare("SELECT key, value FROM settings ORDER BY key") + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + settings, + vec![ + ("open_local_on_startup".into(), "true".into()), + ("session_restore.enabled".into(), "true".into()), + ], + ); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 32); + } + #[test] fn migration_32_preserves_groups_and_defaults_group_waves_to_disabled() { let conn = rusqlite::Connection::open_in_memory().unwrap(); @@ -723,7 +909,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 32); + assert_eq!(version, SCHEMA_VERSION); assert!(!super::column_exists(&conn, "groups", "terminal_background").unwrap()); } @@ -790,7 +976,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 32); + assert_eq!(version, SCHEMA_VERSION); } #[test] @@ -833,7 +1019,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 32); + assert_eq!(version, SCHEMA_VERSION); } } use super::*; diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 6adc6fb1..f2debd5c 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -4,6 +4,7 @@ import {getCurrentWindow} from "@tauri-apps/api/window"; import type {Profile, Tab, Group} from "../stores/app.svelte.ts"; import * as app from "../stores/app.svelte.ts"; + import * as startupSettings from "../stores/startup.svelte.ts"; import * as layout from "../stores/layout.svelte.ts"; import * as updates from "../stores/updates.svelte.ts"; import * as syncStatus from "../stores/sync.svelte.ts"; @@ -87,12 +88,9 @@ try { await initializePrimarySessionWindow({ signal: startup.signal, - canOpenLocal: app.capabilities().localPty, reconcile: () => invoke("reconcile_sessions", { activeIds: [] }), allowResourcePanes: () => { resourcePanesAllowed = true; }, - loadAutoOpenLocal: async () => - await invoke("get_setting", { key: "open_local_on_startup" }) === "true", - openLocal: addLocalTab, + runStartupAction: () => startupSettings.run(startup.signal), }); } catch (error) { if (!startup.signal.aborted) startupError = errMsg(error); @@ -101,6 +99,11 @@ } } let navigationLoad = 0; + function persistSessionSnapshot() { + if (bypassStartupReconcile || !startupSettings.ready()) return; + void startupSettings.persist(startupSettings.snapshot()); + } + $effect(persistSessionSnapshot); $effect(() => { const tabs = app.tabs(); for (const paneId of Object.keys(pendingPaneSources)) { @@ -1253,7 +1256,7 @@ } - + {#if app.snippetPickerOpen()} diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index 2d51c4db..bb42437d 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -2,9 +2,12 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; + import * as startup from "../stores/startup.svelte.ts"; + import type { StartupAction } from "../stores/startup.svelte.ts"; import * as transfers from "../stores/transfers.svelte.ts"; import { isMac } from "../stores/keymap.svelte.ts"; - import { t } from "../i18n/index.svelte.ts"; + import { t, errMsg } from "../i18n/index.svelte.ts"; + import { toast } from "../stores/toast.svelte.ts"; import Select from "./Select.svelte"; import Modal from "./Modal.svelte"; @@ -15,8 +18,8 @@ /** 用户选了 Custom 但还没填路径的瞬态标记 —— 不持久化。 * 避免把占位字符串写进 local_shell 设置导致重启坏 shell。 */ let pendingCustom = $state(false); - /** Auto-open one local terminal on startup — default off (unset reads as off). */ - let openLocalOnStartup = $state(false); + let startupAction = $state(null); + let startupBusy = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); let copyOnSelect = $state(false); @@ -30,6 +33,11 @@ { value: "paste", label: t("settings.shell.right_click_paste") }, { value: "copyPaste", label: t("settings.shell.right_click_copy_paste") }, ]); + let startupOptions = $derived([ + { value: "restore", label: t("settings.shell.startup_restore") }, + ...(shells.length ? [{ value: "local", label: t("settings.shell.startup_local") }] : []), + { value: "home", label: t("settings.shell.startup_home") }, + ]); /** SFTP 并发上限。main.ts 启动时 loadMaxConcurrent 已写过 store, * 这里 onMount 再读一次显示当前值。 */ let sftpMaxConcurrent = $state(transfers.maxConcurrent()); @@ -40,13 +48,12 @@ let customMode = $derived(pendingCustom || (selectedShell !== "" && !shells.includes(selectedShell))); onMount(async () => { + await loadStartup(); if (app.capabilities().localPty) { - try { shells = await invoke("list_shells"); } catch { shells = []; } selectedShell = await invoke("get_setting", { key: "local_shell" }) ?? ""; if (selectedShell && !shells.includes(selectedShell)) { customPath = selectedShell; } - openLocalOnStartup = (await invoke("get_setting", { key: "open_local_on_startup" })) === "true"; } verboseLog = (await invoke("get_setting", { key: "verbose_log" })) !== "false"; const ts = await invoke("get_setting", { key: "connect_timeout" }); @@ -97,8 +104,31 @@ await invoke("set_setting", { key: "verbose_log", value: String(verboseLog) }); } - async function saveOpenLocalOnStartup() { - await invoke("set_setting", { key: "open_local_on_startup", value: String(openLocalOnStartup) }); + async function loadStartup() { + if (startupBusy) return; + startupBusy = true; + try { + const [action, availableShells] = await Promise.all([startup.action(), startup.availableShells()]); + shells = availableShells; + startupAction = action === "local" && !shells.length ? "home" : action; + } catch (error) { + startupAction = null; + toast.error(errMsg(error)); + } finally { + startupBusy = false; + } + } + + async function saveStartup(action: StartupAction) { + startupBusy = true; + try { + await startup.setAction(action); + } catch (error) { + toast.error(errMsg(error)); + } finally { + startupBusy = false; + await loadStartup(); + } } async function saveTimeout() { @@ -150,10 +180,14 @@ } + +
{#if app.capabilities().localPty} -
+ {/if} +
+ {#if app.capabilities().localPty}
{t("settings.shell.pick_hint")}
@@ -192,49 +226,63 @@
-
-
-
{t("settings.shell.open_local_on_startup")}
-
{t("settings.shell.open_local_on_startup_desc")}
-
- + {/if} +
+
+ + {#if startupAction === "restore"} +
{t("settings.shell.startup_restore_desc")}
+ {/if} +
+
+ { if (e.key === "Enter") saveTimeout(); }} /> - {t("settings.shell.timeout_hint")}
- -
- - { if (e.key === "Enter") saveSftpMaxConcurrent(); }} /> - {t("settings.shell.sftp_concurrent_hint", { - min: sftpBounds.min, max: sftpBounds.max, def: sftpBounds.def, - })} -
+ +
+
+
+ +
{t("settings.shell.timeout_hint")}
+
+ { if (e.key === "Enter") saveTimeout(); }} /> +
+ +
- -
-
-
{t("settings.shell.verbose_log")}
-
{t("settings.shell.verbose_log_desc")}
+
+
+ +
{t("settings.shell.sftp_concurrent_hint", { + min: sftpBounds.min, max: sftpBounds.max, def: sftpBounds.def, + })}
+
+ { if (e.key === "Enter") saveSftpMaxConcurrent(); }} /> +
+ +
+ +
+
+ +
{t("settings.shell.verbose_log_desc")}
+
+
-
@@ -267,12 +315,12 @@
-
+
{t("settings.shell.right_click_desc")}
-
+