fix: prevent session restore snapshot loss

This commit is contained in:
Zheng Liu
2026-06-17 23:41:29 +08:00
parent 04906a80ff
commit 93079bb576
11 changed files with 120 additions and 18 deletions
+2 -1
View File
@@ -105,6 +105,7 @@ function App({ settings }: { settings: SettingsState }) {
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
theme,
@@ -259,7 +260,7 @@ function App({ settings }: { settings: SettingsState }) {
copySession,
createSessionFromCloneSource,
updateSessionRestoreCwd,
} = useSessionState();
} = useSessionState({ persistSessionRestore: !isPeerSessionWindow });
const handleRunSnippet = useCallback(
async (snippet: Snippet, targetHosts: Host[]) => {
@@ -65,7 +65,7 @@ test("session restore persistence re-arms when restore previous session is enabl
const revisionStateIndex = source.indexOf("restorePreviousSessionRevision");
const keyGuardIndex = source.indexOf("detail?.key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION");
const listenerIndex = source.indexOf("addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT");
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision]", source.indexOf("beforeunload"));
const effectDependencyIndex = source.indexOf("restorePreviousSessionRevision, persistSessionRestore]", source.indexOf("beforeunload"));
assert.notEqual(adapterEventImportIndex, -1);
assert.notEqual(revisionStateIndex, -1);
@@ -74,6 +74,27 @@ test("session restore persistence re-arms when restore previous session is enabl
assert.notEqual(effectDependencyIndex, -1);
});
test("session restore persistence can be disabled for non-main windows", () => {
const hookSource = readFileSync(new URL("./useSessionState.ts", import.meta.url), "utf8");
const traySource = readFileSync(new URL("../../components/TrayPanel.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const registerBridgesSource = readFileSync(new URL("../../electron/main/registerBridges.cjs", import.meta.url), "utf8");
const mainWindowSource = readFileSync(new URL("../../electron/bridges/windowManager/mainWindow.cjs", import.meta.url), "utf8");
assert.match(hookSource, /persistSessionRestore\?: boolean/);
assert.match(hookSource, /restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting/);
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(mainWindowSource, /const rendererHash = typeof route === "string"/);
assert.match(mainWindowSource, /loadURL\(`\$\{getDevRendererBaseUrl\(devServerUrl\)\}\$\{rendererHash\}`\)/);
assert.match(mainWindowSource, /loadURL\(`app:\/\/netcatty\/index\.html\$\{rendererHash\}`\)/);
});
test("restore terminal cwd setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
@@ -189,3 +189,29 @@ test("session restore flush clears storage instead of writing when restore is di
assert.equal(clearCount, 1);
assert.equal(writes.length, 0);
});
test("session restore flush skips empty transient windows without clearing existing snapshots", () => {
const writes: SessionRestorePayload[] = [];
let clearCount = 0;
const storage = {
write: (next: SessionRestorePayload) => {
writes.push(next);
return true;
},
clear: () => {
clearCount += 1;
},
};
const wrote = buildAndWriteSessionRestorePayload({
sessions: [],
workspaces: [],
tabOrder: [],
activeTabId: "vault",
storage,
});
assert.equal(wrote, false);
assert.equal(clearCount, 0);
assert.equal(writes.length, 0);
});
-1
View File
@@ -100,7 +100,6 @@ export function buildAndWriteSessionRestorePayload({
now,
});
if (!payload) {
storage.clear();
return false;
}
return storage.write(payload);
+10 -4
View File
@@ -43,13 +43,17 @@ import {
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
export const useSessionState = () => {
export const useSessionState = ({
persistSessionRestore = true,
}: {
persistSessionRestore?: boolean;
} = {}) => {
const initialRestoreState = useMemo(() => createInitialRestoredSessionState({
restoreEnabled: resolveRestorePreviousSessionSetting(
restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
payload: sessionRestoreStorage.read(),
}), []);
}), [persistSessionRestore]);
const [sessions, setSessions] = useState<TerminalSession[]>(initialRestoreState.sessions);
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialRestoreState.workspaces);
// Latest workspaces snapshot for synchronous existence checks outside
@@ -102,6 +106,8 @@ export const useSessionState = () => {
}, []);
useEffect(() => {
if (!persistSessionRestore) return;
const restoreEnabled = resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
);
@@ -140,7 +146,7 @@ export const useSessionState = () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("beforeunload", handlePageHide);
};
}, [sessions, workspaces, tabOrder, activeTabRevision, restorePreviousSessionRevision]);
}, [sessions, workspaces, tabOrder, activeTabRevision, restorePreviousSessionRevision, persistSessionRestore]);
const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => {
setSessions((prev) => updateSessionRestoreCwdState(prev, sessionId, cwd));
+1 -1
View File
@@ -128,7 +128,7 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
} = useTrayPanelBackend();
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
useSessionState();
useSessionState({ persistSessionRestore: false });
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
+41
View File
@@ -401,6 +401,47 @@ test("sanitizeSessionRestorePayload falls back to equal split sizes when saved s
}
});
test("sanitizeSessionRestorePayload preserves split sizes when nested splits collapse", () => {
const sanitized = sanitizeSessionRestorePayload({
version: 1,
savedAt: 1,
activeTabId: "ws-1",
tabOrder: ["ws-1"],
sessions: [session("s1", "ws-1"), session("s3", "ws-1")],
workspaces: [{
id: "ws-1",
title: "Workspace",
root: {
id: "root-split",
type: "split",
direction: "horizontal",
sizes: [0.7, 0.3],
children: [
{
id: "left-split",
type: "split",
direction: "vertical",
sizes: [0.4, 0.6],
children: [
{ id: "pane-1", type: "pane", sessionId: "s1" },
{ id: "pane-missing", type: "pane", sessionId: "missing" },
],
},
{ id: "pane-3", type: "pane", sessionId: "s3" },
],
},
}],
});
const root = sanitized.workspaces[0].root;
assert.equal(root.type, "split");
if (root.type === "split") {
assert.deepEqual(root.children.map((child) => child.type === "pane" ? child.sessionId : "split"), ["s1", "s3"]);
assert.deepEqual(root.sizes, [0.7, 0.3]);
assert.ok(root.sizes?.every((size) => size > 0));
}
});
test("resolveRestoredActiveTabId falls back to vault when no restored tab is valid", () => {
assert.equal(resolveRestoredActiveTabId("missing", [], [], []), "vault");
});
+8 -7
View File
@@ -261,9 +261,13 @@ const pruneNode = (node: unknown, validSessionIds: ReadonlySet<string>): Workspa
return null;
}
const children = node.children
.map((child) => pruneNode(child, validSessionIds))
.filter((child): child is WorkspaceNode => child !== null);
const restoredChildren = node.children
.map((child, originalIndex) => ({
child: pruneNode(child, validSessionIds),
originalIndex,
}))
.filter((entry): entry is { child: WorkspaceNode; originalIndex: number } => entry.child !== null);
const children = restoredChildren.map((entry) => entry.child);
if (children.length === 0) return null;
if (children.length === 1) return children[0];
@@ -272,10 +276,7 @@ const pruneNode = (node: unknown, validSessionIds: ReadonlySet<string>): Workspa
const canReuseSizes = rawSizes?.length === node.children.length
&& rawSizes.every((size) => typeof size === "number" && Number.isFinite(size) && size > 0);
const nextSizes = canReuseSizes
? children.map((child) => {
const originalIndex = node.children.findIndex((original) => original.id === child.id);
return originalIndex >= 0 && typeof rawSizes[originalIndex] === "number" ? rawSizes[originalIndex] : 0;
})
? restoredChildren.map(({ originalIndex }) => rawSizes[originalIndex])
: children.map(() => 1 / children.length);
const total = nextSizes.reduce((sum, size) => sum + size, 0);
@@ -3,7 +3,10 @@ function createMainWindowApi(ctx) {
with (ctx) {
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir, route } = options;
const rendererHash = typeof route === "string" && route.trim()
? `#/${route.trim().replace(/^#?\/*/, "")}`
: "";
// Store app reference for window state persistence
electronApp = app;
@@ -321,7 +324,7 @@ function createMainWindowApi(ctx) {
if (isDev) {
try {
await win.loadURL(getDevRendererBaseUrl(devServerUrl));
await win.loadURL(`${getDevRendererBaseUrl(devServerUrl)}${rendererHash}`);
win.webContents.openDevTools({ mode: "detach" });
return win;
} catch (e) {
@@ -330,7 +333,7 @@ function createMainWindowApi(ctx) {
}
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html");
await win.loadURL(`app://netcatty/index.html${rendererHash}`);
return win;
}
+1
View File
@@ -390,6 +390,7 @@ function createBridgeRegistrar(context) {
appIcon,
isMac,
electronDir,
route: "session-window",
onRegisterBridge: registerBridges,
});
try {
+3
View File
@@ -128,6 +128,9 @@ const getRoute = () => {
if (hash === '#/terminal-popup' || hash.startsWith('#/terminal-popup')) {
return 'terminal-popup';
}
if (hash === '#/session-window' || hash.startsWith('#/session-window')) {
return 'main';
}
return 'main';
};