From 27157abbf08a4467854031a557703fcde7eb69de Mon Sep 17 00:00:00 2001 From: fdranger <17621282681@163.com> Date: Wed, 30 Sep 2026 13:33:29 +0000 Subject: [PATCH 1/8] feat(sessions): restore saved terminal tabs on startup --- src/lib/components/AppShell.svelte | 8 + src/lib/components/ShellSettings.svelte | 29 +++- .../components/primary-session-window.test.ts | 24 +++ src/lib/components/primary-session-window.ts | 8 + src/lib/i18n/locales/en.ts | 2 + src/lib/i18n/locales/zh.ts | 2 + src/lib/stores/session-restore.svelte.test.ts | 161 ++++++++++++++++++ src/lib/stores/session-restore.svelte.ts | 143 ++++++++++++++++ 8 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/lib/stores/session-restore.svelte.test.ts create mode 100644 src/lib/stores/session-restore.svelte.ts diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index dc60cb68..24596c33 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 sessionRestore from "../stores/session-restore.svelte.ts"; import * as updates from "../stores/updates.svelte.ts"; import * as syncStatus from "../stores/sync.svelte.ts"; import HomeScreen from "./HomeScreen.svelte"; @@ -75,6 +76,10 @@ const bypassStartupReconcile = !!window.__rssh_clone || !!window.__rssh_ai_handoff; let resourcePanesAllowed = $state(bypassStartupReconcile); let navigationLoad = 0; + $effect(() => { + if (bypassStartupReconcile || !sessionRestore.ready() || !sessionRestore.enabled()) return; + sessionRestore.persist(sessionRestore.snapshot()); + }); $effect(() => { const tabs = app.tabs(); for (const paneId of Object.keys(pendingPaneSources)) { @@ -238,10 +243,13 @@ canOpenLocal: !app.isMobile, reconcile: () => invoke("reconcile_sessions", { activeIds: [] }), allowResourcePanes: () => { resourcePanesAllowed = true; }, + restoreSessions: () => sessionRestore.restore(startup.signal), loadAutoOpenLocal: async () => await invoke("get_setting", { key: "open_local_on_startup" }) === "true", openLocal: addLocalTab, }); + } else { + void sessionRestore.initializeSecondaryWindow(); } consumeCloneQuery(); consumeAiHandoff(); diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index e28ca219..ac84f0ef 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -2,9 +2,11 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; + import * as sessionRestore from "../stores/session-restore.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"; @@ -17,6 +19,7 @@ let pendingCustom = $state(false); /** Auto-open one local terminal on startup — default off (unset reads as off). */ let openLocalOnStartup = $state(false); + let savingRestore = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); let copyOnSelect = $state(false); @@ -101,6 +104,19 @@ await invoke("set_setting", { key: "open_local_on_startup", value: String(openLocalOnStartup) }); } + async function saveRestore(event: Event) { + const input = event.currentTarget as HTMLInputElement; + savingRestore = true; + try { + await sessionRestore.setEnabled(input.checked); + } catch (error) { + toast.error(errMsg(error)); + } finally { + input.checked = sessionRestore.enabled(); + savingRestore = false; + } + } + async function saveTimeout() { const val = Math.max(1, Math.min(300, connectTimeout)); connectTimeout = val; @@ -151,6 +167,17 @@
+
+
+
{t("settings.shell.restore_sessions")}
+
{t("settings.shell.restore_sessions_desc")}
+
+ +
{#if !app.isMobile}
diff --git a/src/lib/components/primary-session-window.test.ts b/src/lib/components/primary-session-window.test.ts index 992c26dc..26509d63 100644 --- a/src/lib/components/primary-session-window.test.ts +++ b/src/lib/components/primary-session-window.test.ts @@ -11,6 +11,30 @@ function deferred() { } describe("initializePrimarySessionWindow", () => { + it("restores only after reconciliation, including on mobile", async () => { + const events: string[] = []; + await initializePrimarySessionWindow({ + canOpenLocal: false, + reconcile: async () => { events.push("reconcile"); }, + allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, + loadAutoOpenLocal: async () => { events.push("auto-open"); return true; }, + openLocal: () => events.push("local"), + }); + expect(events).toEqual(["reconcile", "release", "restore"]); + }); + + it("does not duplicate a restored local terminal", async () => { + const loadAutoOpenLocal = vi.fn(async () => true); + const openLocal = vi.fn(); + await initializePrimarySessionWindow({ + reconcile: async () => {}, allowResourcePanes: () => {}, + restoreSessions: async () => true, loadAutoOpenLocal, openLocal, + }); + expect(loadAutoOpenLocal).not.toHaveBeenCalled(); + expect(openLocal).not.toHaveBeenCalled(); + }); + it("releases resource panes before reading and applying auto-open", async () => { const reconcile = deferred(); const events: string[] = []; diff --git a/src/lib/components/primary-session-window.ts b/src/lib/components/primary-session-window.ts index de25f18f..2f5bc64c 100644 --- a/src/lib/components/primary-session-window.ts +++ b/src/lib/components/primary-session-window.ts @@ -3,6 +3,7 @@ interface PrimarySessionWindowDependencies { canOpenLocal?: boolean; reconcile: () => Promise; allowResourcePanes: () => void; + restoreSessions?: () => Promise; loadAutoOpenLocal: () => Promise; openLocal: () => void; } @@ -19,6 +20,13 @@ export async function initializePrimarySessionWindow( } if (dependencies.signal?.aborted) return; dependencies.allowResourcePanes(); + let restoredLocal = false; + try { + restoredLocal = await dependencies.restoreSessions?.() ?? false; + } catch { + // A failed restore must not prevent the existing startup preference. + } + if (dependencies.signal?.aborted || restoredLocal) return; if (dependencies.canOpenLocal === false) return; try { diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index d2285010..25787aba 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1200,6 +1200,8 @@ const en = { "settings.shell.local_shell": "LOCAL SHELL", "settings.shell.custom": "CUSTOM", "settings.shell.open_local_on_startup": "Open on startup", + "settings.shell.restore_sessions": "Restore sessions on startup", + "settings.shell.restore_sessions_desc": "Reopen saved SSH, Telnet and serial connections and local terminals from this device's last session. Connections start fresh using current profiles; startup commands may run again. Split panes reopen as separate tabs. Terminal output, editors, forwards and discovered containers are not restored. Off by default.", "settings.shell.open_local_on_startup_desc": "When rssh starts, automatically open one local terminal tab.", "settings.shell.connection_timeout": "CONNECTION TIMEOUT", "settings.shell.timeout_label": "Timeout (seconds)", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 97e4051a..1f148061 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -1203,6 +1203,8 @@ const zh: Messages = { "settings.shell.local_shell": "本地 SHELL", "settings.shell.custom": "自定义", "settings.shell.open_local_on_startup": "启动时打开", + "settings.shell.restore_sessions": "启动时恢复会话", + "settings.shell.restore_sessions_desc": "重新打开本机上次的已保存 SSH、Telnet、串口连接和本地终端。使用当前配置建立新连接,启动命令可能再次执行;分屏恢复为独立标签页。不恢复终端输出、编辑器、端口转发或动态发现的容器。默认关闭。", "settings.shell.open_local_on_startup_desc": "rssh 启动时自动打开一个本地终端标签页。", "settings.shell.connection_timeout": "连接超时", "settings.shell.timeout_label": "超时(秒)", diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts new file mode 100644 index 00000000..6b8c176f --- /dev/null +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +const key = "session_restore.desktop"; +let settings: Map; + +beforeEach(() => { + vi.resetModules(); + settings = new Map(); + vi.stubGlobal("navigator", { userAgent: "node" }); + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + invoke.mockReset(); + invoke.mockImplementation(async (command: string, args: Record) => { + if (command === "get_setting") return settings.get(args.key) ?? null; + if (command === "set_setting") { settings.set(args.key, args.value); return; } + if (command === "list_profiles") return [{ id: "ssh1", name: "Current SSH", host: "new-host", port: 2222 }]; + if (command === "list_telnet_profiles") return [{ id: "telnet1", name: "Telnet", host: "telnet", port: 23, login_script: "secret" }]; + if (command === "list_serial_profiles") return []; + return null; + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +async function modules() { + return { app: await import("./app.svelte.ts"), restore: await import("./session-restore.svelte.ts") }; +} +function saved(terminals: unknown[], active = -1) { + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, JSON.stringify({ version: 1, terminals, active })); +} + +describe("session restoration", () => { + it("defaults off without fetching profiles or reopening tabs", async () => { + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(restore.enabled()).toBe(false); + expect(restore.ready()).toBe(true); + expect(app.tabs()).toHaveLength(1); + expect(invoke).not.toHaveBeenCalledWith("list_profiles"); + }); + + it("reopens duplicates in order with fresh IDs and current profiles, skipping deleted profiles", async () => { + saved([ + { type: "ssh", profileId: "ssh1" }, { type: "ssh", profileId: "gone" }, + { type: "local" }, { type: "ssh", profileId: "ssh1" }, + { type: "telnet", profileId: "telnet1" }, + ], 3); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(true); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local", "ssh", "telnet"]); + expect(new Set(app.tabs().map((tab) => tab.id)).size).toBe(5); + expect(app.activeTabId()).toBe(app.tabs()[3].id); + expect(app.tabs()[1].meta).toEqual({ profileId: "ssh1", host: "new-host", port: "2222" }); + expect(app.tabs()[4].meta).not.toHaveProperty("login_script"); + }); + + it("persists only profile references, including split panes, without secret metadata or unsupported tabs", async () => { + const { app, restore } = await modules(); + app.addTab({ id: "ssh", type: "ssh", label: "Secret label", meta: { profileId: "ssh1", password: "secret", host: "host" } }); + app.addPane("ssh", "right", { id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1", login_script: "secret" } }); + app.addTab({ id: "serial", type: "serial", label: "Serial", meta: { serialProfileId: "serial1", login_script: "secret" } }); + app.addTab({ id: "forward", type: "forward", label: "Forward", meta: { forwardId: "f1" } }); + app.addTab({ id: "container", type: "docker_exec", label: "Container", meta: { connectorSpec: "secret" } }); + app.addTab({ id: "editor", type: "edit", label: "Editor", meta: { content: "secret" } }); + app.setActiveWorkspace("ssh"); + app.setActivePane("telnet"); + const snapshot = restore.snapshot(); + expect(JSON.parse(snapshot)).toEqual({ version: 1, terminals: [ + { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, + { type: "serial", profileId: "serial1" }, + ], active: 1 }); + expect(snapshot).not.toContain("secret"); + }); + + it("clears saved sessions when disabled and does not persist while disabled", async () => { + saved([{ type: "local" }]); + const { restore } = await modules(); + await restore.restore(); + await restore.setEnabled(false); + restore.persist(restore.snapshot()); + expect(settings.get(`${key}.enabled`)).toBe("false"); + expect(settings.get(`${key}.snapshot`)).toBe(""); + }); + + it.each(["bad JSON", '{"version":2,"terminals":[]}', '{"version":1,"terminals":[{"type":"ssh"}]}']) + ("handles invalid snapshots: %s", async (snapshot) => { + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, snapshot); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(app.tabs()).toHaveLength(1); + }); + + it("does not overwrite the snapshot if profile loading fails", async () => { + saved([{ type: "ssh", profileId: "ssh1" }]); + const original = settings.get(`${key}.snapshot`); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation((command, args) => command === "list_profiles" + ? Promise.reject(new Error("offline")) : implementation(command, args)); + const { restore } = await modules(); + await restore.restore(); + restore.persist(restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(original); + }); + + it("does not reopen connections after startup cancellation", async () => { + saved([{ type: "local" }]); + const controller = new AbortController(); + controller.abort(); + const { app, restore } = await modules(); + expect(await restore.restore(controller.signal)).toBe(false); + expect(app.tabs()).toHaveLength(1); + expect(restore.ready()).toBe(false); + }); + + it("does not take over connections opened manually during startup", async () => { + saved([{ type: "local" }]); + const { app, restore } = await modules(); + app.addTab({ id: "manual", type: "local", label: "Manual" }); + expect(await restore.restore()).toBe(false); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home", "manual"]); + }); + + it("serializes snapshots so the latest write wins", async () => { + const { restore } = await modules(); + await restore.restore(); + await restore.setEnabled(true); + restore.persist("first"); + restore.persist("second"); + await vi.waitFor(() => expect(settings.get(`${key}.snapshot`)).toBe("second")); + }); + + it("keeps saved ordering even when MRU reordering is enabled", async () => { + saved([{ type: "ssh", profileId: "ssh1" }, { type: "local" }], 1); + const { app, restore } = await modules(); + await app.setTabMru(true); + await restore.restore(); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local"]); + expect(app.activeTabId()).toBe(app.tabs()[2].id); + }); + + it("restores mobile SSH/Telnet while excluding desktop local and serial terminals", async () => { + vi.stubGlobal("navigator", { userAgent: "Android" }); + settings.set("session_restore.mobile.enabled", "true"); + settings.set("session_restore.mobile.snapshot", JSON.stringify({ version: 1, terminals: [ + { type: "local" }, { type: "serial", profileId: "s1" }, + { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, + ], active: 2 })); + const { app, restore } = await modules(); + expect(await restore.restore()).toBe(false); + expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "telnet"]); + expect(invoke).not.toHaveBeenCalledWith("list_serial_profiles"); + expect(app.activeTabId()).toBe(app.tabs()[1].id); + }); +}); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts new file mode 100644 index 00000000..bb2d6116 --- /dev/null +++ b/src/lib/stores/session-restore.svelte.ts @@ -0,0 +1,143 @@ +import { invoke } from "@tauri-apps/api/core"; +import * as app from "./app.svelte.ts"; +import { errMsg } from "../i18n/index.svelte.ts"; +import { toast } from "./toast.svelte.ts"; + +// Only references to saved profiles are persisted. Never serialize tab.meta: +// serial login scripts, editor content and connector commands may contain secrets. +type SavedTerminal = { type: "ssh" | "telnet" | "serial"; profileId: string } | { type: "local" }; +interface Snapshot { version: 1; terminals: SavedTerminal[]; active: number } +const key = `session_restore.${app.isMobile ? "mobile" : "desktop"}`; +let _enabled = $state(false); +let _ready = $state(false); +let writes = Promise.resolve(); + +export function enabled() { return _enabled; } +export function ready() { return _ready; } + +function report(error: unknown) { + console.warn("[session-restore]", error); + toast.error(errMsg(error)); +} + +function write(setting: string, value: string): Promise { + // Serialize writes so an older snapshot cannot overwrite a newer one. + const next = writes.then(() => invoke("set_setting", { key: setting, value })); + writes = next.catch((error) => console.warn("[session-restore] write failed:", error)); + return next; +} + +export async function setEnabled(value: boolean) { + await write(`${key}.enabled`, String(value)); + _enabled = value; + if (!value) await write(`${key}.snapshot`, ""); +} + +function reference(tab: app.Tab): SavedTerminal | null { + if (tab.type === "local") return app.isMobile ? null : { type: "local" }; + if (tab.type !== "ssh" && tab.type !== "telnet" && tab.type !== "serial") return null; + if (tab.type === "serial" && app.isMobile) return null; + const profileId = tab.type === "serial" ? tab.meta?.serialProfileId : tab.meta?.profileId; + return profileId ? { type: tab.type, profileId } : null; +} + +export function snapshot(): string { + const terminals: SavedTerminal[] = []; + let active = -1; + for (const tab of app.tabs()) { + const saved = reference(tab); + if (!saved || terminals.length >= 100) continue; + if (tab.id === app.activeTabId()) active = terminals.length; + terminals.push(saved); + } + return JSON.stringify({ version: 1, terminals, active } satisfies Snapshot); +} + +export function persist(value: string) { + if (_ready && _enabled) void write(`${key}.snapshot`, value).catch(report); +} + +/** Secondary windows read the preference but never restore or save their tabs. */ +export async function initializeSecondaryWindow() { + try { + _enabled = await invoke("get_setting", { key: `${key}.enabled` }) === "true"; + } catch (error) { + report(error); + } finally { + _ready = true; + } +} + +function parse(raw: string | null): Snapshot | null { + if (!raw) return null; + const data = JSON.parse(raw); + if (data?.version !== 1 || !Array.isArray(data.terminals)) return null; + const terminals = data.terminals.slice(0, 100).map((value: unknown): SavedTerminal | null => { + if (!value || typeof value !== "object") return null; + const entry = value as Record; + if (entry.type === "local") return { type: "local" }; + if ((entry.type === "ssh" || entry.type === "telnet" || entry.type === "serial") + && typeof entry.profileId === "string" && entry.profileId) { + return { type: entry.type, profileId: entry.profileId }; + } + return null; + }); + // Reject invalid entries rather than shifting the active index. + if (terminals.some((entry: SavedTerminal | null) => !entry)) return null; + return { version: 1, terminals, active: Number.isInteger(data.active) ? data.active : -1 }; +} + +/** Returns whether a local terminal was restored (avoids startup duplication). */ +export async function restore(signal?: AbortSignal): Promise { + let localRestored = false; + try { + _enabled = await invoke("get_setting", { key: `${key}.enabled` }) === "true"; + if (!_enabled || signal?.aborted) return false; + const saved = parse(await invoke("get_setting", { key: `${key}.snapshot` })); + if (!saved || signal?.aborted) return false; + const types = new Set(saved.terminals.map((entry) => entry.type)); + const [ssh, telnet, serial] = await Promise.all([ + types.has("ssh") ? app.loadProfiles() : [], + types.has("telnet") ? invoke("list_telnet_profiles") : [], + types.has("serial") && !app.isMobile ? invoke("list_serial_profiles") : [], + ]); + // Do not take over tabs the user opened while the backend was loading. + if (signal?.aborted || app.tabs().length !== 1) return false; + let activeId = "home"; + const restoredIds: string[] = []; + saved.terminals.forEach((entry, index) => { + const before = app.tabs().length; + if (entry.type === "local" && !app.isMobile) { + app.addTab({ id: `local:${crypto.randomUUID()}`, type: "local", label: "Local" }); + localRestored = true; + } else if (entry.type === "ssh") { + const profile = ssh.find((profile) => profile.id === entry.profileId); + if (profile) app.addTab({ + id: `ssh:${crypto.randomUUID()}`, type: "ssh", label: profile.name, + meta: { profileId: profile.id, host: profile.host, port: String(profile.port) }, + }); + } else if (entry.type === "telnet") { + const profile = telnet.find((profile) => profile.id === entry.profileId); + if (profile) app.connectTelnetProfile(profile); + } else if (entry.type === "serial" && !app.isMobile) { + const profile = serial.find((profile) => profile.id === entry.profileId); + if (profile) app.connectSerialProfile(profile); + } + if (app.tabs().length > before) { + restoredIds.push(app.activeTabId()); + if (index === saved.active) activeId = app.activeTabId(); + } + }); + app.setActiveTab(activeId); + // addTab/focus may apply MRU ordering. Restore the saved strip order last. + restoredIds.forEach((id, index) => { + app.moveTab(app.tabs().findIndex((tab) => tab.id === id), index + 1); + }); + } catch (error) { + _enabled = false; // Preserve the last snapshot when startup reads fail. + report(error); + } finally { + if (!signal?.aborted) _ready = true; + } + return localRestored; +} From d3dd49dec91e4c8bb67c4ee3ad5ab4c5ca471e94 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 22:21:36 +0800 Subject: [PATCH 2/8] fix(sessions): read restore preference from shared storage on use --- src/lib/components/AppShell.svelte | 13 +- src/lib/components/ShellSettings.svelte | 26 +++- src/lib/stores/session-restore.svelte.test.ts | 136 +++++++++++++++++- src/lib/stores/session-restore.svelte.ts | 54 +++---- 4 files changed, 186 insertions(+), 43 deletions(-) diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 7a605f65..1f29e987 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -103,10 +103,11 @@ } } let navigationLoad = 0; - $effect(() => { - if (bypassStartupReconcile || !sessionRestore.ready() || !sessionRestore.enabled()) return; - sessionRestore.persist(sessionRestore.snapshot()); - }); + function persistSessionSnapshot() { + if (bypassStartupReconcile || !sessionRestore.ready()) return; + void sessionRestore.persist(sessionRestore.snapshot()); + } + $effect(persistSessionSnapshot); $effect(() => { const tabs = app.tabs(); for (const paneId of Object.keys(pendingPaneSources)) { @@ -272,8 +273,6 @@ // backend resource. Otherwise reconcile([]) can race that new session. if (!bypassStartupReconcile) { void initializeResources(); - } else { - void sessionRestore.initializeSecondaryWindow(); } consumeCloneQuery(); consumeAiHandoff(); @@ -1261,7 +1260,7 @@ } - + {#if app.snippetPickerOpen()} diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index bf23a32b..49540220 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -19,6 +19,8 @@ let pendingCustom = $state(false); /** Auto-open one local terminal on startup — default off (unset reads as off). */ let openLocalOnStartup = $state(false); + let restoreEnabled = $state(null); + let loadingRestore = $state(false); let savingRestore = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); @@ -42,6 +44,8 @@ * pendingCustom(点了 Custom 没填)或 selectedShell 不在 shells 里都算 custom。 */ let customMode = $derived(pendingCustom || (selectedShell !== "" && !shells.includes(selectedShell))); + onMount(() => { void loadRestore(); }); + onMount(async () => { if (app.capabilities().localPty) { try { shells = await invoke("list_shells"); } catch { shells = []; } @@ -104,6 +108,19 @@ await invoke("set_setting", { key: "open_local_on_startup", value: String(openLocalOnStartup) }); } + async function loadRestore() { + if (loadingRestore || savingRestore) return; + loadingRestore = true; + try { + restoreEnabled = await sessionRestore.enabled(); + } catch (error) { + restoreEnabled = null; + toast.error(errMsg(error)); + } finally { + loadingRestore = false; + } + } + async function saveRestore(event: Event) { const input = event.currentTarget as HTMLInputElement; savingRestore = true; @@ -112,8 +129,9 @@ } catch (error) { toast.error(errMsg(error)); } finally { - input.checked = sessionRestore.enabled(); savingRestore = false; + await loadRestore(); + input.checked = restoreEnabled === true; } } @@ -166,6 +184,8 @@ } + +
@@ -173,8 +193,8 @@
{t("settings.shell.restore_sessions_desc")}
diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts index 7c92f6a3..40cea4f7 100644 --- a/src/lib/stores/session-restore.svelte.test.ts +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -38,12 +38,17 @@ function saved(terminals: unknown[], active = -1) { settings.set(`${key}.enabled`, "true"); settings.set(`${key}.snapshot`, JSON.stringify({ version: 1, terminals, active })); } +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} describe("session restoration", () => { it("defaults off without fetching profiles or reopening tabs", async () => { const { app, restore } = await modules(); expect(await restore.restore()).toBe(false); - expect(restore.enabled()).toBe(false); + expect(await restore.enabled()).toBe(false); expect(restore.ready()).toBe(true); expect(app.tabs()).toHaveLength(1); expect(invoke).not.toHaveBeenCalledWith("list_profiles"); @@ -87,7 +92,7 @@ describe("session restoration", () => { const { restore } = await modules(); await restore.restore(); await restore.setEnabled(false); - restore.persist(restore.snapshot()); + await restore.persist(restore.snapshot()); expect(settings.get(`${key}.enabled`)).toBe("false"); expect(settings.get(`${key}.snapshot`)).toBe(""); }); @@ -109,18 +114,40 @@ describe("session restoration", () => { ? Promise.reject(new Error("offline")) : implementation(command, args)); const { restore } = await modules(); await restore.restore(); - restore.persist(restore.snapshot()); + expect(restore.ready()).toBe(false); + await restore.persist(restore.snapshot()); expect(settings.get(`${key}.snapshot`)).toBe(original); }); it("does not reopen connections after startup cancellation", async () => { saved([{ type: "local" }]); + const original = settings.get(`${key}.snapshot`); const controller = new AbortController(); controller.abort(); const { app, restore } = await modules(); expect(await restore.restore(controller.signal)).toBe(false); expect(app.tabs()).toHaveLength(1); expect(restore.ready()).toBe(false); + await restore.persist(restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(original); + }); + + it("does not reopen saved connections when disabled during profile loading", async () => { + saved([{ type: "ssh", profileId: "ssh1" }]); + const { app, restore } = await modules(); + const blocked = deferred(); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => { + if (command === "list_profiles") await blocked.promise; + return implementation(command, args); + }); + const startup = restore.restore(); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("list_profiles")); + await restore.setEnabled(false); + blocked.resolve(); + expect(await startup).toBe(false); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); + expect(settings.get(`${key}.snapshot`)).toBe(""); }); it("does not take over connections opened manually during startup", async () => { @@ -135,9 +162,106 @@ describe("session restoration", () => { const { restore } = await modules(); await restore.restore(); await restore.setEnabled(true); - restore.persist("first"); - restore.persist("second"); - await vi.waitFor(() => expect(settings.get(`${key}.snapshot`)).toBe("second")); + await Promise.all([restore.persist("first"), restore.persist("second")]); + expect(settings.get(`${key}.snapshot`)).toBe("second"); + }); + + it("reads the current enabled preference instead of caching it", async () => { + const { restore } = await modules(); + expect(await restore.enabled()).toBe(false); + settings.set(`${key}.enabled`, "true"); + expect(await restore.enabled()).toBe(true); + settings.set(`${key}.enabled`, "false"); + expect(await restore.enabled()).toBe(false); + }); + + it("observes preference changes from a secondary window before saving primary tabs", async () => { + const primary = await modules(); + await primary.restore.restore(); + primary.app.addTab({ id: "primary", type: "local", label: "Primary" }); + vi.resetModules(); + const secondary = await modules(); + secondary.app.addTab({ id: "secondary", type: "local", label: "Secondary" }); + + await secondary.restore.setEnabled(true); + expect(secondary.restore.ready()).toBe(false); + await secondary.restore.persist(secondary.restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBeUndefined(); + await primary.restore.persist(primary.restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(primary.restore.snapshot()); + + await secondary.restore.setEnabled(false); + await primary.restore.persist(primary.restore.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(""); + }); + + it("saves existing primary tabs when restoration is enabled", async () => { + const { app, restore } = await modules(); + await restore.restore(); + app.addTab({ id: "local", type: "local", label: "Local" }); + await restore.setEnabled(true); + expect(settings.get(`${key}.snapshot`)).toBe(restore.snapshot()); + }); + + it("checks the preference when a queued snapshot starts writing", async () => { + saved([{ type: "local" }]); + const { restore } = await modules(); + await restore.restore(); + const blocked = deferred(); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => { + if (command === "set_setting" && args.key === `${key}.snapshot` && args.value === "first") { + await blocked.promise; + } + return implementation(command, args); + }); + const first = restore.persist("first"); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("set_setting", { + key: `${key}.snapshot`, value: "first", + })); + const second = restore.persist("second"); + settings.set(`${key}.enabled`, "false"); + blocked.resolve(); + await Promise.all([first, second]); + expect(settings.get(`${key}.snapshot`)).toBe("first"); + expect(invoke).not.toHaveBeenCalledWith("set_setting", { key: `${key}.snapshot`, value: "second" }); + }); + + it("keeps the snapshot cleared when disable follows a pending save", async () => { + saved([{ type: "local" }]); + const { restore } = await modules(); + await restore.restore(); + const blocked = deferred(); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => { + if (command === "set_setting" && args.key === `${key}.snapshot` && args.value === "pending") { + await blocked.promise; + } + return implementation(command, args); + }); + const pending = restore.persist("pending"); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("set_setting", { + key: `${key}.snapshot`, value: "pending", + })); + const disable = restore.setEnabled(false); + const later = restore.persist("later"); + blocked.resolve(); + await Promise.all([pending, disable, later]); + expect(settings.get(`${key}.enabled`)).toBe("false"); + expect(settings.get(`${key}.snapshot`)).toBe(""); + expect(invoke).not.toHaveBeenCalledWith("set_setting", { key: `${key}.snapshot`, value: "later" }); + }); + + it("preserves the snapshot when a preference read fails and saves after recovery", async () => { + saved([{ type: "local" }]); + const original = settings.get(`${key}.snapshot`); + const { restore } = await modules(); + await restore.restore(); + invoke.mockRejectedValueOnce(new Error("settings unavailable")); + await restore.persist("failed"); + expect(settings.get(`${key}.snapshot`)).toBe(original); + await restore.persist("recovered"); + expect(settings.get(`${key}.snapshot`)).toBe("recovered"); }); it("keeps saved ordering even when MRU reordering is enabled", async () => { diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts index 7d1b15b4..bac7df45 100644 --- a/src/lib/stores/session-restore.svelte.ts +++ b/src/lib/stores/session-restore.svelte.ts @@ -12,11 +12,12 @@ function settingsKey() { const capabilities = app.capabilities(); return `session_restore.${capabilities.localPty || capabilities.serial ? "desktop" : "mobile"}`; } -let _enabled = $state(false); let _ready = $state(false); let writes = Promise.resolve(); -export function enabled() { return _enabled; } +export async function enabled(): Promise { + return await invoke("get_setting", { key: `${settingsKey()}.enabled` }) === "true"; +} export function ready() { return _ready; } function report(error: unknown) { @@ -24,17 +25,20 @@ function report(error: unknown) { toast.error(errMsg(error)); } -function write(setting: string, value: string): Promise { - // Serialize writes so an older snapshot cannot overwrite a newer one. - const next = writes.then(() => invoke("set_setting", { key: setting, value })); +function queueWrite(operation: () => Promise): Promise { + // Keep preference reads and their writes together, including disable + clear. + const next = writes.then(operation); writes = next.catch((error) => console.warn("[session-restore] write failed:", error)); return next; } export async function setEnabled(value: boolean) { - await write(`${settingsKey()}.enabled`, String(value)); - _enabled = value; - if (!value) await write(`${settingsKey()}.snapshot`, ""); + const key = settingsKey(); + await queueWrite(async () => { + await invoke("set_setting", { key: `${key}.enabled`, value: String(value) }); + if (!value) await invoke("set_setting", { key: `${key}.snapshot`, value: "" }); + }); + if (value) await persist(snapshot()); } function reference(tab: app.Tab): SavedTerminal | null { @@ -57,19 +61,14 @@ export function snapshot(): string { return JSON.stringify({ version: 1, terminals, active } satisfies Snapshot); } -export function persist(value: string) { - if (_ready && _enabled) void write(`${settingsKey()}.snapshot`, value).catch(report); -} - -/** Secondary windows read the preference but never restore or save their tabs. */ -export async function initializeSecondaryWindow() { - try { - _enabled = await invoke("get_setting", { key: `${settingsKey()}.enabled` }) === "true"; - } catch (error) { - report(error); - } finally { - _ready = true; - } +export async function persist(value: string): Promise { + // Only a primary window whose startup reads succeeded may replace the snapshot. + if (!_ready) return; + await queueWrite(async () => { + if (await enabled()) { + await invoke("set_setting", { key: `${settingsKey()}.snapshot`, value }); + } + }).catch(report); } function parse(raw: string | null): Snapshot | null { @@ -94,9 +93,9 @@ function parse(raw: string | null): Snapshot | null { /** Returns whether a local terminal was restored (avoids startup duplication). */ export async function restore(signal?: AbortSignal): Promise { let localRestored = false; + let failed = false; try { - _enabled = await invoke("get_setting", { key: `${settingsKey()}.enabled` }) === "true"; - if (!_enabled || signal?.aborted) return false; + if (!await enabled() || signal?.aborted) return false; const saved = parse(await invoke("get_setting", { key: `${settingsKey()}.snapshot` })); if (!saved || signal?.aborted) return false; const types = new Set(saved.terminals.map((entry) => entry.type)); @@ -105,8 +104,9 @@ export async function restore(signal?: AbortSignal): Promise { types.has("telnet") ? invoke("list_telnet_profiles") : [], types.has("serial") && app.capabilities().serial ? invoke("list_serial_profiles") : [], ]); - // Do not take over tabs the user opened while the backend was loading. - if (signal?.aborted || app.tabs().length !== 1) return false; + // The preference or the user's tabs may have changed while profiles loaded. + const stillEnabled = await enabled(); + if (!stillEnabled || signal?.aborted || app.tabs().length !== 1) return false; let activeId = "home"; const restoredIds: string[] = []; saved.terminals.forEach((entry, index) => { @@ -138,10 +138,10 @@ export async function restore(signal?: AbortSignal): Promise { app.moveTab(app.tabs().findIndex((tab) => tab.id === id), index + 1); }); } catch (error) { - _enabled = false; // Preserve the last snapshot when startup reads fail. + failed = true; // Preserve the last snapshot when startup reads fail. report(error); } finally { - if (!signal?.aborted) _ready = true; + _ready = !failed && !signal?.aborted; } return localRestored; } From 15c19a16a53ffeeefa29ec7fa329fc32455a7663 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 22:25:25 +0800 Subject: [PATCH 3/8] refactor(sessions): use one local session snapshot namespace --- src/lib/stores/session-restore.svelte.test.ts | 9 ++++----- src/lib/stores/session-restore.svelte.ts | 18 +++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts index 40cea4f7..0716e4d3 100644 --- a/src/lib/stores/session-restore.svelte.test.ts +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -4,7 +4,7 @@ const invoke = vi.hoisted(() => vi.fn()); const capabilities = vi.hoisted(() => ({ localPty: true, serial: true })); vi.mock("./runtime.svelte.ts", () => ({ capabilities: () => capabilities })); vi.mock("@tauri-apps/api/core", () => ({ invoke })); -const key = "session_restore.desktop"; +const key = "session_restore"; let settings: Map; beforeEach(() => { @@ -273,14 +273,13 @@ describe("session restoration", () => { expect(app.activeTabId()).toBe(app.tabs()[2].id); }); - it("restores SSH/Telnet on hosts without PTY/serial capabilities, independently of UA", async () => { + it("uses the shared snapshot on hosts without PTY/serial capabilities", async () => { capabilities.localPty = false; capabilities.serial = false; - settings.set("session_restore.mobile.enabled", "true"); - settings.set("session_restore.mobile.snapshot", JSON.stringify({ version: 1, terminals: [ + saved([ { type: "local" }, { type: "serial", profileId: "s1" }, { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, - ], active: 2 })); + ], 2); const { app, restore } = await modules(); expect(await restore.restore()).toBe(false); expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "telnet"]); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts index bac7df45..46b6d511 100644 --- a/src/lib/stores/session-restore.svelte.ts +++ b/src/lib/stores/session-restore.svelte.ts @@ -7,16 +7,13 @@ import { toast } from "./toast.svelte.ts"; // serial login scripts, editor content and connector commands may contain secrets. type SavedTerminal = { type: "ssh" | "telnet" | "serial"; profileId: string } | { type: "local" }; interface Snapshot { version: 1; terminals: SavedTerminal[]; active: number } -// Keep the existing storage namespaces, independent of window width. -function settingsKey() { - const capabilities = app.capabilities(); - return `session_restore.${capabilities.localPty || capabilities.serial ? "desktop" : "mobile"}`; -} +const enabledKey = "session_restore.enabled"; +const snapshotKey = "session_restore.snapshot"; let _ready = $state(false); let writes = Promise.resolve(); export async function enabled(): Promise { - return await invoke("get_setting", { key: `${settingsKey()}.enabled` }) === "true"; + return await invoke("get_setting", { key: enabledKey }) === "true"; } export function ready() { return _ready; } @@ -33,10 +30,9 @@ function queueWrite(operation: () => Promise): Promise { } export async function setEnabled(value: boolean) { - const key = settingsKey(); await queueWrite(async () => { - await invoke("set_setting", { key: `${key}.enabled`, value: String(value) }); - if (!value) await invoke("set_setting", { key: `${key}.snapshot`, value: "" }); + await invoke("set_setting", { key: enabledKey, value: String(value) }); + if (!value) await invoke("set_setting", { key: snapshotKey, value: "" }); }); if (value) await persist(snapshot()); } @@ -66,7 +62,7 @@ export async function persist(value: string): Promise { if (!_ready) return; await queueWrite(async () => { if (await enabled()) { - await invoke("set_setting", { key: `${settingsKey()}.snapshot`, value }); + await invoke("set_setting", { key: snapshotKey, value }); } }).catch(report); } @@ -96,7 +92,7 @@ export async function restore(signal?: AbortSignal): Promise { let failed = false; try { if (!await enabled() || signal?.aborted) return false; - const saved = parse(await invoke("get_setting", { key: `${settingsKey()}.snapshot` })); + const saved = parse(await invoke("get_setting", { key: snapshotKey })); if (!saved || signal?.aborted) return false; const types = new Set(saved.terminals.map((entry) => entry.type)); const [ssh, telnet, serial] = await Promise.all([ From db7a25925e7d907e9d1690402d46b33b944a64bc Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 22:31:26 +0800 Subject: [PATCH 4/8] refactor(sessions): remove redundant restore restrictions Record all existing terminal references without rechecking host capabilities or silently truncating the snapshot. Keep capability checks at restoration, make the startup callback required, and use one busy state for the settings switch. --- src/lib/components/ShellSettings.svelte | 15 +++-- .../components/primary-session-window.test.ts | 63 ++++++++----------- src/lib/components/primary-session-window.ts | 9 +-- src/lib/stores/session-restore.svelte.test.ts | 30 +++++++++ src/lib/stores/session-restore.svelte.ts | 7 +-- 5 files changed, 68 insertions(+), 56 deletions(-) diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index 49540220..504877a9 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -20,8 +20,7 @@ /** Auto-open one local terminal on startup — default off (unset reads as off). */ let openLocalOnStartup = $state(false); let restoreEnabled = $state(null); - let loadingRestore = $state(false); - let savingRestore = $state(false); + let restoreBusy = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); let copyOnSelect = $state(false); @@ -109,27 +108,27 @@ } async function loadRestore() { - if (loadingRestore || savingRestore) return; - loadingRestore = true; + if (restoreBusy) return; + restoreBusy = true; try { restoreEnabled = await sessionRestore.enabled(); } catch (error) { restoreEnabled = null; toast.error(errMsg(error)); } finally { - loadingRestore = false; + restoreBusy = false; } } async function saveRestore(event: Event) { const input = event.currentTarget as HTMLInputElement; - savingRestore = true; + restoreBusy = true; try { await sessionRestore.setEnabled(input.checked); } catch (error) { toast.error(errMsg(error)); } finally { - savingRestore = false; + restoreBusy = false; await loadRestore(); input.checked = restoreEnabled === true; } @@ -194,7 +193,7 @@
diff --git a/src/lib/components/primary-session-window.test.ts b/src/lib/components/primary-session-window.test.ts index e1adf652..7453952c 100644 --- a/src/lib/components/primary-session-window.test.ts +++ b/src/lib/components/primary-session-window.test.ts @@ -11,38 +11,6 @@ function deferred() { } describe("initializePrimarySessionWindow", () => { - it("restores sessions only after a failed reconciliation is retried successfully", async () => { - const reconcile = vi.fn().mockRejectedValueOnce(new Error("backend unavailable")).mockResolvedValueOnce(undefined); - const allowResourcePanes = vi.fn(); - const restoreSessions = vi.fn(async () => true); - const dependencies = { - reconcile, allowResourcePanes, restoreSessions, - loadAutoOpenLocal: async () => false, - openLocal: vi.fn(), - }; - - await expect(initializePrimarySessionWindow(dependencies)).rejects.toThrow("backend unavailable"); - expect(allowResourcePanes).not.toHaveBeenCalled(); - expect(restoreSessions).not.toHaveBeenCalled(); - - await initializePrimarySessionWindow(dependencies); - expect(allowResourcePanes).toHaveBeenCalledOnce(); - expect(restoreSessions).toHaveBeenCalledOnce(); - }); - - it("restores only after reconciliation, including on mobile", async () => { - const events: string[] = []; - await initializePrimarySessionWindow({ - canOpenLocal: false, - reconcile: async () => { events.push("reconcile"); }, - allowResourcePanes: () => events.push("release"), - restoreSessions: async () => { events.push("restore"); return false; }, - loadAutoOpenLocal: async () => { events.push("auto-open"); return true; }, - openLocal: () => events.push("local"), - }); - expect(events).toEqual(["reconcile", "release", "restore"]); - }); - it("does not duplicate a restored local terminal", async () => { const loadAutoOpenLocal = vi.fn(async () => true); const openLocal = vi.fn(); @@ -54,7 +22,19 @@ describe("initializePrimarySessionWindow", () => { expect(openLocal).not.toHaveBeenCalled(); }); - it("releases resource panes before reading and applying auto-open", async () => { + it("propagates unexpected restore failures to the startup error handler", async () => { + const loadAutoOpenLocal = vi.fn(async () => true); + const openLocal = vi.fn(); + await expect(initializePrimarySessionWindow({ + reconcile: async () => {}, allowResourcePanes: () => {}, + restoreSessions: async () => { throw new Error("unexpected restore failure"); }, + loadAutoOpenLocal, openLocal, + })).rejects.toThrow("unexpected restore failure"); + expect(loadAutoOpenLocal).not.toHaveBeenCalled(); + expect(openLocal).not.toHaveBeenCalled(); + }); + + it("releases resource panes before restoring sessions and applying auto-open", async () => { const reconcile = deferred(); const events: string[] = []; const initializing = initializePrimarySessionWindow({ @@ -63,6 +43,7 @@ describe("initializePrimarySessionWindow", () => { return reconcile.promise; }, allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, loadAutoOpenLocal: async () => { events.push("load auto-open"); return true; @@ -78,6 +59,7 @@ describe("initializePrimarySessionWindow", () => { expect(events).toEqual([ "reconcile", "release", + "restore", "load auto-open", "open local", ]); @@ -92,6 +74,7 @@ describe("initializePrimarySessionWindow", () => { throw new Error("backend unavailable"); }, allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, loadAutoOpenLocal: async () => { events.push("load auto-open"); return false; @@ -102,13 +85,14 @@ describe("initializePrimarySessionWindow", () => { expect(events).toEqual(["reconcile"]); }); - it("does not read or apply the desktop local-terminal setting on mobile", async () => { + it("restores sessions without reading or applying auto-open when local PTY is unavailable", async () => { const events: string[] = []; await initializePrimarySessionWindow({ canOpenLocal: false, reconcile: async () => events.push("reconcile"), allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, loadAutoOpenLocal: async () => { events.push("load auto-open"); return true; @@ -116,21 +100,24 @@ describe("initializePrimarySessionWindow", () => { openLocal: () => events.push("open local"), }); - expect(events).toEqual(["reconcile", "release"]); + expect(events).toEqual(["reconcile", "release", "restore"]); }); - it("opens resources on a successful retry after a failed reconciliation", async () => { + it("restores sessions and opens local only after a successful reconciliation retry", async () => { const reconcile = vi.fn() .mockRejectedValueOnce(new Error("close failed")) .mockResolvedValueOnce(1); const allowResourcePanes = vi.fn(); + const restoreSessions = vi.fn(async () => false); const openLocal = vi.fn(); - const dependencies = { reconcile, allowResourcePanes, openLocal, loadAutoOpenLocal: async () => true }; + const dependencies = { reconcile, allowResourcePanes, restoreSessions, openLocal, loadAutoOpenLocal: async () => true }; await expect(initializePrimarySessionWindow(dependencies)).rejects.toThrow("close failed"); expect(allowResourcePanes).not.toHaveBeenCalled(); + expect(restoreSessions).not.toHaveBeenCalled(); expect(openLocal).not.toHaveBeenCalled(); await initializePrimarySessionWindow(dependencies); expect(allowResourcePanes).toHaveBeenCalledTimes(1); + expect(restoreSessions).toHaveBeenCalledTimes(1); expect(openLocal).toHaveBeenCalledTimes(1); }); @@ -142,6 +129,7 @@ describe("initializePrimarySessionWindow", () => { signal: controller.signal, reconcile: () => reconcile.promise, allowResourcePanes: () => events.push("release"), + restoreSessions: async () => { events.push("restore"); return false; }, loadAutoOpenLocal: async () => true, openLocal: () => events.push("open local"), }); @@ -161,6 +149,7 @@ describe("initializePrimarySessionWindow", () => { signal: controller.signal, reconcile: async () => {}, allowResourcePanes: () => events.push("release"), + restoreSessions: async () => false, loadAutoOpenLocal: () => autoOpen.promise, openLocal: () => events.push("open local"), }); diff --git a/src/lib/components/primary-session-window.ts b/src/lib/components/primary-session-window.ts index cf84095b..a10166f3 100644 --- a/src/lib/components/primary-session-window.ts +++ b/src/lib/components/primary-session-window.ts @@ -3,7 +3,7 @@ interface PrimarySessionWindowDependencies { canOpenLocal?: boolean; reconcile: () => Promise; allowResourcePanes: () => void; - restoreSessions?: () => Promise; + restoreSessions: () => Promise; loadAutoOpenLocal: () => Promise; openLocal: () => void; } @@ -15,12 +15,7 @@ export async function initializePrimarySessionWindow( await dependencies.reconcile(); if (dependencies.signal?.aborted) return; dependencies.allowResourcePanes(); - let restoredLocal = false; - try { - restoredLocal = await dependencies.restoreSessions?.() ?? false; - } catch { - // A failed restore must not prevent the existing startup preference. - } + const restoredLocal = await dependencies.restoreSessions(); if (dependencies.signal?.aborted || restoredLocal) return; if (dependencies.canOpenLocal === false) return; diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts index 0716e4d3..f493ea6a 100644 --- a/src/lib/stores/session-restore.svelte.test.ts +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -97,6 +97,36 @@ describe("session restoration", () => { expect(settings.get(`${key}.snapshot`)).toBe(""); }); + it("records existing terminal tabs without filtering them by host capabilities", async () => { + const { app, restore } = await modules(); + app.addTab({ id: "local", type: "local", label: "Local" }); + app.addTab({ id: "serial", type: "serial", label: "Serial", meta: { serialProfileId: "serial1" } }); + capabilities.localPty = false; + capabilities.serial = false; + + expect(JSON.parse(restore.snapshot())).toEqual({ version: 1, terminals: [ + { type: "local" }, { type: "serial", profileId: "serial1" }, + ], active: 1 }); + }); + + it("round-trips more than 100 tabs without losing the active terminal", async () => { + const { app, restore } = await modules(); + for (let index = 0; index < 101; index++) { + app.addTab({ id: `local:${index}`, type: "local", label: "Local" }); + } + const snapshot = restore.snapshot(); + expect(JSON.parse(snapshot).terminals).toHaveLength(101); + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, snapshot); + + vi.resetModules(); + const restarted = await modules(); + expect(await restarted.restore.restore()).toBe(true); + expect(restarted.app.tabs()).toHaveLength(102); + expect(restarted.app.activeTabId()).toBe(restarted.app.tabs()[101].id); + expect(restarted.restore.snapshot()).toBe(snapshot); + }); + it.each(["bad JSON", '{"version":2,"terminals":[]}', '{"version":1,"terminals":[{"type":"ssh"}]}']) ("handles invalid snapshots: %s", async (snapshot) => { settings.set(`${key}.enabled`, "true"); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts index 46b6d511..7435d373 100644 --- a/src/lib/stores/session-restore.svelte.ts +++ b/src/lib/stores/session-restore.svelte.ts @@ -38,9 +38,8 @@ export async function setEnabled(value: boolean) { } function reference(tab: app.Tab): SavedTerminal | null { - if (tab.type === "local") return app.capabilities().localPty ? { type: "local" } : null; + if (tab.type === "local") return { type: "local" }; if (tab.type !== "ssh" && tab.type !== "telnet" && tab.type !== "serial") return null; - if (tab.type === "serial" && !app.capabilities().serial) return null; const profileId = tab.type === "serial" ? tab.meta?.serialProfileId : tab.meta?.profileId; return profileId ? { type: tab.type, profileId } : null; } @@ -50,7 +49,7 @@ export function snapshot(): string { let active = -1; for (const tab of app.tabs()) { const saved = reference(tab); - if (!saved || terminals.length >= 100) continue; + if (!saved) continue; if (tab.id === app.activeTabId()) active = terminals.length; terminals.push(saved); } @@ -71,7 +70,7 @@ function parse(raw: string | null): Snapshot | null { if (!raw) return null; const data = JSON.parse(raw); if (data?.version !== 1 || !Array.isArray(data.terminals)) return null; - const terminals = data.terminals.slice(0, 100).map((value: unknown): SavedTerminal | null => { + const terminals = data.terminals.map((value: unknown): SavedTerminal | null => { if (!value || typeof value !== "object") return null; const entry = value as Record; if (entry.type === "local") return { type: "local" }; From 409b2fde29eecd83d29b4eec7ad42fd0fc934d45 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 22:34:39 +0800 Subject: [PATCH 5/8] fix(sessions): restore only root terminal tabs Save workspace roots instead of flattening split panes into extra tabs. Track the active workspace so a focused child pane restores focus to its root terminal. --- src/lib/i18n/locales/en.ts | 2 +- src/lib/i18n/locales/zh.ts | 2 +- src/lib/stores/session-restore.svelte.test.ts | 28 +++++++++++++++++-- src/lib/stores/session-restore.svelte.ts | 4 +-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f15ecfd2..2a709b23 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1237,7 +1237,7 @@ const en = { "settings.shell.custom": "CUSTOM", "settings.shell.open_local_on_startup": "Open on startup", "settings.shell.restore_sessions": "Restore sessions on startup", - "settings.shell.restore_sessions_desc": "Reopen saved SSH, Telnet and serial connections and local terminals from this device's last session. Connections start fresh using current profiles; startup commands may run again. Split panes reopen as separate tabs. Terminal output, editors, forwards and discovered containers are not restored. Off by default.", + "settings.shell.restore_sessions_desc": "Reopen saved SSH, Telnet and serial connections and local terminals from this device's last session. Connections start fresh using current profiles; startup commands may run again. Only the root terminal of each tab is restored; split panes are not restored. Terminal output, editors, forwards and discovered containers are not restored. Off by default.", "settings.shell.open_local_on_startup_desc": "When rssh starts, automatically open one local terminal tab.", "settings.shell.connection_timeout": "CONNECTION TIMEOUT", "settings.shell.timeout_label": "Timeout (seconds)", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index aee8d79a..01d1b2b3 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -1240,7 +1240,7 @@ const zh: Messages = { "settings.shell.custom": "自定义", "settings.shell.open_local_on_startup": "启动时打开", "settings.shell.restore_sessions": "启动时恢复会话", - "settings.shell.restore_sessions_desc": "重新打开本机上次的已保存 SSH、Telnet、串口连接和本地终端。使用当前配置建立新连接,启动命令可能再次执行;分屏恢复为独立标签页。不恢复终端输出、编辑器、端口转发或动态发现的容器。默认关闭。", + "settings.shell.restore_sessions_desc": "重新打开本机上次的已保存 SSH、Telnet、串口连接和本地终端。使用当前配置建立新连接,启动命令可能再次执行;每个标签页只恢复根终端,不恢复分屏。不恢复终端输出、编辑器、端口转发或动态发现的容器。默认关闭。", "settings.shell.open_local_on_startup_desc": "rssh 启动时自动打开一个本地终端标签页。", "settings.shell.connection_timeout": "连接超时", "settings.shell.timeout_label": "超时(秒)", diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/session-restore.svelte.test.ts index f493ea6a..eb0b7ee5 100644 --- a/src/lib/stores/session-restore.svelte.test.ts +++ b/src/lib/stores/session-restore.svelte.test.ts @@ -69,7 +69,7 @@ describe("session restoration", () => { expect(app.tabs()[4].meta).not.toHaveProperty("login_script"); }); - it("persists only profile references, including split panes, without secret metadata or unsupported tabs", async () => { + it("persists only root profile references without split panes, secret metadata or unsupported tabs", async () => { const { app, restore } = await modules(); app.addTab({ id: "ssh", type: "ssh", label: "Secret label", meta: { profileId: "ssh1", password: "secret", host: "host" } }); app.addPane("ssh", "right", { id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1", login_script: "secret" } }); @@ -81,12 +81,34 @@ describe("session restoration", () => { app.setActivePane("telnet"); const snapshot = restore.snapshot(); expect(JSON.parse(snapshot)).toEqual({ version: 1, terminals: [ - { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, + { type: "ssh", profileId: "ssh1" }, { type: "serial", profileId: "serial1" }, - ], active: 1 }); + ], active: 0 }); expect(snapshot).not.toContain("secret"); }); + it("restores only workspace roots and selects the root of the focused split pane", async () => { + const { app, restore } = await modules(); + app.addTab({ id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1" } }); + app.addTab({ id: "ssh", type: "ssh", label: "SSH", meta: { profileId: "ssh1" } }); + app.addPane("ssh", "right", { id: "split-local", type: "local", label: "Local pane" }); + app.addPane("ssh", "bottom", { id: "split-telnet", type: "telnet", label: "Telnet pane", meta: { profileId: "telnet1" } }); + app.addTab({ id: "local", type: "local", label: "Local" }); + app.setActiveWorkspace("ssh"); + app.setActivePane("split-telnet"); + settings.set(`${key}.enabled`, "true"); + settings.set(`${key}.snapshot`, restore.snapshot()); + + vi.resetModules(); + const restarted = await modules(); + await restarted.restore.restore(); + expect(restarted.app.tabs().map((tab) => tab.type)).toEqual(["home", "telnet", "ssh", "local"]); + expect(restarted.app.activeTabId()).toBe(restarted.app.tabs()[2].id); + for (const tab of restarted.app.workspaceTabs()) { + expect(restarted.app.paneIdsForWorkspace(tab.id)).toEqual([tab.id]); + } + }); + it("clears saved sessions when disabled and does not persist while disabled", async () => { saved([{ type: "local" }]); const { restore } = await modules(); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/session-restore.svelte.ts index 7435d373..a9d93911 100644 --- a/src/lib/stores/session-restore.svelte.ts +++ b/src/lib/stores/session-restore.svelte.ts @@ -47,10 +47,10 @@ function reference(tab: app.Tab): SavedTerminal | null { export function snapshot(): string { const terminals: SavedTerminal[] = []; let active = -1; - for (const tab of app.tabs()) { + for (const tab of app.workspaceTabs()) { const saved = reference(tab); if (!saved) continue; - if (tab.id === app.activeTabId()) active = terminals.length; + if (tab.id === app.activeWorkspaceId()) active = terminals.length; terminals.push(saved); } return JSON.stringify({ version: 1, terminals, active } satisfies Snapshot); From e9ad2cc2f1755900b1eb61e21dcadad84ba8e3d6 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 23:24:15 +0800 Subject: [PATCH 6/8] feat(settings): unify startup actions and migrate legacy preferences --- src-tauri/src/db/schema.rs | 194 +++++++++++++- src/lib/components/AppShell.svelte | 12 +- src/lib/components/ShellSettings.svelte | 82 +++--- .../components/primary-session-window.test.ts | 156 ++++------- src/lib/components/primary-session-window.ts | 19 +- src/lib/i18n/locales/en.ts | 9 +- src/lib/i18n/locales/zh.ts | 9 +- ....svelte.test.ts => startup.svelte.test.ts} | 252 +++++++++++------- ...on-restore.svelte.ts => startup.svelte.ts} | 50 ++-- 9 files changed, 478 insertions(+), 305 deletions(-) rename src/lib/stores/{session-restore.svelte.test.ts => startup.svelte.test.ts} (62%) rename src/lib/stores/{session-restore.svelte.ts => startup.svelte.ts} (75%) 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 1f29e987..f2debd5c 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -4,7 +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 sessionRestore from "../stores/session-restore.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"; @@ -88,13 +88,9 @@ try { await initializePrimarySessionWindow({ signal: startup.signal, - canOpenLocal: app.capabilities().localPty, reconcile: () => invoke("reconcile_sessions", { activeIds: [] }), allowResourcePanes: () => { resourcePanesAllowed = true; }, - restoreSessions: () => sessionRestore.restore(startup.signal), - 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); @@ -104,8 +100,8 @@ } let navigationLoad = 0; function persistSessionSnapshot() { - if (bypassStartupReconcile || !sessionRestore.ready()) return; - void sessionRestore.persist(sessionRestore.snapshot()); + if (bypassStartupReconcile || !startupSettings.ready()) return; + void startupSettings.persist(startupSettings.snapshot()); } $effect(persistSessionSnapshot); $effect(() => { diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index 504877a9..cbe87ff6 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -2,7 +2,8 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import * as app from "../stores/app.svelte.ts"; - import * as sessionRestore from "../stores/session-restore.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, errMsg } from "../i18n/index.svelte.ts"; @@ -17,10 +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 restoreEnabled = $state(null); - let restoreBusy = $state(false); + let startupAction = $state(null); + let startupBusy = $state(false); let verboseLog = $state(true); let connectTimeout = $state(10); let copyOnSelect = $state(false); @@ -34,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()); @@ -43,16 +47,13 @@ * pendingCustom(点了 Custom 没填)或 selectedShell 不在 shells 里都算 custom。 */ let customMode = $derived(pendingCustom || (selectedShell !== "" && !shells.includes(selectedShell))); - onMount(() => { void loadRestore(); }); - 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" }); @@ -103,34 +104,30 @@ 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 loadRestore() { - if (restoreBusy) return; - restoreBusy = true; + async function loadStartup() { + if (startupBusy) return; + startupBusy = true; try { - restoreEnabled = await sessionRestore.enabled(); + const [action, availableShells] = await Promise.all([startup.action(), startup.availableShells()]); + shells = availableShells; + startupAction = action === "local" && !shells.length ? "home" : action; } catch (error) { - restoreEnabled = null; + startupAction = null; toast.error(errMsg(error)); } finally { - restoreBusy = false; + startupBusy = false; } } - async function saveRestore(event: Event) { - const input = event.currentTarget as HTMLInputElement; - restoreBusy = true; + async function saveStartup(action: StartupAction) { + startupBusy = true; try { - await sessionRestore.setEnabled(input.checked); + await startup.setAction(action); } catch (error) { toast.error(errMsg(error)); } finally { - restoreBusy = false; - await loadRestore(); - input.checked = restoreEnabled === true; + startupBusy = false; + await loadStartup(); } } @@ -183,19 +180,18 @@ } - +
-
-
-
{t("settings.shell.restore_sessions")}
-
{t("settings.shell.restore_sessions_desc")}
-
- +
+ + - - -
{/if} @@ -362,6 +347,7 @@ /* 卡片:复用全局 .card.surface-raised,本地只加 padding + 内布局, 跟 SyncScreen / AiSettings 同款。 */ + .startup-card, .shell-card, .mouse-card { padding: 18px; diff --git a/src/lib/components/primary-session-window.test.ts b/src/lib/components/primary-session-window.test.ts index 7453952c..80d307af 100644 --- a/src/lib/components/primary-session-window.test.ts +++ b/src/lib/components/primary-session-window.test.ts @@ -11,31 +11,9 @@ function deferred() { } describe("initializePrimarySessionWindow", () => { - it("does not duplicate a restored local terminal", async () => { - const loadAutoOpenLocal = vi.fn(async () => true); - const openLocal = vi.fn(); - await initializePrimarySessionWindow({ - reconcile: async () => {}, allowResourcePanes: () => {}, - restoreSessions: async () => true, loadAutoOpenLocal, openLocal, - }); - expect(loadAutoOpenLocal).not.toHaveBeenCalled(); - expect(openLocal).not.toHaveBeenCalled(); - }); - - it("propagates unexpected restore failures to the startup error handler", async () => { - const loadAutoOpenLocal = vi.fn(async () => true); - const openLocal = vi.fn(); - await expect(initializePrimarySessionWindow({ - reconcile: async () => {}, allowResourcePanes: () => {}, - restoreSessions: async () => { throw new Error("unexpected restore failure"); }, - loadAutoOpenLocal, openLocal, - })).rejects.toThrow("unexpected restore failure"); - expect(loadAutoOpenLocal).not.toHaveBeenCalled(); - expect(openLocal).not.toHaveBeenCalled(); - }); - - it("releases resource panes before restoring sessions and applying auto-open", async () => { + it("reconciles before releasing resource panes and awaiting the startup action", async () => { const reconcile = deferred(); + const startupAction = deferred(); const events: string[] = []; const initializing = initializePrimarySessionWindow({ reconcile: () => { @@ -43,122 +21,82 @@ describe("initializePrimarySessionWindow", () => { return reconcile.promise; }, allowResourcePanes: () => events.push("release"), - restoreSessions: async () => { events.push("restore"); return false; }, - loadAutoOpenLocal: async () => { - events.push("load auto-open"); - return true; + runStartupAction: () => { + events.push("startup"); + return startupAction.promise; }, - openLocal: () => events.push("open local"), - }); + }).then(() => events.push("complete")); await Promise.resolve(); expect(events).toEqual(["reconcile"]); reconcile.resolve(); + await vi.waitFor(() => expect(events).toEqual(["reconcile", "release", "startup"])); + startupAction.resolve(); await initializing; - expect(events).toEqual([ - "reconcile", - "release", - "restore", - "load auto-open", - "open local", - ]); + expect(events).toEqual(["reconcile", "release", "startup", "complete"]); }); - it("keeps resource panes closed when reconciliation fails so the owner can retry", async () => { - const events: string[] = []; - - await expect(initializePrimarySessionWindow({ - reconcile: async () => { - events.push("reconcile"); - throw new Error("backend unavailable"); - }, - allowResourcePanes: () => events.push("release"), - restoreSessions: async () => { events.push("restore"); return false; }, - loadAutoOpenLocal: async () => { - events.push("load auto-open"); - return false; - }, - openLocal: () => events.push("open local"), - })).rejects.toThrow("backend unavailable"); - - expect(events).toEqual(["reconcile"]); - }); - - it("restores sessions without reading or applying auto-open when local PTY is unavailable", async () => { - const events: string[] = []; - - await initializePrimarySessionWindow({ - canOpenLocal: false, - reconcile: async () => events.push("reconcile"), - allowResourcePanes: () => events.push("release"), - restoreSessions: async () => { events.push("restore"); return false; }, - loadAutoOpenLocal: async () => { - events.push("load auto-open"); - return true; - }, - openLocal: () => events.push("open local"), - }); - - expect(events).toEqual(["reconcile", "release", "restore"]); - }); - - it("restores sessions and opens local only after a successful reconciliation retry", async () => { + it("releases resources and runs startup only after a successful reconciliation retry", async () => { const reconcile = vi.fn() .mockRejectedValueOnce(new Error("close failed")) .mockResolvedValueOnce(1); const allowResourcePanes = vi.fn(); - const restoreSessions = vi.fn(async () => false); - const openLocal = vi.fn(); - const dependencies = { reconcile, allowResourcePanes, restoreSessions, openLocal, loadAutoOpenLocal: async () => true }; + const runStartupAction = vi.fn(async () => {}); + const dependencies = { reconcile, allowResourcePanes, runStartupAction }; + await expect(initializePrimarySessionWindow(dependencies)).rejects.toThrow("close failed"); expect(allowResourcePanes).not.toHaveBeenCalled(); - expect(restoreSessions).not.toHaveBeenCalled(); - expect(openLocal).not.toHaveBeenCalled(); + expect(runStartupAction).not.toHaveBeenCalled(); + await initializePrimarySessionWindow(dependencies); - expect(allowResourcePanes).toHaveBeenCalledTimes(1); - expect(restoreSessions).toHaveBeenCalledTimes(1); - expect(openLocal).toHaveBeenCalledTimes(1); + expect(allowResourcePanes).toHaveBeenCalledOnce(); + expect(runStartupAction).toHaveBeenCalledOnce(); }); - it("does nothing after its owner is cancelled", async () => { - const reconcile = deferred(); + it("propagates unexpected startup action failures to the startup error handler", async () => { + const allowResourcePanes = vi.fn(); + await expect(initializePrimarySessionWindow({ + reconcile: async () => {}, + allowResourcePanes, + runStartupAction: async () => { throw new Error("unexpected startup failure"); }, + })).rejects.toThrow("unexpected startup failure"); + expect(allowResourcePanes).toHaveBeenCalledOnce(); + }); + + it("does nothing when its owner is already cancelled", async () => { const controller = new AbortController(); - const events: string[] = []; - const initializing = initializePrimarySessionWindow({ + controller.abort(); + const reconcile = vi.fn(async () => {}); + const allowResourcePanes = vi.fn(); + const runStartupAction = vi.fn(async () => {}); + + await initializePrimarySessionWindow({ signal: controller.signal, - reconcile: () => reconcile.promise, - allowResourcePanes: () => events.push("release"), - restoreSessions: async () => { events.push("restore"); return false; }, - loadAutoOpenLocal: async () => true, - openLocal: () => events.push("open local"), + reconcile, allowResourcePanes, runStartupAction, }); - controller.abort(); - reconcile.resolve(); - await initializing; - - expect(events).toEqual([]); + expect(reconcile).not.toHaveBeenCalled(); + expect(allowResourcePanes).not.toHaveBeenCalled(); + expect(runStartupAction).not.toHaveBeenCalled(); }); - it("does not auto-open after cancellation during the settings read", async () => { - const autoOpen = deferred(); + it("does not release resources or run startup after cancellation during reconciliation", async () => { + const reconcile = deferred(); const controller = new AbortController(); - const events: string[] = []; + const allowResourcePanes = vi.fn(); + const runStartupAction = vi.fn(async () => {}); const initializing = initializePrimarySessionWindow({ signal: controller.signal, - reconcile: async () => {}, - allowResourcePanes: () => events.push("release"), - restoreSessions: async () => false, - loadAutoOpenLocal: () => autoOpen.promise, - openLocal: () => events.push("open local"), + reconcile: () => reconcile.promise, + allowResourcePanes, runStartupAction, }); - await vi.waitFor(() => expect(events).toEqual(["release"])); controller.abort(); - autoOpen.resolve(true); + reconcile.resolve(); await initializing; - expect(events).toEqual(["release"]); + expect(allowResourcePanes).not.toHaveBeenCalled(); + expect(runStartupAction).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/components/primary-session-window.ts b/src/lib/components/primary-session-window.ts index a10166f3..b5e6d6ef 100644 --- a/src/lib/components/primary-session-window.ts +++ b/src/lib/components/primary-session-window.ts @@ -1,11 +1,8 @@ interface PrimarySessionWindowDependencies { signal?: AbortSignal; - canOpenLocal?: boolean; reconcile: () => Promise; allowResourcePanes: () => void; - restoreSessions: () => Promise; - loadAutoOpenLocal: () => Promise; - openLocal: () => void; + runStartupAction: () => Promise; } export async function initializePrimarySessionWindow( @@ -15,17 +12,5 @@ export async function initializePrimarySessionWindow( await dependencies.reconcile(); if (dependencies.signal?.aborted) return; dependencies.allowResourcePanes(); - const restoredLocal = await dependencies.restoreSessions(); - if (dependencies.signal?.aborted || restoredLocal) return; - if (dependencies.canOpenLocal === false) return; - - try { - const autoOpen = await dependencies.loadAutoOpenLocal(); - if (!dependencies.signal?.aborted && autoOpen) { - dependencies.openLocal(); - } - } catch { - // Keep the existing fail-closed auto-open behavior when settings are - // unavailable; the resource barrier has already been released above. - } + await dependencies.runStartupAction(); } diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 2a709b23..f8098c72 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1235,10 +1235,11 @@ const en = { "settings.shell.custom_placeholder": "/usr/local/bin/fish", "settings.shell.local_shell": "LOCAL SHELL", "settings.shell.custom": "CUSTOM", - "settings.shell.open_local_on_startup": "Open on startup", - "settings.shell.restore_sessions": "Restore sessions on startup", - "settings.shell.restore_sessions_desc": "Reopen saved SSH, Telnet and serial connections and local terminals from this device's last session. Connections start fresh using current profiles; startup commands may run again. Only the root terminal of each tab is restored; split panes are not restored. Terminal output, editors, forwards and discovered containers are not restored. Off by default.", - "settings.shell.open_local_on_startup_desc": "When rssh starts, automatically open one local terminal tab.", + "settings.shell.startup_action": "Startup action", + "settings.shell.startup_restore": "Restore previous sessions", + "settings.shell.startup_local": "Open a local shell", + "settings.shell.startup_home": "Open Home page", + "settings.shell.startup_restore_desc": "Reopen saved SSH, Telnet and serial connections and local terminals using current profiles. Startup commands may run again. Only the root terminal of each tab is restored; split panes, terminal output, editors, forwards and discovered containers are not restored.", "settings.shell.connection_timeout": "CONNECTION TIMEOUT", "settings.shell.timeout_label": "Timeout (seconds)", "settings.shell.timeout_hint": "1–300s, default 10s", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 01d1b2b3..16ae00e7 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -1238,10 +1238,11 @@ const zh: Messages = { "settings.shell.custom_placeholder": "/usr/local/bin/fish", "settings.shell.local_shell": "本地 SHELL", "settings.shell.custom": "自定义", - "settings.shell.open_local_on_startup": "启动时打开", - "settings.shell.restore_sessions": "启动时恢复会话", - "settings.shell.restore_sessions_desc": "重新打开本机上次的已保存 SSH、Telnet、串口连接和本地终端。使用当前配置建立新连接,启动命令可能再次执行;每个标签页只恢复根终端,不恢复分屏。不恢复终端输出、编辑器、端口转发或动态发现的容器。默认关闭。", - "settings.shell.open_local_on_startup_desc": "rssh 启动时自动打开一个本地终端标签页。", + "settings.shell.startup_action": "启动时动作", + "settings.shell.startup_restore": "恢复关闭前的会话", + "settings.shell.startup_local": "打开本地 Shell", + "settings.shell.startup_home": "打开 Home 页", + "settings.shell.startup_restore_desc": "使用当前配置重新打开已保存的 SSH、Telnet、串口连接和本地终端,启动命令可能再次执行。每个标签页只恢复根终端,不恢复分屏、终端输出、编辑器、端口转发或动态发现的容器。", "settings.shell.connection_timeout": "连接超时", "settings.shell.timeout_label": "超时(秒)", "settings.shell.timeout_hint": "1–300 秒,默认 10 秒", diff --git a/src/lib/stores/session-restore.svelte.test.ts b/src/lib/stores/startup.svelte.test.ts similarity index 62% rename from src/lib/stores/session-restore.svelte.test.ts rename to src/lib/stores/startup.svelte.test.ts index eb0b7ee5..6de9346b 100644 --- a/src/lib/stores/session-restore.svelte.test.ts +++ b/src/lib/stores/startup.svelte.test.ts @@ -5,6 +5,7 @@ const capabilities = vi.hoisted(() => ({ localPty: true, serial: true })); vi.mock("./runtime.svelte.ts", () => ({ capabilities: () => capabilities })); vi.mock("@tauri-apps/api/core", () => ({ invoke })); const key = "session_restore"; +const actionKey = "startup_action"; let settings: Map; beforeEach(() => { @@ -26,16 +27,17 @@ beforeEach(() => { if (command === "list_profiles") return [{ id: "ssh1", name: "Current SSH", host: "new-host", port: 2222 }]; if (command === "list_telnet_profiles") return [{ id: "telnet1", name: "Telnet", host: "telnet", port: 23, login_script: "secret" }]; if (command === "list_serial_profiles") return []; + if (command === "list_shells") return ["/bin/sh"]; return null; }); }); afterEach(() => vi.unstubAllGlobals()); async function modules() { - return { app: await import("./app.svelte.ts"), restore: await import("./session-restore.svelte.ts") }; + return { app: await import("./app.svelte.ts"), startup: await import("./startup.svelte.ts") }; } function saved(terminals: unknown[], active = -1) { - settings.set(`${key}.enabled`, "true"); + settings.set(actionKey, "restore"); settings.set(`${key}.snapshot`, JSON.stringify({ version: 1, terminals, active })); } function deferred() { @@ -44,12 +46,80 @@ function deferred() { return { promise, resolve }; } -describe("session restoration", () => { - it("defaults off without fetching profiles or reopening tabs", async () => { - const { app, restore } = await modules(); - expect(await restore.restore()).toBe(false); - expect(await restore.enabled()).toBe(false); - expect(restore.ready()).toBe(true); +describe("startup actions", () => { + it.each(["home", "local"] as const)("uses %s instead of restoring the saved snapshot", async (action) => { + saved([{ type: "ssh", profileId: "ssh1" }]); + settings.set(actionKey, action); + const { app, startup } = await modules(); + await startup.run(); + expect(app.tabs().map((tab) => tab.type)).toEqual(action === "local" ? ["home", "local"] : ["home"]); + expect(invoke).not.toHaveBeenCalledWith("list_profiles"); + expect(startup.ready()).toBe(true); + }); + + it("keeps Home when no shell was detected", async () => { + settings.set(actionKey, "local"); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation((command, args) => command === "list_shells" + ? Promise.resolve([]) : implementation(command, args)); + const { app, startup } = await modules(); + expect(await startup.availableShells()).toEqual([]); + await startup.run(); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); + expect(settings.get(actionKey)).toBe("local"); + }); + + it("does not query shells or open local tabs without PTY support", async () => { + capabilities.localPty = false; + settings.set(actionKey, "local"); + const { app, startup } = await modules(); + expect(await startup.availableShells()).toEqual([]); + await startup.run(); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); + expect(invoke).not.toHaveBeenCalledWith("list_shells"); + }); + + it.each(["cancel", "home", "manual"])("does not open a shell after %s during discovery", async (change) => { + settings.set(actionKey, "local"); + const blocked = deferred(); + const implementation = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => { + if (command === "list_shells") await blocked.promise; + return implementation(command, args); + }); + const { app, startup } = await modules(); + const controller = new AbortController(); + const running = startup.run(controller.signal); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("list_shells")); + if (change === "cancel") controller.abort(); + if (change === "home") settings.set(actionKey, "home"); + if (change === "manual") app.addTab({ id: "manual", type: "local", label: "Local" }); + blocked.resolve(); + await running; + expect(app.tabs().map((tab) => tab.id)).toEqual(change === "manual" ? ["home", "manual"] : ["home"]); + }); + + it("does not fall back to a local shell when restoring an empty snapshot", async () => { + settings.set(actionKey, "restore"); + const { app, startup } = await modules(); + await startup.run(); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); + expect(invoke).not.toHaveBeenCalledWith("list_shells"); + }); + + it("treats an unknown startup action as Home", async () => { + settings.set(actionKey, "unknown"); + const { app, startup } = await modules(); + expect(await startup.action()).toBe("home"); + await startup.run(); + expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); + }); + + it("defaults to Home without fetching profiles or reopening tabs", async () => { + const { app, startup } = await modules(); + await startup.run(); + expect(await startup.action()).toBe("home"); + expect(startup.ready()).toBe(true); expect(app.tabs()).toHaveLength(1); expect(invoke).not.toHaveBeenCalledWith("list_profiles"); }); @@ -60,8 +130,8 @@ describe("session restoration", () => { { type: "local" }, { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, ], 3); - const { app, restore } = await modules(); - expect(await restore.restore()).toBe(true); + const { app, startup } = await modules(); + await startup.run(); expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local", "ssh", "telnet"]); expect(new Set(app.tabs().map((tab) => tab.id)).size).toBe(5); expect(app.activeTabId()).toBe(app.tabs()[3].id); @@ -70,7 +140,7 @@ describe("session restoration", () => { }); it("persists only root profile references without split panes, secret metadata or unsupported tabs", async () => { - const { app, restore } = await modules(); + const { app, startup } = await modules(); app.addTab({ id: "ssh", type: "ssh", label: "Secret label", meta: { profileId: "ssh1", password: "secret", host: "host" } }); app.addPane("ssh", "right", { id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1", login_script: "secret" } }); app.addTab({ id: "serial", type: "serial", label: "Serial", meta: { serialProfileId: "serial1", login_script: "secret" } }); @@ -79,7 +149,7 @@ describe("session restoration", () => { app.addTab({ id: "editor", type: "edit", label: "Editor", meta: { content: "secret" } }); app.setActiveWorkspace("ssh"); app.setActivePane("telnet"); - const snapshot = restore.snapshot(); + const snapshot = startup.snapshot(); expect(JSON.parse(snapshot)).toEqual({ version: 1, terminals: [ { type: "ssh", profileId: "ssh1" }, { type: "serial", profileId: "serial1" }, @@ -88,7 +158,7 @@ describe("session restoration", () => { }); it("restores only workspace roots and selects the root of the focused split pane", async () => { - const { app, restore } = await modules(); + const { app, startup } = await modules(); app.addTab({ id: "telnet", type: "telnet", label: "Telnet", meta: { profileId: "telnet1" } }); app.addTab({ id: "ssh", type: "ssh", label: "SSH", meta: { profileId: "ssh1" } }); app.addPane("ssh", "right", { id: "split-local", type: "local", label: "Local pane" }); @@ -96,12 +166,12 @@ describe("session restoration", () => { app.addTab({ id: "local", type: "local", label: "Local" }); app.setActiveWorkspace("ssh"); app.setActivePane("split-telnet"); - settings.set(`${key}.enabled`, "true"); - settings.set(`${key}.snapshot`, restore.snapshot()); + settings.set(actionKey, "restore"); + settings.set(`${key}.snapshot`, startup.snapshot()); vi.resetModules(); const restarted = await modules(); - await restarted.restore.restore(); + await restarted.startup.run(); expect(restarted.app.tabs().map((tab) => tab.type)).toEqual(["home", "telnet", "ssh", "local"]); expect(restarted.app.activeTabId()).toBe(restarted.app.tabs()[2].id); for (const tab of restarted.app.workspaceTabs()) { @@ -109,52 +179,52 @@ describe("session restoration", () => { } }); - it("clears saved sessions when disabled and does not persist while disabled", async () => { + it.each(["home", "local"] as const)("clears saved sessions when %s is selected and does not persist", async (action) => { saved([{ type: "local" }]); - const { restore } = await modules(); - await restore.restore(); - await restore.setEnabled(false); - await restore.persist(restore.snapshot()); - expect(settings.get(`${key}.enabled`)).toBe("false"); + const { startup } = await modules(); + await startup.run(); + await startup.setAction(action); + await startup.persist(startup.snapshot()); + expect(settings.get(actionKey)).toBe(action); expect(settings.get(`${key}.snapshot`)).toBe(""); }); it("records existing terminal tabs without filtering them by host capabilities", async () => { - const { app, restore } = await modules(); + const { app, startup } = await modules(); app.addTab({ id: "local", type: "local", label: "Local" }); app.addTab({ id: "serial", type: "serial", label: "Serial", meta: { serialProfileId: "serial1" } }); capabilities.localPty = false; capabilities.serial = false; - expect(JSON.parse(restore.snapshot())).toEqual({ version: 1, terminals: [ + expect(JSON.parse(startup.snapshot())).toEqual({ version: 1, terminals: [ { type: "local" }, { type: "serial", profileId: "serial1" }, ], active: 1 }); }); it("round-trips more than 100 tabs without losing the active terminal", async () => { - const { app, restore } = await modules(); + const { app, startup } = await modules(); for (let index = 0; index < 101; index++) { app.addTab({ id: `local:${index}`, type: "local", label: "Local" }); } - const snapshot = restore.snapshot(); + const snapshot = startup.snapshot(); expect(JSON.parse(snapshot).terminals).toHaveLength(101); - settings.set(`${key}.enabled`, "true"); + settings.set(actionKey, "restore"); settings.set(`${key}.snapshot`, snapshot); vi.resetModules(); const restarted = await modules(); - expect(await restarted.restore.restore()).toBe(true); + await restarted.startup.run(); expect(restarted.app.tabs()).toHaveLength(102); expect(restarted.app.activeTabId()).toBe(restarted.app.tabs()[101].id); - expect(restarted.restore.snapshot()).toBe(snapshot); + expect(restarted.startup.snapshot()).toBe(snapshot); }); it.each(["bad JSON", '{"version":2,"terminals":[]}', '{"version":1,"terminals":[{"type":"ssh"}]}']) ("handles invalid snapshots: %s", async (snapshot) => { - settings.set(`${key}.enabled`, "true"); + settings.set(actionKey, "restore"); settings.set(`${key}.snapshot`, snapshot); - const { app, restore } = await modules(); - expect(await restore.restore()).toBe(false); + const { app, startup } = await modules(); + await startup.run(); expect(app.tabs()).toHaveLength(1); }); @@ -164,10 +234,10 @@ describe("session restoration", () => { const implementation = invoke.getMockImplementation()!; invoke.mockImplementation((command, args) => command === "list_profiles" ? Promise.reject(new Error("offline")) : implementation(command, args)); - const { restore } = await modules(); - await restore.restore(); - expect(restore.ready()).toBe(false); - await restore.persist(restore.snapshot()); + const { startup } = await modules(); + await startup.run(); + expect(startup.ready()).toBe(false); + await startup.persist(startup.snapshot()); expect(settings.get(`${key}.snapshot`)).toBe(original); }); @@ -176,89 +246,89 @@ describe("session restoration", () => { const original = settings.get(`${key}.snapshot`); const controller = new AbortController(); controller.abort(); - const { app, restore } = await modules(); - expect(await restore.restore(controller.signal)).toBe(false); + const { app, startup } = await modules(); + await startup.run(controller.signal); expect(app.tabs()).toHaveLength(1); - expect(restore.ready()).toBe(false); - await restore.persist(restore.snapshot()); + expect(startup.ready()).toBe(false); + await startup.persist(startup.snapshot()); expect(settings.get(`${key}.snapshot`)).toBe(original); }); it("does not reopen saved connections when disabled during profile loading", async () => { saved([{ type: "ssh", profileId: "ssh1" }]); - const { app, restore } = await modules(); + const { app, startup } = await modules(); const blocked = deferred(); const implementation = invoke.getMockImplementation()!; invoke.mockImplementation(async (command, args) => { if (command === "list_profiles") await blocked.promise; return implementation(command, args); }); - const startup = restore.restore(); + const running = startup.run(); await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("list_profiles")); - await restore.setEnabled(false); + await startup.setAction("home"); blocked.resolve(); - expect(await startup).toBe(false); + await running; expect(app.tabs().map((tab) => tab.id)).toEqual(["home"]); expect(settings.get(`${key}.snapshot`)).toBe(""); }); it("does not take over connections opened manually during startup", async () => { saved([{ type: "local" }]); - const { app, restore } = await modules(); + const { app, startup } = await modules(); app.addTab({ id: "manual", type: "local", label: "Manual" }); - expect(await restore.restore()).toBe(false); + await startup.run(); expect(app.tabs().map((tab) => tab.id)).toEqual(["home", "manual"]); }); it("serializes snapshots so the latest write wins", async () => { - const { restore } = await modules(); - await restore.restore(); - await restore.setEnabled(true); - await Promise.all([restore.persist("first"), restore.persist("second")]); + const { startup } = await modules(); + await startup.run(); + await startup.setAction("restore"); + await Promise.all([startup.persist("first"), startup.persist("second")]); expect(settings.get(`${key}.snapshot`)).toBe("second"); }); - it("reads the current enabled preference instead of caching it", async () => { - const { restore } = await modules(); - expect(await restore.enabled()).toBe(false); - settings.set(`${key}.enabled`, "true"); - expect(await restore.enabled()).toBe(true); - settings.set(`${key}.enabled`, "false"); - expect(await restore.enabled()).toBe(false); + it("reads the current startup action instead of caching it", async () => { + const { startup } = await modules(); + expect(await startup.action()).toBe("home"); + settings.set(actionKey, "restore"); + expect(await startup.action()).toBe("restore"); + settings.set(actionKey, "home"); + expect(await startup.action()).toBe("home"); }); it("observes preference changes from a secondary window before saving primary tabs", async () => { const primary = await modules(); - await primary.restore.restore(); + await primary.startup.run(); primary.app.addTab({ id: "primary", type: "local", label: "Primary" }); vi.resetModules(); const secondary = await modules(); secondary.app.addTab({ id: "secondary", type: "local", label: "Secondary" }); - await secondary.restore.setEnabled(true); - expect(secondary.restore.ready()).toBe(false); - await secondary.restore.persist(secondary.restore.snapshot()); + await secondary.startup.setAction("restore"); + expect(secondary.startup.ready()).toBe(false); + await secondary.startup.persist(secondary.startup.snapshot()); expect(settings.get(`${key}.snapshot`)).toBeUndefined(); - await primary.restore.persist(primary.restore.snapshot()); - expect(settings.get(`${key}.snapshot`)).toBe(primary.restore.snapshot()); + await primary.startup.persist(primary.startup.snapshot()); + expect(settings.get(`${key}.snapshot`)).toBe(primary.startup.snapshot()); - await secondary.restore.setEnabled(false); - await primary.restore.persist(primary.restore.snapshot()); + await secondary.startup.setAction("home"); + await primary.startup.persist(primary.startup.snapshot()); expect(settings.get(`${key}.snapshot`)).toBe(""); }); - it("saves existing primary tabs when restoration is enabled", async () => { - const { app, restore } = await modules(); - await restore.restore(); + it("saves existing primary tabs when restoration is selected", async () => { + const { app, startup } = await modules(); + await startup.run(); app.addTab({ id: "local", type: "local", label: "Local" }); - await restore.setEnabled(true); - expect(settings.get(`${key}.snapshot`)).toBe(restore.snapshot()); + await startup.setAction("restore"); + expect(settings.get(`${key}.snapshot`)).toBe(startup.snapshot()); }); it("checks the preference when a queued snapshot starts writing", async () => { saved([{ type: "local" }]); - const { restore } = await modules(); - await restore.restore(); + const { startup } = await modules(); + await startup.run(); const blocked = deferred(); const implementation = invoke.getMockImplementation()!; invoke.mockImplementation(async (command, args) => { @@ -267,12 +337,12 @@ describe("session restoration", () => { } return implementation(command, args); }); - const first = restore.persist("first"); + const first = startup.persist("first"); await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("set_setting", { key: `${key}.snapshot`, value: "first", })); - const second = restore.persist("second"); - settings.set(`${key}.enabled`, "false"); + const second = startup.persist("second"); + settings.set(actionKey, "home"); blocked.resolve(); await Promise.all([first, second]); expect(settings.get(`${key}.snapshot`)).toBe("first"); @@ -281,8 +351,8 @@ describe("session restoration", () => { it("keeps the snapshot cleared when disable follows a pending save", async () => { saved([{ type: "local" }]); - const { restore } = await modules(); - await restore.restore(); + const { startup } = await modules(); + await startup.run(); const blocked = deferred(); const implementation = invoke.getMockImplementation()!; invoke.mockImplementation(async (command, args) => { @@ -291,15 +361,15 @@ describe("session restoration", () => { } return implementation(command, args); }); - const pending = restore.persist("pending"); + const pending = startup.persist("pending"); await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("set_setting", { key: `${key}.snapshot`, value: "pending", })); - const disable = restore.setEnabled(false); - const later = restore.persist("later"); + const disable = startup.setAction("home"); + const later = startup.persist("later"); blocked.resolve(); await Promise.all([pending, disable, later]); - expect(settings.get(`${key}.enabled`)).toBe("false"); + expect(settings.get(actionKey)).toBe("home"); expect(settings.get(`${key}.snapshot`)).toBe(""); expect(invoke).not.toHaveBeenCalledWith("set_setting", { key: `${key}.snapshot`, value: "later" }); }); @@ -307,20 +377,20 @@ describe("session restoration", () => { it("preserves the snapshot when a preference read fails and saves after recovery", async () => { saved([{ type: "local" }]); const original = settings.get(`${key}.snapshot`); - const { restore } = await modules(); - await restore.restore(); + const { startup } = await modules(); + await startup.run(); invoke.mockRejectedValueOnce(new Error("settings unavailable")); - await restore.persist("failed"); + await startup.persist("failed"); expect(settings.get(`${key}.snapshot`)).toBe(original); - await restore.persist("recovered"); + await startup.persist("recovered"); expect(settings.get(`${key}.snapshot`)).toBe("recovered"); }); it("keeps saved ordering even when MRU reordering is enabled", async () => { saved([{ type: "ssh", profileId: "ssh1" }, { type: "local" }], 1); - const { app, restore } = await modules(); + const { app, startup } = await modules(); await app.setTabMru(true); - await restore.restore(); + await startup.run(); expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "local"]); expect(app.activeTabId()).toBe(app.tabs()[2].id); }); @@ -332,8 +402,8 @@ describe("session restoration", () => { { type: "local" }, { type: "serial", profileId: "s1" }, { type: "ssh", profileId: "ssh1" }, { type: "telnet", profileId: "telnet1" }, ], 2); - const { app, restore } = await modules(); - expect(await restore.restore()).toBe(false); + const { app, startup } = await modules(); + await startup.run(); expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "ssh", "telnet"]); expect(invoke).not.toHaveBeenCalledWith("list_serial_profiles"); expect(app.activeTabId()).toBe(app.tabs()[1].id); @@ -342,8 +412,8 @@ describe("session restoration", () => { it("does not fetch or restore serial profiles on a PTY-only host", async () => { capabilities.serial = false; saved([{ type: "local" }, { type: "serial", profileId: "serial1" }], 0); - const { app, restore } = await modules(); - expect(await restore.restore()).toBe(true); + const { app, startup } = await modules(); + await startup.run(); expect(app.tabs().map((tab) => tab.type)).toEqual(["home", "local"]); expect(invoke).not.toHaveBeenCalledWith("list_serial_profiles"); }); diff --git a/src/lib/stores/session-restore.svelte.ts b/src/lib/stores/startup.svelte.ts similarity index 75% rename from src/lib/stores/session-restore.svelte.ts rename to src/lib/stores/startup.svelte.ts index a9d93911..247d3fa4 100644 --- a/src/lib/stores/session-restore.svelte.ts +++ b/src/lib/stores/startup.svelte.ts @@ -7,34 +7,40 @@ import { toast } from "./toast.svelte.ts"; // serial login scripts, editor content and connector commands may contain secrets. type SavedTerminal = { type: "ssh" | "telnet" | "serial"; profileId: string } | { type: "local" }; interface Snapshot { version: 1; terminals: SavedTerminal[]; active: number } -const enabledKey = "session_restore.enabled"; +export type StartupAction = "restore" | "local" | "home"; +const actionKey = "startup_action"; const snapshotKey = "session_restore.snapshot"; let _ready = $state(false); let writes = Promise.resolve(); -export async function enabled(): Promise { - return await invoke("get_setting", { key: enabledKey }) === "true"; +export async function action(): Promise { + const value = await invoke("get_setting", { key: actionKey }); + return value === "restore" || value === "local" ? value : "home"; } export function ready() { return _ready; } +export async function availableShells(): Promise { + return app.capabilities().localPty ? invoke("list_shells") : []; +} + function report(error: unknown) { - console.warn("[session-restore]", error); + console.warn("[startup]", error); toast.error(errMsg(error)); } function queueWrite(operation: () => Promise): Promise { - // Keep preference reads and their writes together, including disable + clear. + // Keep action reads and their writes together, including switching away + clear. const next = writes.then(operation); - writes = next.catch((error) => console.warn("[session-restore] write failed:", error)); + writes = next.catch((error) => console.warn("[startup] write failed:", error)); return next; } -export async function setEnabled(value: boolean) { +export async function setAction(value: StartupAction) { await queueWrite(async () => { - await invoke("set_setting", { key: enabledKey, value: String(value) }); - if (!value) await invoke("set_setting", { key: snapshotKey, value: "" }); + await invoke("set_setting", { key: actionKey, value }); + if (value !== "restore") await invoke("set_setting", { key: snapshotKey, value: "" }); }); - if (value) await persist(snapshot()); + if (value === "restore") await persist(snapshot()); } function reference(tab: app.Tab): SavedTerminal | null { @@ -60,7 +66,7 @@ export async function persist(value: string): Promise { // Only a primary window whose startup reads succeeded may replace the snapshot. if (!_ready) return; await queueWrite(async () => { - if (await enabled()) { + if (await action() === "restore") { await invoke("set_setting", { key: snapshotKey, value }); } }).catch(report); @@ -85,14 +91,20 @@ function parse(raw: string | null): Snapshot | null { return { version: 1, terminals, active: Number.isInteger(data.active) ? data.active : -1 }; } -/** Returns whether a local terminal was restored (avoids startup duplication). */ -export async function restore(signal?: AbortSignal): Promise { - let localRestored = false; +export async function run(signal?: AbortSignal): Promise { let failed = false; try { - if (!await enabled() || signal?.aborted) return false; + const selectedAction = await action(); + if (selectedAction === "home" || signal?.aborted || app.tabs().length !== 1) return; + if (selectedAction === "local") { + const shells = await availableShells(); + if (shells.length && await action() === "local" && !signal?.aborted && app.tabs().length === 1) { + app.addTab({ id: `local:${crypto.randomUUID()}`, type: "local", label: "Local" }); + } + return; + } const saved = parse(await invoke("get_setting", { key: snapshotKey })); - if (!saved || signal?.aborted) return false; + if (!saved || signal?.aborted) return; const types = new Set(saved.terminals.map((entry) => entry.type)); const [ssh, telnet, serial] = await Promise.all([ types.has("ssh") ? app.loadProfiles() : [], @@ -100,15 +112,14 @@ export async function restore(signal?: AbortSignal): Promise { types.has("serial") && app.capabilities().serial ? invoke("list_serial_profiles") : [], ]); // The preference or the user's tabs may have changed while profiles loaded. - const stillEnabled = await enabled(); - if (!stillEnabled || signal?.aborted || app.tabs().length !== 1) return false; + const stillRestoring = await action() === "restore"; + if (!stillRestoring || signal?.aborted || app.tabs().length !== 1) return; let activeId = "home"; const restoredIds: string[] = []; saved.terminals.forEach((entry, index) => { const before = app.tabs().length; if (entry.type === "local" && app.capabilities().localPty) { app.addTab({ id: `local:${crypto.randomUUID()}`, type: "local", label: "Local" }); - localRestored = true; } else if (entry.type === "ssh") { const profile = ssh.find((profile) => profile.id === entry.profileId); if (profile) app.addTab({ @@ -138,5 +149,4 @@ export async function restore(signal?: AbortSignal): Promise { } finally { _ready = !failed && !signal?.aborted; } - return localRestored; } From bed8e270c4db042f714ae1498bd2fd5b18fa7f17 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Sat, 24 Oct 2026 23:28:59 +0800 Subject: [PATCH 7/8] fix(settings): align startup action with shell card layout --- src/lib/components/ShellSettings.svelte | 42 +++++++++++++++---------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/lib/components/ShellSettings.svelte b/src/lib/components/ShellSettings.svelte index cbe87ff6..96d38944 100644 --- a/src/lib/components/ShellSettings.svelte +++ b/src/lib/components/ShellSettings.svelte @@ -183,19 +183,11 @@
-
- - saveStartup(value as StartupAction)} /> +
- {/if} +
@@ -298,12 +305,12 @@
-
+
{t("settings.shell.right_click_desc")}
-
+
{ if (e.key === "Enter") saveTimeout(); }} /> - {t("settings.shell.timeout_hint")} -
+ +
+
+
+ +
{t("settings.shell.timeout_hint")}
+
+ { if (e.key === "Enter") saveTimeout(); }} /> +
- -
- - { if (e.key === "Enter") saveSftpMaxConcurrent(); }} /> - {t("settings.shell.sftp_concurrent_hint", { - min: sftpBounds.min, max: sftpBounds.max, def: sftpBounds.def, - })} -
+
+ +
+
+ +
{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")}
-
{t("settings.shell.verbose_log_desc")}
+
+ +
+
+ +
{t("settings.shell.verbose_log_desc")}
+
+
-
@@ -355,6 +365,7 @@ /* 卡片:复用全局 .card.surface-raised,本地只加 padding + 内布局, 跟 SyncScreen / AiSettings 同款。 */ .shell-card, + .connection-card, .mouse-card { padding: 18px; display: flex; @@ -453,20 +464,15 @@ .custom-input:focus { outline: none; color: var(--text); } .custom-input::placeholder { color: var(--text-dim); opacity: 0.6; } - .timeout-row { - display: flex; align-items: center; gap: 10px; - } - .timeout-row input[type="number"] { + .connection-card input[type="number"] { width: 80px; + flex-shrink: 0; } /* 动作下拉框:定宽靠右,窄窗口换行并限制在卡片内。 */ .action-row { flex-wrap: wrap; } - .action-row .cmd-block-head-body { flex-basis: 180px; min-width: 0; } + .action-row .cmd-block-head-body { flex-basis: 180px; } .action-select { width: 260px; max-width: 100%; flex-shrink: 0; margin-left: auto; } - .timeout-hint { - font-size: 11px; color: var(--text-dim); - } /* 交互卡片的行布局(title/desc + 控件),分隔线分隔多行。 类名沿用 .cmd-block-* —— 命令块卡片已迁到 CommandBlockSettings,这里复用同款行结构。 */ @@ -477,6 +483,7 @@ } .cmd-block-head-body { flex: 1; + min-width: 0; display: flex; flex-direction: column; gap: 4px; diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f8098c72..01660f78 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -191,8 +191,7 @@ const en = { "settings.appearance.font.row_desc": "Pick an installed font; it leads the default fallback stack.", "settings.appearance.font.size": "Font size", "settings.appearance.font.size_desc": "Terminal text size, {min}–{max} px.", - "settings.shell.sftp_concurrent": "SFTP CONCURRENT TRANSFERS", - "settings.shell.sftp_concurrent_label": "Max parallel", + "settings.shell.sftp_concurrent_label": "SFTP concurrent transfer limit", "settings.shell.sftp_concurrent_hint": "{min}–{max}, default {def}. Excess transfers queue up; one starts when another finishes. Raising too high may hit your SSH server's channel limit.", "settings.shell.command_block": "Command block", "settings.shell.command_block_bar": "Command block bar", @@ -1240,11 +1239,10 @@ const en = { "settings.shell.startup_local": "Open a local shell", "settings.shell.startup_home": "Open Home page", "settings.shell.startup_restore_desc": "Reopen saved SSH, Telnet and serial connections and local terminals using current profiles. Startup commands may run again. Only the root terminal of each tab is restored; split panes, terminal output, editors, forwards and discovered containers are not restored.", - "settings.shell.connection_timeout": "CONNECTION TIMEOUT", - "settings.shell.timeout_label": "Timeout (seconds)", + "settings.shell.connection_settings": "CONNECTION SETTINGS", + "settings.shell.timeout_label": "Connection timeout (seconds)", "settings.shell.timeout_hint": "1–300s, default 10s", - "settings.shell.connection_logging": "CONNECTION LOGGING", - "settings.shell.verbose_log": "VERBOSE LOG", + "settings.shell.verbose_log": "SSH connection logging", "settings.shell.verbose_log_desc": "Show detailed SSH handshake and authentication messages in terminal.", "settings.shell.interaction": "Terminal interaction", "settings.shell.confirm_close_tab": "Confirm before closing tab", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 16ae00e7..b37c7f8b 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -193,8 +193,7 @@ const zh: Messages = { "settings.appearance.font.row_desc": "选择已安装字体,置于默认字体链最前。", "settings.appearance.font.size": "字体大小", "settings.appearance.font.size_desc": "终端文字大小,{min}–{max} 像素。", - "settings.shell.sftp_concurrent": "SFTP 并发传输", - "settings.shell.sftp_concurrent_label": "并发上限", + "settings.shell.sftp_concurrent_label": "SFTP 并发传输上限", "settings.shell.sftp_concurrent_hint": "{min}–{max},默认 {def}。超出上限的传输自动排队,前一个结束后顶替。调大要注意 SSH 服务端 channel 上限。", "settings.shell.command_block": "命令块", "settings.shell.command_block_bar": "命令块侧栏", @@ -1243,11 +1242,10 @@ const zh: Messages = { "settings.shell.startup_local": "打开本地 Shell", "settings.shell.startup_home": "打开 Home 页", "settings.shell.startup_restore_desc": "使用当前配置重新打开已保存的 SSH、Telnet、串口连接和本地终端,启动命令可能再次执行。每个标签页只恢复根终端,不恢复分屏、终端输出、编辑器、端口转发或动态发现的容器。", - "settings.shell.connection_timeout": "连接超时", - "settings.shell.timeout_label": "超时(秒)", + "settings.shell.connection_settings": "连接设置", + "settings.shell.timeout_label": "连接超时(秒)", "settings.shell.timeout_hint": "1–300 秒,默认 10 秒", - "settings.shell.connection_logging": "连接日志", - "settings.shell.verbose_log": "详细日志", + "settings.shell.verbose_log": "SSH 连接日志", "settings.shell.verbose_log_desc": "在终端显示详细的 SSH 握手与认证信息。", "settings.shell.interaction": "终端交互", "settings.shell.confirm_close_tab": "关闭标签页前确认",