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
16 changed files with 60 additions and 995 deletions

No files matched your search

-185
View File
@@ -1,185 +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 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);
}
});
-45
View File
@@ -1,45 +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[];
keepCount: 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;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.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;
});
}, []);
+21 -67
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;
@@ -2365,40 +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,
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();
@@ -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");
@@ -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,29 +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.doesNotMatch(body, /resolveHiddenTabEviction/);
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
assert.match(
body,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
);
});
@@ -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). */