mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe2b0edc0 | ||
|
|
945147c600 |
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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] ?? "";
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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/**/*',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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=";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user