Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 abe2b0edc0 fix(terminal): merge pending side-panel writes across urgent renders
Icon switches inside startTransition used to publish absolute maps from
refs, and the next render copied the last committed state back over
those refs. A second tab's switch could then replace the queued map and
drop the first tab's selection and split. Patch each tab onto the queued
map, and leave a pending ref in place until that render commits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:13:44 +00:00
Cursor Agentand陈大猫 945147c600 perf(terminal): commit side-panel tool switches in one render
Icon clicks write the focused tool and the layout together through
selectSidePanelTool. External open-tab writes still reconcile in an effect,
and that effect skips when the layout is already the selection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:06:52 +00:00
28 changed files with 76 additions and 1970 deletions

No files matched your search

-255
View File
@@ -1,255 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
-53
View File
@@ -1,53 +0,0 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -9,106 +9,6 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
const sessions = ["images", "plain", "other"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("images");
terminalHiddenRendererStore.markSoftHidden("plain");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
terminalHiddenRendererStore.clearSoftHidden("plain");
terminalHiddenRendererStore.settleEviction("plain", true);
}
});
try {
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
terminalHiddenRendererStore.markSoftHidden("images");
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
const delivered: string[] = [];
const later = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
});
try {
terminalHiddenRendererStore.markSoftHidden("other");
assert.deepEqual(delivered, []);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
} finally {
later();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a reserved leaving slot counts toward occupancy until it is released", () => {
const sessions = ["a", "b", "c", "d"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
terminalHiddenRendererStore.clearSoftHidden("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
terminalHiddenRendererStore.clearSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("victim");
let markerClearedBeforeSettle = false;
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.settleEviction("victim", false);
});
});
try {
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
assert.equal(markerClearedBeforeSettle, true);
assert.equal(completed, false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
} finally {
unsubscribe();
terminalHiddenRendererStore.clearSoftHidden("victim");
}
});
@@ -3,85 +3,14 @@ type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
const reservedLeavingSlots = new Map<string, number>();
let reservationToken = 0;
function reservedLeavingCount(): number {
let extra = 0;
for (const sessionId of reservedLeavingSlots.keys()) {
if (!softHiddenSessions.has(sessionId)) extra += 1;
}
return extra;
}
type EvictionWaiter = {
promise: Promise<boolean>;
resolve: (completed: boolean) => void;
settled: boolean;
};
const evictionWaiters = new Map<string, EvictionWaiter>();
function finishEviction(sessionId: string, completed: boolean): void {
const waiter = evictionWaiters.get(sessionId);
if (!waiter || waiter.settled) return;
waiter.settled = true;
evictionWaiters.delete(sessionId);
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
waiter.resolve(completed);
}
function emit(): void {
listeners.forEach((listener) => listener());
}
function listSoftHiddenOldestFirst(): string[] {
return [...softHiddenSessions.entries()]
.sort((left, right) => left[1] - right[1])
.map(([sessionId]) => sessionId);
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
/**
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
* yet in the marker set. Keep-count decisions use this so a second timer
* cannot take the slot a pending eviction already claimed.
*/
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
/**
* Claim one keep slot for a leaving tab before awaiting eviction.
* A session already in the marker set is not counted twice. A later claim
* for the same id replaces the token; only that token can release it.
*/
reserveLeavingSlot: (sessionId: string): number => {
const token = ++reservationToken;
if (sessionId && !softHiddenSessions.has(sessionId)) {
reservedLeavingSlots.set(sessionId, token);
}
return token;
},
releaseLeavingSlot: (sessionId: string, token: number) => {
if (reservedLeavingSlots.get(sessionId) === token) {
reservedLeavingSlots.delete(sessionId);
}
},
/**
* True when retaining this leaving tab would stay within keepCount.
* Its own reservation is not counted against the slots held by others.
*/
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
if (!(keepCount > 0)) return false;
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
return others < keepCount;
},
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
@@ -94,12 +23,18 @@ export const terminalHiddenRendererStore = {
emit();
},
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
return listSoftHiddenOldestFirst()[0] ?? null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
},
requestEviction: (sessionId: string) => {
@@ -107,44 +42,6 @@ export const terminalHiddenRendererStore = {
emit();
},
/** True while this session's hibernate has been requested and not settled. */
hasPendingEviction: (sessionId: string): boolean => {
const waiter = evictionWaiters.get(sessionId);
return waiter != null && !waiter.settled;
},
/**
* Ask one soft-hidden session to upgrade. Resolves true only when
* settleEviction reports that hibernation finished. Clearing the marker
* during wake is not success: drain, snapshot, or alternate-screen failure
* can still restore that renderer. A synchronous refusal (marker still
* present, including inline images) resolves false and drops an unconsumed
* request so a later emit cannot upgrade it.
*/
evictSoftHidden: (sessionId: string): Promise<boolean> => {
const existing = evictionWaiters.get(sessionId);
if (existing) return existing.promise;
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
let resolvePromise!: (completed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
evictionWaiters.set(sessionId, waiter);
evictionRequestSessionId = sessionId;
emit();
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
finishEviction(sessionId, false);
}
return promise;
},
/** Record whether the in-flight eviction's hibernate actually completed. */
settleEviction: (sessionId: string, completed: boolean) => {
finishEviction(sessionId, completed);
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
@@ -279,82 +279,6 @@ test('a pending side-panel write survives an urgent render on another tab', asyn
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
@@ -27,64 +27,6 @@ function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
@@ -120,22 +62,17 @@ export function useTerminalSidePanelLayoutState() {
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
// back over that pending write. Functional updates still merge onto queued
// state when the ref and the committed map have diverged.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
if (sidePanelOpenTabsRef.current === renderedOpenTabsRef.current) {
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
}
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
if (sidePanelLayoutsRef.current === renderedLayoutsRef.current) {
sidePanelLayoutsRef.current = sidePanelLayouts;
}
renderedLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
@@ -180,28 +117,14 @@ export function useTerminalSidePanelLayoutState() {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
sidePanelLayoutsRef.current = next;
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
sidePanelOpenTabsRef.current = next;
return next;
});
}, []);
+22 -82
View File
@@ -243,7 +243,6 @@ import {
import {
canHibernateTerminalRuntimeSession,
canHibernateTerminalRuntimeStatus,
decideHiddenTabKeepCountAction,
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
resolveHibernatePreferWasmSerialize,
@@ -255,7 +254,6 @@ import {
} from "../domain/terminalHibernate";
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
import { retainLeavingTabAfterEviction } from "../application/state/hiddenTabEviction";
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
import {
wakeTerminalFromHibernate,
@@ -524,7 +522,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hibernateAlternateScreenRef = useRef(false);
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
const hibernateEvictionAttemptRef = useRef(0);
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
const wakeInProgressRef = useRef(false);
@@ -2300,6 +2297,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
fullHibernateRuntimeRef.current = fullHibernateRuntime;
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
if (!wakeSoftHiddenRuntimeRef.current) return;
wakeSoftHiddenRuntimeRef.current?.();
void fullHibernateRuntime().then(
(completed) => {
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
},
(error) => {
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
},
);
}, [fullHibernateRuntime, sessionId]);
const hideRuntimeOnly = useCallback(() => {
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
xtermRuntimeRef.current?.suspendWebglRenderer();
@@ -2308,39 +2319,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
}, [sessionId]);
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
const pendingEviction = terminalHiddenRendererStore.hasPendingEviction(sessionId);
if (!wakeSoftHiddenRuntimeRef.current) {
terminalHiddenRendererStore.settleEviction(sessionId, false);
return;
}
wakeSoftHiddenRuntimeRef.current?.();
void fullHibernateRuntime().then(
(completed) => {
if (!completed) {
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
// Wake already dropped the marker. Restore the soft-hidden slot only
// while this tab is still hidden and hibernate is still enabled.
// Selecting the victim during the drain makes fullHibernateRuntime
// return false because the tab is visible; soft-hiding it again
// would hide the tab the user just opened.
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
hideRuntimeOnly();
}
}
terminalHiddenRendererStore.settleEviction(sessionId, completed);
},
(error) => {
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
hideRuntimeOnly();
}
terminalHiddenRendererStore.settleEviction(sessionId, false);
},
);
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId]);
const hibernateRuntime = useCallback(() => {
if (hibernatedRef.current || !termRef.current) return;
@@ -2355,14 +2333,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Disconnected sessions never stay soft-hidden. A connected session whose
// keep count dropped to zero takes the same upgrade. A positive count stays
// retained until a later leave evicts overflow.
if (softHiddenRef.current) {
const retainedKeepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0;
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
if (statusRef.current === "disconnected") {
upgradeSoftHiddenRuntimeToHibernate();
}
return;
@@ -2371,47 +2343,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const keepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettings)
: 0;
const keepDecision = decideHiddenTabKeepCountAction({
keepCount,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId,
candidatesOldestFirst: keepCount > 0
? terminalHiddenRendererStore.listSoftHiddenOldestFirst()
: [],
});
if (keepDecision.action === "soft-hide") {
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
hideRuntimeOnly();
return;
}
if (keepDecision.action === "await-eviction") {
const attempt = ++hibernateEvictionAttemptRef.current;
// Reserves this tab's slot before the first await, then soft-hides only
// if a victim hibernate completes and the slot still fits. A sync marker
// clear is not enough: alt-screen and drain/snapshot failure restore the
// victim, and this tab stays out of the soft-hidden set.
void retainLeavingTabAfterEviction({
sessionId,
candidates: keepDecision.candidates,
keepCount,
// Re-read after the victim drain. The count above was captured when
// this timer fired; a zero or tighter setting must full-hibernate.
resolveKeepCount: () => (
statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0
),
stillWantsRetention: () => (
hibernateEvictionAttemptRef.current === attempt
&& !isVisibleRef.current
&& !hibernatedRef.current
&& !softHiddenRef.current
),
softHide: () => hideRuntimeOnly(),
fullHibernate: () => {
void fullHibernateRuntime();
},
});
return;
if (keepCount > 0) {
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
if (victim && victim !== sessionId) {
terminalHiddenRendererStore.requestEviction(victim);
}
}
void fullHibernateRuntime();
@@ -4769,7 +4710,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: hibernateEnabled,
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -43,26 +43,6 @@ test("note content drafts stay in refs so MDX keystrokes do not rebuild the shel
);
});
test("hidden notes flush drafts on the isActive edge without rebuilding the tree", () => {
assert.match(
managerSource,
/useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);\s*\}, \[isActive\]\);/,
);
assert.match(managerSource, /const NotesTreePane = memo\(/);
assert.match(managerSource, /notesTreePanePropsEqual/);
assert.match(managerSource, /prev\.selectedNoteId === next\.selectedNoteId/);
assert.match(managerSource, /prev\.editingNoteId === next\.editingNoteId/);
assert.match(managerSource, /prev\.editingGroupPath === next\.editingGroupPath/);
assert.match(managerSource, /prev\.expandedGroups === next\.expandedGroups/);
assert.match(managerSource, /<NotesTreePane/);
assert.doesNotMatch(managerSource, /key=\{isActive\}/);
assert.doesNotMatch(
managerSource,
/if \(!isActive\) return null;/,
"hiding notes must not unmount NotesManager",
);
});
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
assert.match(
@@ -93,21 +73,6 @@ test("hidden terminal notes side panel does not subscribe to notes publishes", (
"utf8",
);
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
const notesSlot = slotsSource.slice(
slotsSource.indexOf("function SidePanelNotesSlotInner"),
slotsSource.indexOf("export const SidePanelNotesSlot"),
);
assert.doesNotMatch(notesSlot, /if \(!isVisible\) return null/);
assert.match(notesSlot, /<NotesManager/);
const notesStoreSource = readFileSync(
new URL("../../application/state/notesStore.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(
notesStoreSource,
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
"enabled:false must keep the live snapshot so reopen does not flash empty",
);
});
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
+1 -33
View File
@@ -22,9 +22,7 @@ import {
isNoteFolderTreeSelected,
isNoteEditorMode,
normalizeNoteEditorMode,
notesTreePanePropsEqual,
NotesManager,
type NotesTreePaneProps,
} from "./NotesManager.tsx";
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
@@ -371,40 +369,10 @@ test("NotesManager flushes drafts when retained mounts become inactive", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /isActive\?: boolean/);
assert.match(source, /if \(isActive\) return;/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
assert.match(source, /visibilitychange/);
});
const treePaneProps = (overrides: Partial<NotesTreePaneProps> = {}): NotesTreePaneProps => ({
notes: [note()],
noteGroups: ["Ops"],
selectedNoteId: "note-1",
selectedGroup: null,
expandedGroups: new Set(["Ops"]),
editingNoteId: null,
editingGroupPath: null,
query: "",
locale: "en",
hosts: [],
draggingNoteId: null,
draggingGroupPath: null,
creatingGroupParent: undefined,
isSidebarMode: true,
renderRows: () => null,
...overrides,
});
test("notes tree pane equality ignores render callbacks and compares selection state", () => {
const base = treePaneProps();
assert.equal(notesTreePanePropsEqual(base, { ...base, renderRows: () => "other" }), true);
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedNoteId: "note-2" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedGroup: "Ops" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, editingNoteId: "note-1" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, editingGroupPath: "Ops" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, expandedGroups: new Set(["Ops"]) }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, notes: [note({ title: "Renamed" })] }), false);
});
test("NotesManager tree rename allows clearing note titles", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
+18 -97
View File
@@ -16,7 +16,7 @@ import {
Upload,
X,
} from "lucide-react";
import React, { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
import { useStoredNumber } from "../../application/state/useStoredNumber";
@@ -357,57 +357,6 @@ export const getValidatedNoteSelectionState = (
return null;
};
export type NotesTreePaneProps = {
notes: VaultNote[];
noteGroups: string[];
selectedNoteId: string | null;
selectedGroup: string | null;
expandedGroups: ReadonlySet<string>;
editingNoteId: string | null;
editingGroupPath: string | null;
query: string;
locale: string;
hosts: Host[];
draggingNoteId: string | null;
draggingGroupPath: string | null;
creatingGroupParent: string | null | undefined;
isSidebarMode: boolean;
/**
* Row elements. Identity changes every parent render; equality ignores it so
* a visibility flip does not rebuild the tree. Event handlers close over the
* render that last changed one of the compared fields.
*/
renderRows: () => React.ReactNode;
};
/**
* Hide/show passes a new `isActive` into NotesManager, but that flag is not a
* tree input. Equality therefore keys off the data the rows actually paint.
* `selected` / `editing` / `expanded` are required so a memo bailout cannot
* freeze selection, rename, or folder expansion.
*/
export function notesTreePanePropsEqual(prev: NotesTreePaneProps, next: NotesTreePaneProps): boolean {
return prev.notes === next.notes
&& prev.noteGroups === next.noteGroups
&& prev.selectedNoteId === next.selectedNoteId
&& prev.selectedGroup === next.selectedGroup
&& prev.expandedGroups === next.expandedGroups
&& prev.editingNoteId === next.editingNoteId
&& prev.editingGroupPath === next.editingGroupPath
&& prev.query === next.query
&& prev.locale === next.locale
&& prev.hosts === next.hosts
&& prev.draggingNoteId === next.draggingNoteId
&& prev.draggingGroupPath === next.draggingGroupPath
&& prev.creatingGroupParent === next.creatingGroupParent
&& prev.isSidebarMode === next.isSidebarMode;
}
const NotesTreePane = memo(function NotesTreePane({ renderRows }: NotesTreePaneProps) {
return <>{renderRows()}</>;
}, notesTreePanePropsEqual);
NotesTreePane.displayName = "NotesTreePane";
export const getNotesGroupDropAction = (
sourceGroup: string | null,
targetGroup: string,
@@ -432,7 +381,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
openNoteRequestId = null,
onOpenNoteIdHandled,
}) => {
const { t, locale } = useI18n();
const { t } = useI18n();
const { openExternal } = useApplicationBackend();
const isSidebarMode = displayMode === "sidebar";
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
@@ -612,12 +561,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
flushNoteDraftRef.current();
}, []);
// Hide edge only. The ref is the latest flush, so this does not subscribe
// the tree render to draft-writer identity, and it does not remount the editor.
useLayoutEffect(() => {
if (isActive) return;
flushNoteDraftRef.current();
}, [isActive]);
flushNoteDraft();
}, [flushNoteDraft, isActive]);
useEffect(() => {
if (!isActive) return;
@@ -1539,28 +1486,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
);
};
// Built only when NotesTreePane's compared props change. isActive / the
// hidden class stay on the parent, so a side-panel hide does not reconcile rows.
const renderTreeRows = () => {
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
const visibleTree = noteTree.children
.map((child) => renderFolderRow(child, 0))
.filter(Boolean);
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
return (
<>
{renderCreateGroupRow(null, 0)}
{visibleTree}
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
{treeIsEmpty && (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
<Search size={20} className="mx-auto mb-2 opacity-60" />
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
</div>
)}
</>
);
};
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
const visibleTree = noteTree.children
.map((child) => renderFolderRow(child, 0))
.filter(Boolean);
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
const hasSearch = query.trim().length > 0;
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
@@ -1820,23 +1750,15 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}}
onDrop={(event) => handleGroupDrop(null, event)}
>
<NotesTreePane
notes={sortedNotes}
noteGroups={groups}
selectedNoteId={selectedNoteId}
selectedGroup={selectedGroup}
expandedGroups={expandedGroups}
editingNoteId={editingNoteId}
editingGroupPath={editingGroupPath}
query={query}
locale={locale}
hosts={hosts}
draggingNoteId={draggingNoteId}
draggingGroupPath={draggingGroupPath}
creatingGroupParent={creatingGroupParent}
isSidebarMode={isSidebarMode}
renderRows={renderTreeRows}
/>
{renderCreateGroupRow(null, 0)}
{visibleTree}
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
{treeIsEmpty && (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
<Search size={20} className="mx-auto mb-2 opacity-60" />
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
</div>
)}
</div>
</ScrollArea>
</div>
@@ -2126,7 +2048,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
{/* Kept mounted while the panel is hidden. Do not key this on isActive. */}
{isSidebarMode && overlayNoteView && (
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
<div className={cn(
@@ -84,7 +84,7 @@ test("a session holding inline images degrades to soft-hide instead of full hibe
const gateIndex = hibernateBody.indexOf("if (runtimeHasInlineImages()) {");
const softHideIndex = hibernateBody.indexOf("hideRuntimeOnly();", gateIndex);
const keepCountIndex = hibernateBody.indexOf("resolveHibernateKeepRendererCount(terminalSettings)");
const evictionIndex = hibernateBody.indexOf("retainLeavingTabAfterEviction(");
const evictionIndex = hibernateBody.indexOf("terminalHiddenRendererStore.pickEvictionCandidate(");
const fullHibernateIndex = hibernateBody.indexOf("void fullHibernateRuntime()");
assert.notEqual(gateIndex, -1, "hibernate must check for inline images");
@@ -348,62 +348,6 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
assert.deepEqual(writtenCommands, []);
});
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
const writtenCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let releaseOpen: (() => void) | undefined;
const openPending = new Promise<void>((resolve) => {
releaseOpen = resolve;
});
const bootHost = {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial" as const,
username: "admin",
password: "secret",
};
const hostRef = { current: bootHost };
const backend = buildBackend({
startSerialSession: async () => {
await openPending;
return "serial-session";
},
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
});
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
// Vault hydration can replace the fallback host after preflight has already
// decided there is no command to hold for auto-login.
hostRef.current = { ...bootHost, startupCommand: "show version" };
releaseOpen?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
autoLoginComplete?.({ sessionId: "session-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(autoLoginComplete, null);
assert.equal(ctx.hasRunStartupCommandRef.current, false);
assert.deepEqual(writtenCommands, []);
});
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -3653,207 +3653,6 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
// Session restore mounts the pane before the vault host (and group defaults)
// replace the fallback snapshot. The dial can still succeed from that
// snapshot; the startup command is only known on the effective host by the
// time the shell is attached.
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const effectiveHost = {
...bootHost,
startupCommand: "echo restored-startup",
startupCommandRunMode: "paste" as const,
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = effectiveHost;
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
sessionWrites.length = 0;
hostRef.current = effectiveHost;
const automaticCtx = createStarterContext({
...ctx,
sessionRef: { current: null },
hasRunStartupCommandRef: { current: false },
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
},
});
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const mismatches = [
{ label: "hostname", patch: { hostname: "moved.example.test" } },
{ label: "port", patch: { port: 2222 } },
{ label: "username", patch: { username: "bob" } },
{ label: "protocol", patch: { protocol: "telnet" as const } },
];
for (const { label, patch } of mismatches) {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
...patch,
startupCommand: "echo moved-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
assert.deepEqual(sessionWrites, [], label);
}
});
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
port: undefined,
startupCommand: "echo restored-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
@@ -3,56 +3,6 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Host startup is scheduled only after the shell attaches. Restored SSH panes
* boot from the session fallback first; vault hydration (including group
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
* from the live host only when that object is still this dial: same id,
* hostname, username, and dial port (`port || 22`), and the same protocol
* when the live host sets one. A missing protocol is a legacy SSH host.
* A saved host edited onto another endpoint since the workspace was saved
* keeps the snapshot, so its command is not typed into the old session.
*
* Serial preflight uses this same resolver to decide whether to wait for
* auto-login, then schedules again after the port opens. Non-SSH transports
* stay on the snapshot, so a command that appears on `hostRef` during the
* open is not typed into a saved username/password exchange.
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
* A different host id is a later pane, not this dial, so keep the snapshot.
*/
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
protocol === "ssh" || protocol === undefined;
const sshDialPort = (port: number | undefined): number => port || 22;
const sameSshDialTarget = (
dial: TerminalSessionStartersContext["host"],
live: TerminalSessionStartersContext["host"],
): boolean => {
if (live.hostname !== dial.hostname) return false;
if ((live.username ?? "") !== (dial.username ?? "")) return false;
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
// A live protocol, when set, must still be the protocol this dial used.
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
return true;
};
const resolveStartupHost = (
ctx: TerminalSessionStartersContext,
): TerminalSessionStartersContext["host"] => {
const live = ctx.hostRef?.current;
if (
prefersLiveStartupHost(ctx.host.protocol)
&& live
&& live.id === ctx.host.id
&& sameSshDialTarget(ctx.host, live)
) {
return live;
}
return ctx.host;
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
@@ -88,7 +38,7 @@ export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
@@ -146,7 +96,7 @@ export const scheduleStartupCommand = (
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
: (ctx.host.startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
);
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
);
assert.match(
alternateSkipBody,
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
);
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
@@ -239,16 +239,6 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(helperBody, /ensureWebglRenderer\(\)/);
assert.match(helperBody, /clearTextureAtlas\(\)/);
assert.match(helperBody, /safeFitRef\.current\(\{ force: true \}\)/);
const restoreGuard = "if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current)";
assert.equal(
upgradeBody.split(restoreGuard).length - 1,
2,
"both the failed hibernate and the rejection path must skip soft-hide when the tab is visible or hibernate is off",
);
assert.ok(
upgradeBody.indexOf(restoreGuard) > upgradeIndex,
"the visibility guard belongs on the settled upgrade, after the renderer was resumed",
);
});
test("snapshot and handoff paths reject output that misses the settle deadline", () => {
@@ -292,34 +282,3 @@ test("snapshot and handoff paths reject output that misses the settle deadline",
/const flushed = await flushPendingTerminalWritesBeforeHibernate\(snapshotTerm\);\s*if \(!flushed\) \{\s*throw new Error\("Terminal output did not settle before closing the attached display"\);\s*\}/,
);
});
test("a full keep count soft-hides the leaving session instead of snapshotting it", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
const imageGate = body.indexOf("if (runtimeHasInlineImages()) {");
const alternateGate = body.indexOf("shouldSkipHibernateForActiveAlternateScreen(termRef.current)");
const decision = body.indexOf("decideHiddenTabKeepCountAction(");
const softHide = body.indexOf('if (keepDecision.action === "soft-hide")');
const hide = body.indexOf("hideRuntimeOnly();", softHide);
const full = body.indexOf("void fullHibernateRuntime()");
assert.ok(alternateGate !== -1 && alternateGate < imageGate);
assert.ok(imageGate !== -1 && imageGate < decision);
assert.ok(decision < softHide && softHide < hide && hide < full);
const awaited = body.indexOf("retainLeavingTabAfterEviction({");
assert.ok(awaited > decision, "a full keep set retains the leaving tab from the application eviction walk");
assert.match(body, /getSoftHiddenOccupancy\(\)/);
assert.match(body, /stillWantsRetention:/);
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
assert.match(
body,
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
"retention after eviction must read the live keep count, not the captured one",
);
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
assert.match(
body,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
);
});
@@ -20,8 +20,6 @@ type UseTerminalHibernateEffectOptions = {
isSearchOpen: boolean;
hibernateEnabled: boolean;
hibernateDelayMs: number;
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
hibernateKeepRendererCount?: number;
fileTransferActive: boolean;
hibernatedRef: React.MutableRefObject<boolean>;
softHiddenRef: React.MutableRefObject<boolean>;
@@ -51,7 +49,6 @@ export function useTerminalHibernateEffect({
isSearchOpen,
hibernateEnabled,
hibernateDelayMs,
hibernateKeepRendererCount,
fileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -213,27 +210,4 @@ export function useTerminalHibernateEffect({
softHiddenRef,
status,
]);
// Separate from the visibility timer so a keep-count edit does not clear an
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
// later zero; this one re-enters hibernate without stacking a second snapshot
// when StrictMode runs the effect twice.
useEffect(() => {
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
if (!softHiddenRef.current) return;
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
const timer = window.setTimeout(() => {
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
if (!softHiddenRef.current) return;
onHibernateRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, [
hibernateEnabled,
hibernateKeepRendererCount,
isVisible,
isVisibleRef,
sessionId,
softHiddenRef,
]);
}
@@ -58,90 +58,6 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
});
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
const sessionId = "keep-count-zero-recheck";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout, clearTimeout },
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
t.after(() => {
removePaneVisible(sessionId);
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
if (previousActEnvironment) {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
}
});
let hibernateCalls = 0;
let renderer: ReactTestRenderer | null = null;
const softHiddenRef = { current: true };
const ref = <T>(current: T) => ({ current });
function Probe({ keepCount }: { keepCount: number }) {
useTerminalHibernateEffect({
sessionId,
isVisible: false,
isVisibleRef: ref(false),
getSessionConnectedRef: ref(() => true),
status: "connected",
isSearchOpen: false,
hibernateEnabled: true,
hibernateDelayMs: 60_000,
hibernateKeepRendererCount: keepCount,
fileTransferActive: false,
hibernatedRef: ref(false),
softHiddenRef,
hibernatePendingBufferRef: ref(""),
hibernateSnapshotRef: ref(""),
hibernateViewportSnapshotRef: ref(""),
hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""),
hibernateContextViewportSnapshotRef: ref(""),
hibernateContextScrollbackSnapshotRef: ref(""),
hibernateAlternateScreenRef: ref(false),
hasRuntimeRef: ref(true),
onHibernate: () => { hibernateCalls += 1; },
onSoftHideWake: () => {},
onWake: () => true,
});
return null;
}
const element = (keepCount: number) => React.createElement(
React.StrictMode,
null,
React.createElement(Probe, { keepCount }),
);
await act(async () => { renderer = create(element(2)); });
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
await act(async () => { renderer!.update(element(1)); });
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
await act(async () => {
renderer!.update(element(0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
await act(async () => renderer!.unmount());
});
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
const sessionId = "disconnected-hidden-hibernate-test";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -1,9 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import type { Workspace } from "../../types";
import { terminalPaneSessionsEqual } from "../../domain/terminalPaneSessionsEqual.ts";
import {
terminalLayerFocusSidebarPropsEqual,
terminalLayerSidePanelCtxEqual,
@@ -104,27 +102,6 @@ test("terminal layer memo ignores activeWorkspace-only tab flips (live store own
);
});
test("solo tab id change still invalidates workspace ctx; panes host does not read it", () => {
const sessions = [{ id: "session-a" }, { id: "session-b" }];
assert.equal(
terminalLayerWorkspaceCtxEqual(
{ activeTabId: "session-a", sessions },
{ activeTabId: "session-b", sessions },
),
false,
);
const hostSource = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
const equalStart = hostSource.indexOf("const terminalPanesHostPropsAreEqual");
const equalEnd = hostSource.indexOf("export const TerminalPanesHost");
const equalBody = hostSource.slice(equalStart, equalEnd);
assert.ok(equalStart !== -1 && equalEnd > equalStart);
assert.doesNotMatch(equalBody, /prev\.activeTabId/);
assert.doesNotMatch(equalBody, /next\.activeTabId/);
assert.match(equalBody, /terminalPaneSessionsEqual\(prev\.sessions, next\.sessions\)/);
assert.equal(terminalPaneSessionsEqual(sessions, sessions), true);
});
test("terminal layer memo re-renders when active resizers change", () => {
const base = {
activeResizers: [
@@ -343,11 +343,6 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
] as const;
const WORKSPACE_CTX_KEYS = [
// Stay. Solo SSH A->B already keeps TerminalPanesHost equal when the sessions
// ref is unchanged (this compare does not feed the panes host). The visible
// pane still pays reveal flush + repaint, and focus retries at 50ms.
// Dropping activeTabId only skips one workspace-section pass; the live-store
// host/theme update still renders the section. That is not the settle cost.
'activeTabId',
'workspaceInnerRef',
'workspaceOuterRef',
-49
View File
@@ -6,7 +6,6 @@ import {
canHibernateTerminalRuntimeStatus,
capHibernateBuffer,
capHibernateBufferByLines,
decideHiddenTabKeepCountAction,
isTerminalFileTransferActive,
normalizeHibernateHiddenTabsDelaySec,
resolveTerminalHibernateDelayMs,
@@ -93,54 +92,6 @@ test("normalizeHibernateHiddenTabsDelaySec clamps to the allowed range", () => {
assert.equal(normalizeHibernateHiddenTabsDelaySec(9999), TERMINAL_HIBERNATE_DELAY_SEC_MAX);
});
test("a full keep-count lists eviction candidates and does not evict them itself", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 1,
sessionId: "leaving",
candidatesOldestFirst: ["older"],
}), { action: "soft-hide", candidates: [] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["oldest", "newer"],
}), { action: "await-eviction", candidates: ["oldest", "newer"] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 3,
sessionId: "leaving",
candidatesOldestFirst: ["oldest"],
}), { action: "await-eviction", candidates: ["oldest"] });
});
test("keep-count zero still full-hibernates and does not evict", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 0,
softHiddenCount: 4,
sessionId: "leaving",
candidatesOldestFirst: ["oldest"],
}), { action: "full-hibernate", candidates: [] });
});
test("a full keep-count does not evict the session that is deciding", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["leaving"],
}), { action: "full-hibernate", candidates: [] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["leaving", "other"],
}), { action: "await-eviction", candidates: ["other"] });
});
test("resolveTerminalHibernateDelayMs converts seconds to milliseconds", () => {
assert.equal(resolveTerminalHibernateDelayMs(), TERMINAL_HIBERNATE_DELAY_SEC_DEFAULT * 1000);
assert.equal(resolveTerminalHibernateDelayMs({ hibernateHiddenTabsDelaySec: 10 }), 10_000);
-44
View File
@@ -158,50 +158,6 @@ export function shouldSkipHibernateForAltScreen(
return alternateScreen && resolveHibernateSkipAltScreen(settings);
}
export type HiddenTabKeepCountDecision = {
/**
* Soft-hide immediately when the keep set has room. Await eviction when it
* is full. Full-hibernate when keepCount is 0 or no other candidate exists.
* Callers retain the leaving tab only after a victim's hibernate completes.
*/
action: "soft-hide" | "full-hibernate" | "await-eviction";
/** Oldest-first victims to try when action is await-eviction. */
candidates: readonly string[];
};
/**
* Keep-count policy for a hidden session that is not already soft-hidden.
* Callers still handle alternate screen, inline images, and a soft-hidden
* session that later disconnects before asking. keepCount 0 (disconnected, or
* the setting) full-hibernates. When the soft-hide set is full this only
* lists candidates. It does not evict, reserve a slot, or touch renderer
* lifecycle. Waking a victim clears its marker before hibernate finishes, so
* the caller awaits that outside this helper. Wake replay order stays in
* resolveHibernateWakeHistory (#2762). Atlas clear on a soft wake stays in
* wakeSoftHiddenRuntime (#1063).
*/
export function decideHiddenTabKeepCountAction(input: {
keepCount: number;
softHiddenCount: number;
sessionId: string;
/** Oldest soft-hidden session first. Snapshot this before eviction mutates it. */
candidatesOldestFirst: readonly string[];
}): HiddenTabKeepCountDecision {
if (!(input.keepCount > 0)) {
return { action: "full-hibernate", candidates: [] };
}
if (input.softHiddenCount < input.keepCount) {
return { action: "soft-hide", candidates: [] };
}
const candidates = input.candidatesOldestFirst.filter((candidate) => (
Boolean(candidate) && candidate !== input.sessionId
));
if (candidates.length === 0) {
return { action: "full-hibernate", candidates: [] };
}
return { action: "await-eviction", candidates };
}
/** Snapshot + buffered output to replay when recreating xterm after hibernate. */
export type TerminalHibernateWakePayload = {
/** Full serialized terminal state -- preferred wake history (coherent ANSI). */
-3
View File
@@ -195,9 +195,6 @@ module.exports = {
'node_modules/@cursor/sdk-*/**/*',
'node_modules/sqlite3/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
// must resolve re2js from a real directory (issue #3910).
'node_modules/re2js/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'node_modules/zod/**/*',
+1 -26
View File
@@ -73,11 +73,6 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -212,26 +207,7 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
handleUserInput() {
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -248,7 +224,6 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
-149
View File
@@ -224,155 +224,6 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+7 -27
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,9 +1713,8 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput(data);
session.autoLogin?.handleUserInput();
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1806,8 +1805,6 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1820,7 +1817,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
const written = writeToSessionNow(payload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1833,24 +1830,20 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Classify auto-login at input ingress, before the asynchronous
// Cancel the auto-login detector at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
expectedSession.autoLogin?.handleUserInput();
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1858,19 +1851,6 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,32 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -87,221 +61,6 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.86";
version = "1.1.85";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-RE9aE79gEBFE2HnwJ3BIDA4vJoEhvx2T1RP7relNXtI=";
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-186ZW8LUDUJKLQAnz+WSE40tY7Rw+kctU+5iddgvrpM=";
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
};
};
}
-7
View File
@@ -128,13 +128,6 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
// externalMcpRedaction.cjs requires re2js. The launcher runs from
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
// that exist only inside app.asar (issue #3910).
assert.ok(
config.asarUnpack.includes("node_modules/re2js/**/*"),
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
);
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {