mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c64196670 | ||
|
|
114f80b6c4 | ||
|
|
57e48342e4 |
No files matched your search
@@ -1202,7 +1202,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Freeze trusted helper
|
||||
run: *freeze_ai_helper
|
||||
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
|
||||
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
|
||||
@@ -528,10 +528,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.jumpUntitled': '(empty message)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.searchSessions': 'Search titles and recent content…',
|
||||
'ai.chat.clearSessionSearch': 'Clear session search',
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
|
||||
@@ -519,10 +519,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.jumpUntitled': '(mensaje vacío)',
|
||||
'ai.chat.usedTools': 'Herramientas usadas: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
|
||||
'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…',
|
||||
'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones',
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
|
||||
@@ -475,10 +475,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.jumpUntitled': '(пустое сообщение)',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
|
||||
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
|
||||
@@ -528,10 +528,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.jumpUntitled': '(空消息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.searchSessions': '搜索标题和近期内容…',
|
||||
'ai.chat.clearSessionSearch': '清空会话搜索',
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
|
||||
@@ -518,10 +518,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.jumpUntitled': '(空訊息)',
|
||||
'ai.chat.usedTools': '已使用 {n} 個工具',
|
||||
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
|
||||
'ai.chat.searchSessions': '搜尋標題和近期內容…',
|
||||
'ai.chat.clearSessionSearch': '清空會話搜尋',
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
|
||||
@@ -466,115 +466,6 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
// Just over the 8 KiB cap: the truncation marker must not make the degraded
|
||||
// copy larger than the original, or the ladder could evict a session whose
|
||||
// unmodified JSON would have fit the budget.
|
||||
for (const oversize of [8193, 8266, 20000]) {
|
||||
const session = makeSession('near-cap', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
|
||||
}]);
|
||||
const fullJson = JSON.stringify([session]);
|
||||
const result = serializeSessionsForStorage([session], fullJson.length - 10);
|
||||
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
|
||||
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
|
||||
assert.ok(
|
||||
JSON.stringify(result.sessions).length < fullJson.length,
|
||||
`degraded copy smaller than original for ${oversize}`,
|
||||
);
|
||||
assert.ok(content.includes('truncated'));
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'still-open chat',
|
||||
timestamp: 2,
|
||||
}]);
|
||||
const toolBulk = makeSession('tool-bulk', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
|
||||
|
||||
// The bloated tool result must not evict the other visible chat.
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
|
||||
assert.ok(result.json.length <= 100 * 1024);
|
||||
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
|
||||
assert.ok(persistedToolContent);
|
||||
assert.ok(persistedToolContent.length <= 9 * 1024);
|
||||
assert.ok(persistedToolContent.includes('truncated'));
|
||||
assert.ok(persistedToolContent.startsWith('zzz'));
|
||||
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: index,
|
||||
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(
|
||||
persisted.map(session => session.id),
|
||||
sessions.map(session => session.id),
|
||||
);
|
||||
// Degradation sheds only as many messages as the budget demands, so at
|
||||
// least one chat keeps its full tool output while the persisted set stays
|
||||
// complete. Assert completion first; truncation second.
|
||||
const shedContents = persisted.map(
|
||||
session => session.messages[0].toolResults?.[0].content ?? '',
|
||||
);
|
||||
assert.ok(shedContents.some(content => content.includes('truncated')));
|
||||
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
|
||||
@@ -195,40 +195,12 @@ const RETRY_SESSIONS_JSON_BYTES = [
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
@@ -246,7 +218,7 @@ function stripReasoningEncryptedContent(
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
@@ -267,22 +239,6 @@ function stripReasoningCiphertextFromMessage(message: AISession['messages'][numb
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
@@ -303,7 +259,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
@@ -354,11 +310,6 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
@@ -370,13 +321,13 @@ export function serializeSessionsForStorage(
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
@@ -388,7 +339,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
degradableMessages,
|
||||
ciphertextMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -413,12 +364,11 @@ export function serializeSessionsForStorage(
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
@@ -428,27 +378,27 @@ export function serializeSessionsForStorage(
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
}
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
|
||||
@@ -8,20 +8,10 @@ import {
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("backend exited events close the session tab only when auto-close is enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
|
||||
// The fallback for callers that have not loaded the terminal setting yet:
|
||||
// keep the tab so a jump host / bastion reaping a session (a clean exit)
|
||||
// never silently deletes the user's scrollback.
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,19 +57,11 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative exit-signal disconnects keep the tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
|
||||
});
|
||||
|
||||
test("disabled auto-close keeps command and attached terminal popups open", () => {
|
||||
@@ -151,17 +133,6 @@ test("exit messages show the reported code for a real remote exit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
@@ -179,25 +150,3 @@ test("exit messages without a reported code stay plain instead of showing a sent
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,10 +4,7 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
|
||||
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
|
||||
// terminated the session process. Distinct from a transport "closed".
|
||||
exitSignal?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
@@ -19,13 +16,6 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
@@ -44,11 +34,7 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
// Keep-tab is the safe default: a caller that has not loaded the terminal
|
||||
// setting yet must not silently delete a session tab. Remote graceful
|
||||
// channel closes (e.g. a jump host reaping an idle session) are reported
|
||||
// as a clean exit, so an unavailable setting must not re-enable closing.
|
||||
autoCloseOnExit = false,
|
||||
autoCloseOnExit = true,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -1,790 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectSessionSearchFields,
|
||||
type SessionHistorySearchMessage,
|
||||
} from "../../domain/sessionHistorySearch.ts";
|
||||
import {
|
||||
PINYIN_CACHE_MAX_CHARS,
|
||||
getPinyinCacheStatsForTests,
|
||||
resetPinyinCacheForTests,
|
||||
} from "../../lib/searchMatcher.ts";
|
||||
import { createSessionHistorySearch } from "./sessionHistorySearch.ts";
|
||||
|
||||
// One instance for the whole file: it keeps the pre-factory semantics of every
|
||||
// test sharing (and clearing) a single cache.
|
||||
const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch();
|
||||
|
||||
// Fixture messages stay loosely typed: persisted sessions carry far richer
|
||||
// shapes (role, ids, payload fields, …) than the minimal searchable shapes,
|
||||
// and extra properties must remain structurally harmless.
|
||||
function createSession(
|
||||
id: string,
|
||||
title: string,
|
||||
messages: Array<Record<string, unknown>>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
messages: messages.map((message, index) => ({
|
||||
id: `${id}-m${index}`,
|
||||
role: "user",
|
||||
content: "",
|
||||
timestamp: index,
|
||||
...message,
|
||||
})) as SessionHistorySearchMessage[],
|
||||
};
|
||||
}
|
||||
|
||||
test("filterSessionHistory matches titles and keeps order when query is blank", () => {
|
||||
const sessions = [
|
||||
createSession("a", "nginx restart", []),
|
||||
createSession("b", "disk cleanup", []),
|
||||
];
|
||||
|
||||
for (const query of ["", " \t\n"]) {
|
||||
assert.deepEqual(filterSessionHistory(sessions, query), sessions);
|
||||
}
|
||||
});
|
||||
|
||||
test("filterSessionHistory handles special characters through the shared matcher", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]),
|
||||
createSession("b", "Deploy", [{ content: "ship it" }]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches message content of user and assistant turns", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Debugging", [
|
||||
{ role: "user", content: "why is sshd down on prod-3?" },
|
||||
{ role: "assistant", content: "let me check the journal" },
|
||||
]),
|
||||
createSession("b", "Deploy notes", [
|
||||
{ role: "user", content: "ship release 1.2" },
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory ignores hidden system messages", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{ role: "system", content: "shared hidden prompt with marker text" },
|
||||
{ role: "user", content: "all clear" },
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{ role: "system", content: "shared hidden prompt with marker text" },
|
||||
{ role: "user", content: "ship it" },
|
||||
]),
|
||||
];
|
||||
|
||||
// A query found only inside system messages must not surface the sessions:
|
||||
// the reopened conversation hides those messages, so a match would display
|
||||
// no matching text.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "marker text"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory searches thinking and tool call/result content", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{ role: "assistant", thinking: "the certificate expired" },
|
||||
]),
|
||||
createSession("b", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }],
|
||||
toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches text stored only in tool call arguments", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t1",
|
||||
arguments: { command: "systemctl restart nginx" },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{ name: "file", id: "t2", arguments: {} }],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
assert.ok(fields.some((field) => field.includes("systemctl restart nginx")));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted agent activities", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Research", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{ id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("b", "Refactor", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{
|
||||
id: "2",
|
||||
type: "file_change",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{ path: "src/sessionStore.ts", kind: "update" },
|
||||
{ path: "src/sessionStore.test.ts", kind: "add" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("c", "Plan", [
|
||||
{
|
||||
role: "assistant",
|
||||
agentActivities: [
|
||||
{
|
||||
id: "3",
|
||||
type: "plan_update",
|
||||
status: "completed",
|
||||
items: [
|
||||
{ text: "extract search collector", completed: true },
|
||||
{ text: "wire into drawer", completed: false },
|
||||
],
|
||||
},
|
||||
{ id: "4", type: "warning", status: "completed", message: "rate limit hit" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[2]);
|
||||
assert.ok(fields.some((field) => field.includes("extract search collector")));
|
||||
assert.ok(fields.some((field) => field.includes("rate limit hit")));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted error messages and attachment labels", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true },
|
||||
},
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "please summarize",
|
||||
attachments: [
|
||||
{ base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" },
|
||||
{ base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("c", "Legacy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "see attached",
|
||||
images: [
|
||||
{ base64Data: "", mediaType: "image/png", filename: "screenshot.png" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
createSession("d", "Payloads", [
|
||||
{
|
||||
role: "user",
|
||||
content: "",
|
||||
attachments: [
|
||||
{ base64Data: "AAAA", mediaType: "image/png", terminalSelection: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]);
|
||||
// Attachment base64 payloads and unlabeled attachments are not indexed.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[1]);
|
||||
assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml")));
|
||||
assert.ok(fields.some((field) => field.includes("Postgres runbook")));
|
||||
assert.ok(!fields.some((field) => field === "AAAA"));
|
||||
});
|
||||
|
||||
test("budget exhaustion inside a message keeps the newest tool calls searchable", () => {
|
||||
// One assistant message bearing enough tool calls (each argument capped at
|
||||
// 2,000 chars) to exhaust the remaining session budget: iteration must be
|
||||
// newest-first so the latest calls (appended at the array end) stay
|
||||
// searchable instead of the oldest ones winning the budget.
|
||||
const session = createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "y".repeat(20_000),
|
||||
toolCalls: Array.from({ length: 25 }, (_, i) => ({
|
||||
name: `call-${i}`,
|
||||
id: `t${i}`,
|
||||
arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` },
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
// The newest call (index 24, appended last) stays searchable along with the
|
||||
// rest of the recent burst; the oldest call is the one dropped.
|
||||
assert.ok(fields.some((field) => field.includes("marker-args-24")));
|
||||
assert.ok(fields.some((field) => field.includes("marker-args-19")));
|
||||
assert.ok(!fields.some((field) => field.includes("marker-args-0")));
|
||||
assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]);
|
||||
// Retained call fields are output in chronological order after the
|
||||
// newest-first collection is reversed back (call-10 before call-19).
|
||||
const call10 = fields.findIndex((field) => field.includes("call-10"));
|
||||
const call19 = fields.findIndex((field) => field.includes("call-19"));
|
||||
assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19);
|
||||
});
|
||||
|
||||
test("later attachment labels stay searchable when the label list overflows the cap", () => {
|
||||
// The joined labels of these attachments exceed the 2,000-character field
|
||||
// cap; feeding labels individually keeps the later (newest) ones findable.
|
||||
const session = createSession("a", "Deploy", [
|
||||
{
|
||||
role: "user",
|
||||
content: "please summarize",
|
||||
attachments: [
|
||||
{ base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` },
|
||||
{ base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.ok(fields.some((field) => field.includes("latest-run.log")));
|
||||
assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields skips empty content and caps very long fields", () => {
|
||||
const session = createSession("a", " ", [
|
||||
{ role: "user", content: " " },
|
||||
{ role: "assistant", content: "x".repeat(25_000) },
|
||||
]);
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.equal(fields.length, 1);
|
||||
assert.equal(fields[0].length, 20_000);
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => {
|
||||
const sessions = [
|
||||
createSession("a", "重启生产服务器", []),
|
||||
createSession("b", "backup", []),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => {
|
||||
// An empty persisted title is displayed as the localized "Untitled" label;
|
||||
// searching for that label must find the session instead of hiding it.
|
||||
const sessions = [
|
||||
createSession("a", "", [{ content: "nginx notes" }]),
|
||||
createSession("b", "nginx restart", []),
|
||||
];
|
||||
|
||||
// Without the fallback the blank title contributes nothing.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "untitled"), []);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]);
|
||||
// Titled sessions are unaffected by the fallback label.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]);
|
||||
// Localized (CJK) fallback labels match through title pinyin too.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0], "Untitled");
|
||||
assert.deepEqual(fields, ["Untitled", "nginx notes"]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory lets tokens span title pinyin and message text", () => {
|
||||
const sessions = [
|
||||
createSession("a", "重启服务器", [
|
||||
{ role: "user", content: "restarted nginx without errors" },
|
||||
]),
|
||||
];
|
||||
|
||||
const combined = filterSessionHistory(sessions, "chongqi nginx");
|
||||
assert.deepEqual(combined, [sessions[0]]);
|
||||
// Message-only pinyin still stays out of the expensive fallback.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]);
|
||||
assert.deepEqual(
|
||||
filterSessionHistory(sessions, "failing nginx"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("search field trimming stays inside the field and remaining session limits", () => {
|
||||
const fieldLimited = createSession("a", "Ops", [
|
||||
{ content: " ".repeat(20_000) + "outside-field" },
|
||||
{ content: " recent-match " + " ".repeat(1_000_000) },
|
||||
]);
|
||||
assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]);
|
||||
assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]);
|
||||
assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []);
|
||||
|
||||
// Title + three full fields leave 3,997 characters for the oldest message.
|
||||
// Trimming must not reach past that window to discover more content.
|
||||
const sessionLimited = createSession("b", "Ops", [
|
||||
{ content: " ".repeat(3_997) + "outside-session" },
|
||||
...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })),
|
||||
]);
|
||||
assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003);
|
||||
assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []);
|
||||
});
|
||||
|
||||
test("filterSessionHistory keeps the pinyin fallback on titles only", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{ role: "user", content: "重启生产服务器" },
|
||||
]),
|
||||
];
|
||||
|
||||
// Pinyin transliteration of long message content is skipped; literal CJK
|
||||
// queries still match message text.
|
||||
assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields bounds the total searchable text per session", () => {
|
||||
const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({
|
||||
role: "user" as const,
|
||||
content: `chunk-${i}-${"y".repeat(19_000)}`,
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
||||
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
||||
// Title is indexed first so it always lands in the bounded window.
|
||||
assert.equal(fields[0], "big session");
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields keeps recent messages searchable under the cap", () => {
|
||||
// Four 20,000-char messages exceed the 64 KB cap; the budget must be
|
||||
// allocated newest-first so the most recent message remains searchable.
|
||||
const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({
|
||||
role: "user" as const,
|
||||
content: `${"x".repeat(19_900)} marker-${i}`,
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
assert.equal(fields[0], "big session");
|
||||
// Oldest messages are dropped, the newest is retained.
|
||||
assert.ok(!fields.some((field) => field.includes("marker-0")));
|
||||
assert.ok(fields.some((field) => field.includes("marker-3")));
|
||||
// Output order stays chronological (marker-1 before marker-2).
|
||||
const marker1 = fields.findIndex((field) => field.includes("marker-1"));
|
||||
const marker2 = fields.findIndex((field) => field.includes("marker-2"));
|
||||
assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2);
|
||||
});
|
||||
|
||||
test("collectSessionSearchFields truncates a field to the remaining session budget", () => {
|
||||
// Four 19,000-char messages plus the title overflow the 64 KB cap; the last
|
||||
// collected (oldest) message must be truncated to exactly the remaining
|
||||
// budget instead of being discarded wholesale.
|
||||
const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({
|
||||
role: "user" as const,
|
||||
content: "a".repeat(19_000),
|
||||
})));
|
||||
|
||||
const fields = collectSessionSearchFields(session);
|
||||
const total = fields.reduce((sum, field) => sum + field.length, 0);
|
||||
assert.ok(total <= 64_000, `total ${total} exceeds session cap`);
|
||||
assert.equal(total, 64_000);
|
||||
// The oldest message fills the leftover budget with its freshest-available
|
||||
// head instead of being dropped entirely.
|
||||
assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000)));
|
||||
assert.ok(fields.some((field) => field.startsWith("aaa")));
|
||||
});
|
||||
|
||||
test("huge tool call arguments are serialized within the field cap without full materialization", () => {
|
||||
const blob = "x".repeat(5_000_000);
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
// Retained calls can carry uncapped payloads (e.g. `sftp_write_file`
|
||||
// content); serialization must stop at the field cap instead of
|
||||
// stringifying the whole argument object on every keystroke.
|
||||
name: "sftp_write_file",
|
||||
id: "t1",
|
||||
arguments: { path: "/tmp/report.md", content: blob },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
const serialized = fields.find((field) => field.includes("/tmp/report.md"));
|
||||
assert.ok(serialized);
|
||||
// The serialized field (plus the tool name) must land under the total
|
||||
// per-session budget cap, i.e. no multi-megabyte field is ever emitted.
|
||||
assert.ok(serialized!.length <= 2_000);
|
||||
assert.ok(serialized!.includes("/tmp/report.md"));
|
||||
|
||||
// Short payloads still serialize fully and stay searchable.
|
||||
const searchable = createSession("b", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t2",
|
||||
arguments: { command: "systemctl restart nginx" },
|
||||
}],
|
||||
},
|
||||
]);
|
||||
assert.ok(
|
||||
collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')),
|
||||
);
|
||||
});
|
||||
|
||||
test("wide tool call argument objects and long property names stay within the cap", () => {
|
||||
// A wide object: thousands of own properties must be iterated lazily
|
||||
// instead of materializing the full entries array on every keystroke.
|
||||
const wide: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`;
|
||||
// An unusually large property name must be capped before it is escaped.
|
||||
const longKey = "k".repeat(50_000);
|
||||
const sessions = [
|
||||
createSession("a", "Ops", [
|
||||
{
|
||||
role: "assistant",
|
||||
toolCalls: [{
|
||||
name: "shell",
|
||||
id: "t1",
|
||||
arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide },
|
||||
}],
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
const serialized = fields.find((field) => field.includes("systemctl restart nginx"));
|
||||
assert.ok(serialized);
|
||||
assert.ok(serialized!.length <= 2_000);
|
||||
// The oversized key (and the wide object past the cap) never materialize:
|
||||
// the long key is truncated before escaping.
|
||||
assert.ok(!fields.some((field) => field.length > 2_000));
|
||||
});
|
||||
|
||||
test("filterSessionHistory matches persisted status text", () => {
|
||||
const sessions = [
|
||||
createSession("a", "Untitled", [
|
||||
{ role: "assistant", content: "", statusText: "generating diff for src/main.ts" },
|
||||
]),
|
||||
createSession("b", "Deploy", [
|
||||
{ role: "user", content: "ship it" },
|
||||
]),
|
||||
];
|
||||
|
||||
assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []);
|
||||
|
||||
const fields = collectSessionSearchFields(sessions[0]);
|
||||
assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts")));
|
||||
});
|
||||
|
||||
// Regression: bounds enforcement must run once per scan, not on every cache
|
||||
// miss. Evicting per miss makes each rebuild drop the index of a later
|
||||
// session in the same scan, so any history longer than the entry cap
|
||||
// cycle-evicts the whole cache and rebuilds every corpus on every keystroke.
|
||||
test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => {
|
||||
const sessionCount = 80;
|
||||
const sessions = Array.from({ length: sessionCount }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
// Observe corpus (re)builds: a cache hit serves the prepared haystack
|
||||
// without touching `messages`, while a rebuild collects the fields again.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
readSessions.clear();
|
||||
const second = filterSessionHistory(sessions, "deploy");
|
||||
|
||||
// End-of-scan eviction only prunes the per-scan overshoot: the indexes of
|
||||
// every session retained by the last prune are cache hits. Per-miss
|
||||
// eviction would have rebuilt all 80 sessions here.
|
||||
const rebuilt = readSessions.size;
|
||||
assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`);
|
||||
assert.deepEqual(second, sessions);
|
||||
// Eviction must retain the FRONT of the ranked list (the sessions the next
|
||||
// scan visits first) and rebuild only the oldest tail above the entry cap —
|
||||
// evicting the Map's first key would drop the head and thrash it instead.
|
||||
const rebuiltIds = [...readSessions].sort();
|
||||
assert.deepEqual(
|
||||
rebuiltIds,
|
||||
sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: hard-rejecting NEW entries once the entry cap is saturated by
|
||||
// live sessions froze the cache's membership. New sessions are prepended to
|
||||
// the ranked list, so every subsequently created conversation (and its
|
||||
// immutable streaming updates) would rebuild its index on every keystroke
|
||||
// while stale lower-ranked entries stayed cached — and the end-of-scan prune
|
||||
// could not correct it because the cache was already within both bounds.
|
||||
test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => {
|
||||
const initial = Array.from({ length: 64 }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
filterSessionHistory(initial, "deploy");
|
||||
|
||||
// A newly created session is prepended: it ranks above every cached entry.
|
||||
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the thrash regression test above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// First scan over the new list: "fresh" misses (its index was never cached)
|
||||
// and must be admitted; the prune evicts the lowest-ranked tail instead.
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
readSessions.clear();
|
||||
const third = filterSessionHistory(sessions, "deploy");
|
||||
|
||||
assert.deepEqual(third, sessions);
|
||||
// The fresh, highest-ranked session stays cached; only the evicted tail
|
||||
// above the entry cap is rebuilt.
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s63"]);
|
||||
});
|
||||
|
||||
// Regression: the entry cap must be enforced at cache-admission time, not only
|
||||
// by the end-of-scan prune. When misses are small (empty/short searchable
|
||||
// content), the char budget consumes almost no headroom per insert, so
|
||||
// overshooting the entry cap mid-scan and letting the prune trim it made a
|
||||
// single keystroke over a very long history build and transiently retain one
|
||||
// index + Map entry per live session. Admission must instead evict the stale
|
||||
// lowest-ranked untouched tail to make room (the same entry the prune would
|
||||
// otherwise drop moments later), keeping the cache within the entry cap
|
||||
// throughout the scan.
|
||||
test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => {
|
||||
const initial = Array.from({ length: 64 }, (_, i) =>
|
||||
createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }]));
|
||||
filterSessionHistory(initial, "deploy");
|
||||
|
||||
// A newly created session is prepended: it ranks above every cached entry.
|
||||
const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]);
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the regression tests above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The single scan over the prepended list must admit "fresh" by evicting
|
||||
// the stalest cached tail entry ("s63") during the scan: under the old
|
||||
// overshoot-then-prune behavior "s63" would stay cached until the prune,
|
||||
// so it would not be rebuilt in this same scan at all. Rebuilding it here
|
||||
// is the (bounded, one-entry) cost of never exceeding the entry cap
|
||||
// mid-scan.
|
||||
filterSessionHistory(sessions, "deploy");
|
||||
assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]);
|
||||
|
||||
// Recovery is unchanged: the fresh session stays cached, and only the
|
||||
// tail displaced below the entry cap rebuilds on the next keystroke.
|
||||
readSessions.clear();
|
||||
const next = filterSessionHistory(sessions, "deploy");
|
||||
assert.deepEqual(next, sessions);
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s63"]);
|
||||
});
|
||||
|
||||
// Regression: a hard-reject at the CHAR-budget gate froze cache membership
|
||||
// just like the earlier entry-cap bug. With ~20 large sessions (each near the
|
||||
// 64,000-character corpus cap) already consuming nearly the 4M-character
|
||||
// cache budget, a newly prepended high-ranked session was rejected without
|
||||
// evicting anything, and the end-of-scan prune — seeing a cache within both
|
||||
// bounds — kept the stale lower-ranked entries: the new large conversation
|
||||
// then rebuilt its index on every search keystroke.
|
||||
test("filterSessionHistory admits a newly created session after the char budget is saturated", () => {
|
||||
// Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each
|
||||
// index retaining ~192K chars (normalized fields + haystack + compact
|
||||
// haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it.
|
||||
const createLargeSession = (id: string) =>
|
||||
createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) })));
|
||||
|
||||
const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`));
|
||||
filterSessionHistory(initial, "x");
|
||||
|
||||
// A newly created large session is prepended: it ranks above every cached
|
||||
// entry, and its index can only fit if the stale tail is evicted.
|
||||
const created = createLargeSession("fresh");
|
||||
const sessions = [created, ...initial];
|
||||
|
||||
// Observe corpus (re)builds as in the regression tests above.
|
||||
const readSessions = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
const messages = session.messages;
|
||||
Object.defineProperty(session, "messages", {
|
||||
get() {
|
||||
readSessions.add(session.id);
|
||||
return messages;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// First scan over the new list: "fresh" misses (its index was never cached)
|
||||
// and must be admitted by evicting the stalest cached tail entry.
|
||||
filterSessionHistory(sessions, "x");
|
||||
readSessions.clear();
|
||||
const second = filterSessionHistory(sessions, "x");
|
||||
|
||||
assert.deepEqual(second, sessions);
|
||||
// The fresh, highest-ranked session stays cached; only the evicted stalest
|
||||
// tail session (which no longer fits the char budget) rebuilds.
|
||||
assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`);
|
||||
assert.deepEqual([...readSessions], ["s19"]);
|
||||
// And the steady state holds on subsequent scans too.
|
||||
readSessions.clear();
|
||||
filterSessionHistory(sessions, "x");
|
||||
assert.deepEqual([...readSessions], ["s19"]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => {
|
||||
const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]);
|
||||
const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]);
|
||||
|
||||
filterSessionHistory([kept, removed], "nginx");
|
||||
// Deleting a session then clearing the query goes through the blank path,
|
||||
// which must prune the cache against the remaining list.
|
||||
filterSessionHistory([kept], "");
|
||||
// Subsequent searches are unaffected: the surviving session stays cached
|
||||
// (its cached index is still there) and matching still works.
|
||||
assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]);
|
||||
assert.deepEqual(filterSessionHistory([kept], "nothing here"), []);
|
||||
// The removed session's index is gone entirely (no match via a stale cache).
|
||||
assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]);
|
||||
});
|
||||
|
||||
test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => {
|
||||
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
||||
|
||||
filterSessionHistory([session], "nginx");
|
||||
clearSessionHistorySearchCache();
|
||||
// After the clear, the next search rebuilds from scratch and still matches.
|
||||
assert.deepEqual(filterSessionHistory([session], "nginx"), [session]);
|
||||
assert.deepEqual(filterSessionHistory([session], ""), [session]);
|
||||
});
|
||||
|
||||
test("independent search instances own isolated caches", () => {
|
||||
const first = createSessionHistorySearch();
|
||||
const second = createSessionHistorySearch();
|
||||
const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]);
|
||||
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
// Clearing one instance must not disturb the other's state: both stay fully
|
||||
// functional afterwards (each store is private to its instance).
|
||||
second.clearSessionHistorySearchCache();
|
||||
assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]);
|
||||
});
|
||||
|
||||
test("filterSessionHistory caps the query and matches on its capped prefix", () => {
|
||||
const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]);
|
||||
|
||||
// A pasted multi-megabyte query must not be re-normalized per session: it is
|
||||
// capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's
|
||||
// input and normalized once per scan, so matching falls back to the capped
|
||||
// prefix (which still spans tokens here).
|
||||
const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`;
|
||||
assert.deepEqual(filterSessionHistory([session], longQuery), [session]);
|
||||
// The uncapped-looking but normal query still matches exactly.
|
||||
assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []);
|
||||
});
|
||||
|
||||
// Keep the author's long-title rescan protection local to history. Shared
|
||||
// search must still match complete fields, including later joined fields.
|
||||
test("history pinyin cache preserves long titles across same-order rescans", () => {
|
||||
resetPinyinCacheForTests();
|
||||
const sessions = Array.from({ length: 17 }, (_, index) =>
|
||||
createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, []));
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []);
|
||||
}
|
||||
const stats = getPinyinCacheStatsForTests();
|
||||
assert.equal(stats.size, sessions.length);
|
||||
assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS);
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
test("history alone bounds title pinyin while retaining literal title and body matches", () => {
|
||||
const sessions = [
|
||||
createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]),
|
||||
createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]),
|
||||
];
|
||||
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
|
||||
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
|
||||
});
|
||||
@@ -1,382 +0,0 @@
|
||||
import {
|
||||
getSessionSearchTitle,
|
||||
matchesSessionSearchIndex,
|
||||
prepareSessionSearchIndex,
|
||||
type SessionHistorySearchTarget,
|
||||
type SessionSearchIndex,
|
||||
} from '../../domain/sessionHistorySearch';
|
||||
import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher';
|
||||
|
||||
/**
|
||||
* Memory bounds for the per-session search index cache. `filterSessionHistory`
|
||||
* runs on every search keystroke, but `pruneSessionsForStorage` only bounds the
|
||||
* persisted copy — `useAIState` retains hundreds of live sessions in memory,
|
||||
* and rebuilding plus re-normalizing each session's (up to 64,000-character)
|
||||
* haystack per keystroke scans tens of megabytes and freezes the renderer.
|
||||
*
|
||||
* The raw collected fields are NOT retained (only the prepared search fields),
|
||||
* and
|
||||
* the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries
|
||||
* within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an
|
||||
* unbounded cache would let one entry per live session (several normalized
|
||||
* copies of each session's searchable corpus) permanently pin hundreds of
|
||||
* megabytes in a long-running process. Both bounds hold after every scan's
|
||||
* prune and are enforced eagerly on every insert (see
|
||||
* `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate
|
||||
* first evicts the cache's stale, lowest-ranked untouched tail (entries the
|
||||
* in-flight scan has not yet visited — they rank below every visited session,
|
||||
* so this can never cycle-evict an entry the same scan still needs); if no
|
||||
* such entry remains, the insert is skipped — the miss is then rebuilt on the
|
||||
* next search, exactly what the end-of-scan prune would have done to it.
|
||||
* Skipping is only reachable for misses ranked below every cached entry, so
|
||||
* cache membership never freezes for freshly prepended high-ranked sessions:
|
||||
* at their (earliest) visit nothing is touched yet and the stale tail yields.
|
||||
*/
|
||||
const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64;
|
||||
const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000;
|
||||
|
||||
/**
|
||||
* Mutable cache state owned by exactly one search client (one mounted history
|
||||
* drawer). Keeping this out of module scope means several concurrently mounted
|
||||
* drawers (one per terminal tab) never share a cache: each drawer's prune only
|
||||
* drops entries no longer in that drawer's scoped session list, and one
|
||||
* drawer's unmount releases only its own store (previously a shared
|
||||
* module-level cache was discarded for every drawer on any unmount and pruned
|
||||
* against whichever drawer scanned last).
|
||||
*/
|
||||
export type SessionHistorySearchStore = {
|
||||
/**
|
||||
* Sessions in state are updated immutably (`{ ...s, messages: next }`), so
|
||||
* object identity is a reliable cache key: each session's normalized/compact
|
||||
* haystacks are built once and reused until that session's object changes.
|
||||
* A changed display title (e.g. a localized fallback switch) forces a rebuild.
|
||||
*/
|
||||
cache: Map<SessionHistorySearchTarget, SessionSearchIndex>;
|
||||
/**
|
||||
* Session keys the in-flight `filterSessionHistory` scan has already visited
|
||||
* (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex`
|
||||
* never evicts a touched key: it either ranks above the current insert (it was
|
||||
* visited earlier in this newest-first scan) or it is the current session
|
||||
* itself (whose stale entry a rebuild is about to replace), and evicting either
|
||||
* would cycle-evict entries the same scan still needs.
|
||||
*/
|
||||
scanTouched: Set<SessionHistorySearchTarget>;
|
||||
/**
|
||||
* Running total of the cache's retained characters. Maintained at every
|
||||
* mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays
|
||||
* O(1) per miss instead of re-summing the whole cache on every keystroke
|
||||
* (mid-scan overshoot of the entry cap would otherwise make that sum O(n²)
|
||||
* across a scan).
|
||||
*/
|
||||
cacheChars: number;
|
||||
};
|
||||
|
||||
/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */
|
||||
function indexRetainedChars(index: SessionSearchIndex): number {
|
||||
// `normalizedFields`, `haystack` and `haystackCompact` are all retained
|
||||
// (`haystack` is `normalizedFields` joined, `haystackCompact` is derived
|
||||
// from `haystack`), so their combined length approximates the cache's
|
||||
// per-entry memory cost. Counting only the joined haystacks would
|
||||
// undercount by roughly a full copy of the corpus and let the char budget
|
||||
// retain ~50% more than advertised.
|
||||
let total = index.prepared.haystack.length + index.prepared.haystackCompact.length;
|
||||
for (const field of index.prepared.normalizedFields) total += field.length;
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a freshly built index may be inserted into the cache without
|
||||
* pushing either bound past its cap. Both bounds must fit eagerly — the prune
|
||||
* that re-enforces them runs once per scan, so admitting every prepared index
|
||||
* would let a single search over hundreds/thousands of uncached sessions
|
||||
* transiently pin the whole corpus (several normalized haystacks per session)
|
||||
* or hold one index and Map entry per live session — for empty/short sessions
|
||||
* the char budget consumes little room, so the entry cap would be enforced
|
||||
* only after the complete scan — before pruning, briefly allocating memory
|
||||
* for the whole scanned list.
|
||||
*
|
||||
* Rather than rejecting inserts outright (which froze cache membership the
|
||||
* same way a hard entry-count gate would), lack of headroom in either bound
|
||||
* first evicts the cache's stale tail — the lowest-ranked entries this scan
|
||||
* has not yet visited. Because the scan visits the ranked list newest-first,
|
||||
* every untouched entry ranks below the current insert, so evicting any of
|
||||
* them never drops an index a later visit of the same scan still needs (no
|
||||
* cycle thrash) and always preserves the highest-ranked cached prefix. Only
|
||||
* if no untouched entry remains does the insert get skipped — that is
|
||||
* reachable only for misses ranked below every cached entry, which the
|
||||
* end-of-scan prune would have evicted from the Map's back anyway, so the
|
||||
* skip changes no steady-state membership: the index is simply rebuilt on
|
||||
* the next search.
|
||||
*
|
||||
* The bounds check is applied to NEW entries only: cache hits merely
|
||||
* re-append an existing entry (no growth), and a rebuild replaces the same
|
||||
* session's entry (no entry-count growth; the char delta against the
|
||||
* replaced index is checked).
|
||||
*/
|
||||
function canCacheSessionSearchIndex(
|
||||
store: SessionHistorySearchStore,
|
||||
previous: SessionSearchIndex | undefined,
|
||||
index: SessionSearchIndex,
|
||||
): boolean {
|
||||
const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0);
|
||||
// A rebuild replaces its own entry (no entry-count growth); a new entry
|
||||
// grows the Map by one.
|
||||
const growsEntries = !previous;
|
||||
if (
|
||||
deltaChars <= 0
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan
|
||||
// the Map's order is old-insertion-order front to back with this scan's
|
||||
// touched keys re-appended at the end, so scanning the keys back-to-front
|
||||
// and skipping touched ones reaches the untouched (stalest, lowest-ranked)
|
||||
// entries first.
|
||||
let entriesToFree = growsEntries
|
||||
? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|
||||
: 0;
|
||||
const keysInInsertionOrder = [...store.cache.keys()];
|
||||
for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) {
|
||||
if (entriesToFree <= 0
|
||||
&& store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) {
|
||||
break;
|
||||
}
|
||||
const key = keysInInsertionOrder[i];
|
||||
if (store.scanTouched.has(key)) continue;
|
||||
const evicted = store.cache.get(key);
|
||||
if (!evicted) continue;
|
||||
store.cacheChars -= indexRetainedChars(evicted);
|
||||
store.cache.delete(key);
|
||||
if (growsEntries) entriesToFree--;
|
||||
}
|
||||
return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
&& (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES);
|
||||
}
|
||||
|
||||
function getSessionSearchIndex(
|
||||
store: SessionHistorySearchStore,
|
||||
session: SessionHistorySearchTarget,
|
||||
untitledLabel: string,
|
||||
): SessionSearchIndex {
|
||||
const indexedTitle = getSessionSearchTitle(session, untitledLabel);
|
||||
const cached = store.cache.get(session);
|
||||
if (cached && cached.displayTitle === indexedTitle) {
|
||||
// Re-append on every visit so the cache's insertion order mirrors the
|
||||
// latest scan's visitation order (the ranked, newest-first session list);
|
||||
// `pruneSessionSearchIndexCache` relies on that order when evicting.
|
||||
store.cache.delete(session);
|
||||
store.cache.set(session, cached);
|
||||
store.scanTouched.add(session);
|
||||
return cached;
|
||||
}
|
||||
const index = prepareSessionSearchIndex(session, untitledLabel);
|
||||
// Mark the session touched before the gate so the gate's admission eviction
|
||||
// can never pick this session's own (stale) entry — the rebuild below is
|
||||
// about to replace it, and evicting it would corrupt the running char total.
|
||||
store.scanTouched.add(session);
|
||||
if (canCacheSessionSearchIndex(store, cached, index)) {
|
||||
store.cache.set(session, index);
|
||||
// Keep the eager gate's running total in sync (also covers the
|
||||
// display-title-change rebuild, which replaces an existing entry).
|
||||
store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eviction runs once per full scan (at the end of `filterSessionHistory`), not
|
||||
* on every cache miss: evicting during the scan would make each miss drop the
|
||||
* index of a later session in the same scan, so any history longer than the
|
||||
* entry cap would cycle-evict the whole cache and rebuild every session's
|
||||
* haystack on every keystroke. Deferring eviction keeps every retained entry
|
||||
* usable within a scan while still bounding the cache — first by dropping
|
||||
* indexes for sessions no longer in the scanned list, then by evicting the
|
||||
* lowest-ranked entries (the oldest tail of the newest-first ranked list)
|
||||
* until both bounds hold, preserving the front of the ranked list.
|
||||
*
|
||||
* In practice the eviction loop is cheap: both bounds are enforced eagerly
|
||||
* on every insert (see `canCacheSessionSearchIndex`), so the cache never
|
||||
* overshoots the entry cap or char budget mid-scan — the prune only drops
|
||||
* indexes for sessions that left the scanned list and re-syncs the running
|
||||
* char total.
|
||||
*/
|
||||
function pruneSessionSearchIndexCache(
|
||||
store: SessionHistorySearchStore,
|
||||
sessions: readonly SessionHistorySearchTarget[],
|
||||
): void {
|
||||
const live = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const key of [...store.cache.keys()]) {
|
||||
if (!live.has(key)) store.cache.delete(key);
|
||||
}
|
||||
let retained = 0;
|
||||
for (const cachedIndex of store.cache.values()) {
|
||||
retained += indexRetainedChars(cachedIndex);
|
||||
}
|
||||
// Re-sync the eager gate's running total: the sum above is authoritative
|
||||
// here (once per scan), so any drift accumulated mid-scan is corrected.
|
||||
store.cacheChars = retained;
|
||||
// Evict from the END of the Map until both bounds hold. Every scan visits
|
||||
// the full ranked session list newest-first and `getSessionSearchIndex`
|
||||
// re-appends each visited entry, so after a scan the Map's insertion order
|
||||
// mirrors visitation order: the highest-ranked (newest) sessions sit at the
|
||||
// FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on
|
||||
// Map order) would therefore drop the newest, highest-ranked sessions and
|
||||
// retain the stalest tail — every subsequent keystroke would rebuild every
|
||||
// session above the cache cap. Evicting the back instead preserves the
|
||||
// front of the ranked list. The single remaining entry is always kept, even
|
||||
// if it alone exceeds the char budget (a single entry is bounded by ~192K
|
||||
// chars by the collector caps).
|
||||
// Snapshot the keys once: prune only deletes (never inserts), so popping
|
||||
// from this array visits keys exactly in Map order, back to front.
|
||||
const keysInInsertionOrder = [...store.cache.keys()];
|
||||
while (
|
||||
store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES
|
||||
|| retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS
|
||||
) {
|
||||
// A single entry can never push the cache past the char budget (it is
|
||||
// bounded by ~192K chars by the collector caps), so keep the last entry.
|
||||
if (store.cache.size <= 1) break;
|
||||
const evictKey = keysInInsertionOrder.pop();
|
||||
if (evictKey === undefined) break;
|
||||
const evicted = store.cache.get(evictKey);
|
||||
if (evicted) retained -= indexRetainedChars(evicted);
|
||||
store.cache.delete(evictKey);
|
||||
}
|
||||
store.cacheChars = retained;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every cached index and touched-session reference for one store
|
||||
* (invoked for the drawer's unmount lifecycle via the per-drawer search
|
||||
* instance). Cache keys are the session objects themselves, so clearing fully
|
||||
* drops all strong session references — the retained haystack characters
|
||||
* become garbage together with their sessions.
|
||||
*/
|
||||
function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void {
|
||||
store.cache.clear();
|
||||
store.scanTouched.clear();
|
||||
store.cacheChars = 0;
|
||||
}
|
||||
|
||||
export type SessionHistorySearchOptions = {
|
||||
/**
|
||||
* Localized fallback label rendered by the drawer for sessions with an
|
||||
* empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the
|
||||
* displayed title so searching for the visible label finds the session.
|
||||
*/
|
||||
untitledLabel?: string;
|
||||
};
|
||||
|
||||
function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
|
||||
store: SessionHistorySearchStore,
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
options: SessionHistorySearchOptions = {},
|
||||
): T[] {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
// The blank-query path skips the scan entirely; release any stale
|
||||
// scan-touched state so it cannot pin visited sessions (and their
|
||||
// message/attachment memory) while the drawer is open without a query.
|
||||
// Normally this set is already empty — the nonblank scan clears it in its
|
||||
// `finally` — but clearing here too keeps every entry path leak-free.
|
||||
store.scanTouched.clear();
|
||||
// Cache keys are the session objects themselves, so an entry for a
|
||||
// deleted/replaced session strongly retains that session (and its full
|
||||
// message/attachment payloads). Prune against the current list here too:
|
||||
// the blank-query path may run while the drawer stays open after
|
||||
// deletions, and a later nonblank search that would prune otherwise may
|
||||
// never happen (e.g. the user just closes the drawer).
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
return [...sessions];
|
||||
}
|
||||
const untitledLabel = options.untitledLabel ?? '';
|
||||
// Bound and prepare the query once per scan: a pasted-in multi-megabyte
|
||||
// query would otherwise be re-normalized (NFKC), tokenized and compacted
|
||||
// inside `matchesSessionSearchIndex` for every session, freezing the
|
||||
// renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is
|
||||
// applied here at this scan's input — not inside the shared `prepareSearchQuery`
|
||||
// — so the meaning of over-limit queries stays local to session history and
|
||||
// the normalized forms are computed a single time, letting the per-session
|
||||
// work below only do `includes` scans over the cached haystacks.
|
||||
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed));
|
||||
// A new scan starts with no visited sessions: the touched set records only
|
||||
// this scan's visits so `canCacheSessionSearchIndex` can never mistake a
|
||||
// previous scan's keys for keys the current scan still needs.
|
||||
store.scanTouched.clear();
|
||||
try {
|
||||
// Drop entries still held by sessions that left the scan list (e.g. the
|
||||
// history shrank or session objects changed since the previous scan). Those
|
||||
// entries are never visited by this scan — the end-of-scan prune would
|
||||
// remove them too — but dropping them up front keeps their characters
|
||||
// available to this scan's inserts (via the eager char gate). Cheap: one
|
||||
// Set plus one pass over the cached keys.
|
||||
const currentLive = new Set<SessionHistorySearchTarget>(sessions);
|
||||
for (const key of [...store.cache.keys()]) {
|
||||
if (!currentLive.has(key)) {
|
||||
const removed = store.cache.get(key);
|
||||
if (removed) store.cacheChars -= indexRetainedChars(removed);
|
||||
store.cache.delete(key);
|
||||
}
|
||||
}
|
||||
// Scan all sessions first (a miss admits its index into the cache —
|
||||
// evicting this scan's untouched stale tail whenever either bound lacks
|
||||
// headroom, and skipping the insert only when no untouched entry remains),
|
||||
// then prune entries that fell out of the scan list at the end of the
|
||||
// scan.
|
||||
const result = sessions.filter((session) => {
|
||||
const index = getSessionSearchIndex(store, session, untitledLabel);
|
||||
return matchesSessionSearchIndex(preparedQuery, index);
|
||||
});
|
||||
// Prune against the full scanned list (not the filtered result): sessions
|
||||
// that merely did not match this query must keep their cached indexes.
|
||||
pruneSessionSearchIndexCache(store, sessions);
|
||||
return result;
|
||||
} finally {
|
||||
// The touched set only guards against cycle-eviction within the current
|
||||
// scan; once the scan ends (even if it throws), release its strong
|
||||
// references to session objects so deleted/replaced sessions and their
|
||||
// large message/attachment payloads aren't retained until the next
|
||||
// nonblank search.
|
||||
store.scanTouched.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One stateful search instance per consumer (one mounted session history
|
||||
* drawer). The cache lives in the returned instance's store instead of module
|
||||
* scope, so concurrently mounted drawers (one per terminal tab) never share
|
||||
* state: an unmount of one drawer clears only that drawer's indexes, and each
|
||||
* drawer's prune is scoped to its own session list.
|
||||
*/
|
||||
export type SessionHistorySearch = {
|
||||
filterSessionHistory: <T extends SessionHistorySearchTarget>(
|
||||
sessions: readonly T[],
|
||||
query: string,
|
||||
options?: SessionHistorySearchOptions,
|
||||
) => T[];
|
||||
clearSessionHistorySearchCache: () => void;
|
||||
};
|
||||
|
||||
export function createSessionHistorySearch(): SessionHistorySearch {
|
||||
const store: SessionHistorySearchStore = {
|
||||
cache: new Map(),
|
||||
scanTouched: new Set(),
|
||||
cacheChars: 0,
|
||||
};
|
||||
return {
|
||||
filterSessionHistory(sessions, query, options = {}) {
|
||||
return filterSessionHistoryStore(store, sessions, query, options);
|
||||
},
|
||||
clearSessionHistorySearchCache() {
|
||||
clearSessionHistorySearchStore(store);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,14 +9,9 @@ import {
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
function installMemoryLocalStorage(): Map<string, string> {
|
||||
@@ -145,72 +140,3 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
|
||||
"--ring": "270 70% 45%",
|
||||
});
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(migrated.autoCloseOnExit, false);
|
||||
// Other saved settings pass through untouched.
|
||||
assert.equal(migrated.scrollback, 3000);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
false,
|
||||
);
|
||||
// The user re-enables after upgrading: the migration must not undo it again.
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
// Fresh install: no saved settings at all.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
|
||||
// Explicit-off (or any settings blob without the key) is never rewritten.
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const store2 = installMemoryLocalStorage();
|
||||
store2.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 500 }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
|
||||
scrollback: 500,
|
||||
});
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
TERMINAL_FONT_AUTO,
|
||||
@@ -67,30 +63,6 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
/**
|
||||
* One-time migration for the autoCloseOnExit default flip (tabs now survive
|
||||
* session exits by default; the old default-on silently deleted tabs whenever
|
||||
* a bastion or idle-harvesting server reported a clean exit). Builds before
|
||||
* the flip shipped autoCloseOnExit: true as the default and materialized it
|
||||
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
|
||||
* carry a value they never chose. Leaving it in place would make the new
|
||||
* default unreachable for them. Reset that materialized `true` exactly once
|
||||
* and persist the marker; afterwards every stored value is treated as the
|
||||
* user's own choice and is respected. An inbound cloud or backup payload that
|
||||
* still carries the pre-migration materialized true (no
|
||||
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
|
||||
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
|
||||
* before this release must re-enable it once.
|
||||
*/
|
||||
export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
|
||||
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (stored?.autoCloseOnExit === true) {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
|
||||
}
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
|
||||
import { UploadController } from "../../../lib/uploadService";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -37,64 +37,3 @@ test("external upload controls survive their originating panel unmount", async (
|
||||
assert.equal(getExternalUploadController("folder-1"), undefined);
|
||||
assert.equal(getExternalUploadController("child-1"), undefined);
|
||||
});
|
||||
|
||||
for (const selectedFile of ["a.bin", "c.bin"]) {
|
||||
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
|
||||
resetExternalUploadRuntimeForTests();
|
||||
t.after(resetExternalUploadRuntimeForTests);
|
||||
const controller = new UploadController();
|
||||
const cancelled: string[] = [];
|
||||
const stagedCancelled: string[] = [];
|
||||
const ids = new Map<string, string>();
|
||||
const pending = new Map<string, (cancelled?: boolean) => void>();
|
||||
const started: string[] = [];
|
||||
const upload = uploadEntriesDirect(
|
||||
["a.bin", "b.bin", "c.bin"].map((name) => ({
|
||||
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
|
||||
})),
|
||||
{
|
||||
targetPath: "/remote", sftpId: "fixture", isLocal: false,
|
||||
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onTaskCreated: (task) => {
|
||||
ids.set(task.fileName, task.id);
|
||||
registerExternalUploadController(task.id, controller, "file");
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
|
||||
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
|
||||
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
|
||||
started.push(sourcePath);
|
||||
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
|
||||
}),
|
||||
},
|
||||
},
|
||||
controller,
|
||||
);
|
||||
t.after(async () => {
|
||||
await controller.cancel();
|
||||
for (const finish of pending.values()) finish(true);
|
||||
await upload;
|
||||
});
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
|
||||
const selectedId = ids.get(selectedFile)!;
|
||||
await cancelExternalUploadRuntime(selectedId);
|
||||
assert.deepEqual(cancelled, [selectedId]);
|
||||
assert.deepEqual(stagedCancelled, [selectedId]);
|
||||
assert.equal(controller.isCancelled(), false);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, selectedFile === "a.bin"
|
||||
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
|
||||
: ["/local/a.bin", "/local/b.bin"]);
|
||||
for (const [id, finish] of pending) if (id !== selectedId) finish();
|
||||
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
|
||||
fileName === selectedFile
|
||||
? { fileName, success: false, cancelled: true }
|
||||
: { fileName, success: true }
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -5,13 +5,11 @@ import type { UploadController } from "../../../lib/uploadService";
|
||||
// terminal only removes the view; the global transfer runtime can still cancel
|
||||
// the upload, and the upload's own finally block releases these entries.
|
||||
const controllersByTaskId = new Map<string, UploadController>();
|
||||
const scopesByTaskId = new Map<string, "file" | "batch">();
|
||||
const taskIdsByController = new Map<UploadController, Set<string>>();
|
||||
|
||||
export function registerExternalUploadController(
|
||||
taskId: string,
|
||||
controller: UploadController,
|
||||
scope: "file" | "batch" = "batch",
|
||||
): void {
|
||||
if (!taskId) return;
|
||||
const previous = controllersByTaskId.get(taskId);
|
||||
@@ -21,7 +19,6 @@ export function registerExternalUploadController(
|
||||
if (previousIds?.size === 0) taskIdsByController.delete(previous);
|
||||
}
|
||||
controllersByTaskId.set(taskId, controller);
|
||||
scopesByTaskId.set(taskId, scope);
|
||||
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
|
||||
taskIds.add(taskId);
|
||||
taskIdsByController.set(controller, taskIds);
|
||||
@@ -33,7 +30,6 @@ export function unregisterExternalUploadController(controller: UploadController)
|
||||
for (const taskId of taskIds) {
|
||||
if (controllersByTaskId.get(taskId) === controller) {
|
||||
controllersByTaskId.delete(taskId);
|
||||
scopesByTaskId.delete(taskId);
|
||||
}
|
||||
}
|
||||
taskIdsByController.delete(controller);
|
||||
@@ -44,10 +40,6 @@ export function getExternalUploadController(taskId: string): UploadController |
|
||||
}
|
||||
|
||||
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
|
||||
if (taskId && scopesByTaskId.get(taskId) === "file") {
|
||||
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
|
||||
return true;
|
||||
}
|
||||
const controllers = taskId
|
||||
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
|
||||
: [...taskIdsByController.keys()];
|
||||
@@ -58,6 +50,5 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
|
||||
|
||||
export function resetExternalUploadRuntimeForTests(): void {
|
||||
controllersByTaskId.clear();
|
||||
scopesByTaskId.clear();
|
||||
taskIdsByController.clear();
|
||||
}
|
||||
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
|
||||
return { sftpId, release: () => {} };
|
||||
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
|
||||
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
|
||||
registerExternalUploadController(taskId, controller, scope);
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
|
||||
registerExternalUploadController(taskId, controller);
|
||||
}, []);
|
||||
|
||||
const unregisterUploadController = useCallback((controller: UploadController) => {
|
||||
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
|
||||
registerUploadController(task.id, controller);
|
||||
if (task.parentTaskId) {
|
||||
registerUploadController(task.parentTaskId, controller);
|
||||
}
|
||||
|
||||
@@ -58,52 +58,6 @@ test("normal shell exits do not auto reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "timeout" as const },
|
||||
{ reason: "timeout" as const, exitCode: 0 },
|
||||
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
|
||||
{ reason: "signaled" as const },
|
||||
// A path that still says "closed" but plumbed the exit-signal is the same kick.
|
||||
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
|
||||
{ reason: "closed" as const, exitSignal: " SIGHUP " },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("transport drops still auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "closed" as const },
|
||||
{ reason: "error" as const, error: "connection reset" },
|
||||
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
|
||||
{ reason: "closed" as const, exitSignal: "" },
|
||||
{ reason: "closed" as const, exitSignal: " " },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
|
||||
@@ -40,16 +40,7 @@ export function shouldAutoReconnectAfterExit({
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
|
||||
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
|
||||
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
|
||||
// Reconnecting would fight that policy. Transport "error" and bare "closed"
|
||||
// still retry.
|
||||
if (evt.reason === "error") return true;
|
||||
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
|
||||
return false;
|
||||
}
|
||||
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
|
||||
return evt.reason !== "exited";
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -21,7 +18,6 @@ import {
|
||||
resetExternalMcpStartupSyncOnceForTests,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
shouldWaitForExternalMcpStartupReady,
|
||||
syncExternalMcpConfig,
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
@@ -67,44 +63,10 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
const manager = createSessionIdleManager();
|
||||
try {
|
||||
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
|
||||
if (savedMinutes !== null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
|
||||
}
|
||||
const expected = savedMinutes ?? manager.getTimeoutMinutes();
|
||||
const received: number[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
savedMinutes,
|
||||
'sync does not migrate or overwrite the stored preference',
|
||||
);
|
||||
} finally {
|
||||
manager.clearAll();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
|
||||
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
createSessionHistorySearch,
|
||||
type SessionHistorySearch,
|
||||
} from './sessionHistorySearch';
|
||||
|
||||
/**
|
||||
* Owns one search instance per mounted session history drawer.
|
||||
*
|
||||
* The search index cache is bound to this instance (not module scope), so
|
||||
* several concurrently mounted drawers — one per terminal tab — each keep
|
||||
* their own indexes: pruning in one drawer never drops another drawer's
|
||||
* entries, and the unmount below releases only this drawer's store instead of
|
||||
* discarding the shared cache for every drawer. Cache keys are the session
|
||||
* objects themselves, so clearing on unmount drops all strong session
|
||||
* references together with the retained haystack characters.
|
||||
*/
|
||||
export function useSessionHistorySearch(): SessionHistorySearch {
|
||||
const search = useMemo(() => createSessionHistorySearch(), []);
|
||||
useEffect(() => () => {
|
||||
search.clearSessionHistorySearchCache();
|
||||
}, [search]);
|
||||
return search;
|
||||
}
|
||||
@@ -151,7 +151,6 @@ import {
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -356,9 +355,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
passwordVerifier: null,
|
||||
});
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
|
||||
@@ -1667,50 +1667,6 @@ test("terminal auto-close preference survives sync round-trip", async () => {
|
||||
assert.equal(restored.autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
|
||||
await applySyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { autoCloseOnExit: true } },
|
||||
}, { importVaultData: () => {} });
|
||||
|
||||
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(resurrected.autoCloseOnExit, false);
|
||||
assert.equal(resurrected.scrollback, 3000);
|
||||
|
||||
// An explicit opt-in made after the migration round-trips with a marker.
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
|
||||
);
|
||||
const optedIn = buildSyncPayload(vault());
|
||||
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
await applySyncPayload(optedIn, { importVaultData: () => {} });
|
||||
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restoredChoice.autoCloseOnExit, true);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
reconcileInboundAutoCloseOnExit,
|
||||
retainLocalHostLastConnectedAt,
|
||||
sanitizeHostsForSync,
|
||||
type SyncPayload,
|
||||
@@ -63,7 +62,6 @@ import {
|
||||
} from './state/customAccentSync';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
@@ -507,9 +505,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
|
||||
settings.autoCloseOnExitDefaultMigrated = true;
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
@@ -800,23 +795,6 @@ async function applySyncableSettings(
|
||||
? 'disabled'
|
||||
: 'paste';
|
||||
}
|
||||
if ('autoCloseOnExit' in settings.terminalSettings) {
|
||||
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
|
||||
settings.terminalSettings.autoCloseOnExit,
|
||||
settings.autoCloseOnExitDefaultMigrated,
|
||||
);
|
||||
}
|
||||
// A legacy true is the old default. A migrated peer's explicit choice must
|
||||
// stick, so record the marker before the next local migration pass.
|
||||
if (
|
||||
settings.autoCloseOnExitDefaultMigrated === true
|
||||
|| (
|
||||
settings.terminalSettings.autoCloseOnExit === true
|
||||
&& settings.autoCloseOnExitDefaultMigrated !== true
|
||||
)
|
||||
) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import type { AISession } from "../infrastructure/ai/types.ts";
|
||||
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({
|
||||
id: `s-${i}`,
|
||||
title: `Session ${i}`,
|
||||
agentId: "catty",
|
||||
scope: { type: "terminal", targetId: "terminal-1" },
|
||||
messages: [{
|
||||
id: `m-${i}`, role: "user", timestamp: 0,
|
||||
content: i === 140 ? "needle [prod].json" : "ordinary content",
|
||||
}],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}));
|
||||
const selected: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
let closed = 0;
|
||||
const render = (items = sessions, locale = "en") => renderer.render(
|
||||
<I18nProvider locale={locale}>
|
||||
<TooltipProvider>
|
||||
<SessionHistoryDrawer
|
||||
sessions={items}
|
||||
activeSessionId="s-140"
|
||||
onSelect={(id) => selected.push(id)}
|
||||
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
|
||||
onClose={() => { closed += 1; }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const rows = () => renderer.container.querySelectorAll('[role="button"]');
|
||||
const click = async (element: Element | null | undefined) => {
|
||||
assert.ok(element);
|
||||
await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
};
|
||||
const enterQuery = async (query: string) => {
|
||||
const input = renderer.container.querySelector("input");
|
||||
assert.ok(input);
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
|
||||
setValue.call(input, query);
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
};
|
||||
const loadMore = () => [...renderer.container.querySelectorAll("button")]
|
||||
.find((button) => button.textContent?.includes("Load more"));
|
||||
|
||||
try {
|
||||
await render();
|
||||
assert.equal(rows().length, 80);
|
||||
assert.match(loadMore()?.textContent ?? "", /61/);
|
||||
await click(loadMore());
|
||||
assert.equal(rows().length, 140);
|
||||
await enterQuery("[prod].json");
|
||||
assert.equal(rows().length, 1);
|
||||
assert.match(rows()[0].textContent ?? "", /Session 140/);
|
||||
assert.equal(loadMore(), undefined);
|
||||
await click(rows()[0]);
|
||||
await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await click(rows()[0].querySelector("button"));
|
||||
assert.deepEqual(selected, ["s-140", "s-140"]);
|
||||
assert.deepEqual(deleted, ["s-140"]);
|
||||
|
||||
// A pasted suffix must not remain visible while the matcher silently
|
||||
// ignores it: the controlled input and search accept the same prefix.
|
||||
const pastedQuery = "needle ".repeat(36) + "needle absent-tail";
|
||||
await enterQuery(pastedQuery);
|
||||
const boundedInput = renderer.container.querySelector("input")!;
|
||||
assert.equal(boundedInput.value, pastedQuery.slice(0, 256));
|
||||
assert.equal(boundedInput.maxLength, 256);
|
||||
assert.ok(!boundedInput.value.includes("absent-tail"));
|
||||
assert.equal(rows().length, 1);
|
||||
assert.match(rows()[0].textContent ?? "", /Session 140/);
|
||||
await enterQuery("needle absent-tail");
|
||||
assert.equal(boundedInput.value, "needle absent-tail");
|
||||
assert.equal(rows().length, 0, "every visible query token must affect matching");
|
||||
await enterQuery("x".repeat(255) + "\u{1f63a}");
|
||||
assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair");
|
||||
|
||||
await enterQuery("[](){}");
|
||||
assert.equal(rows().length, 0);
|
||||
assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/);
|
||||
assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/);
|
||||
assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/);
|
||||
assert.match(renderer.container.textContent ?? "", /not full-text search/);
|
||||
await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button"));
|
||||
assert.equal(renderer.container.querySelector("input")?.value, "");
|
||||
assert.equal(rows().length, 80);
|
||||
assert.match(loadMore()?.textContent ?? "", /61/);
|
||||
await enterQuery(" \t");
|
||||
assert.equal(rows().length, 80);
|
||||
await click(renderer.container.querySelector("button"));
|
||||
assert.equal(closed, 1);
|
||||
|
||||
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
|
||||
await render(sessions, locale);
|
||||
await enterQuery("no-such-content");
|
||||
const messages = MESSAGES_BY_LOCALE[locale];
|
||||
const input = renderer.container.querySelector("input")!;
|
||||
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
|
||||
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
|
||||
assert.ok(messages[key], `${locale} is missing ${key}`);
|
||||
}
|
||||
assert.equal(input.placeholder, messages["ai.chat.searchSessions"]);
|
||||
assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]);
|
||||
assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]);
|
||||
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
|
||||
await render([], locale);
|
||||
assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"]));
|
||||
assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"]));
|
||||
}
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Search, Trash2, X } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
@@ -34,28 +31,13 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// One cache instance per mounted drawer: caches of concurrently mounted
|
||||
// drawers stay independent (no shared state to prune or discard across tabs).
|
||||
const search = useSessionHistorySearch();
|
||||
|
||||
const filteredSessions = React.useMemo(
|
||||
// The localized fallback below is displayed for untitled sessions
|
||||
// (row label), so it must be indexed for search as the displayed title.
|
||||
() => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }),
|
||||
[sessions, searchQuery, t, search],
|
||||
);
|
||||
|
||||
// Reset the render batch when the list scope or query changes so matching
|
||||
// sessions are never hidden behind stale paging.
|
||||
useEffect(() => {
|
||||
setRenderCount(SESSION_RENDER_BATCH);
|
||||
}, [sessions, searchQuery]);
|
||||
}, [sessions]);
|
||||
|
||||
const displayedSessions = filteredSessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
|
||||
const displayedSessions = sessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
@@ -68,41 +50,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 shrink-0 border-b border-border/30">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={13}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
maxLength={MAX_SEARCH_QUERY_LENGTH}
|
||||
placeholder={t('ai.chat.searchSessions')}
|
||||
aria-label={t('ai.chat.searchSessions')}
|
||||
aria-describedby={searchHintId}
|
||||
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
|
||||
className="h-8 pl-8 pr-7 text-[12px]"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ai.chat.clearSessionSearch')}
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
|
||||
{t('ai.chat.searchSessionsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
@@ -111,12 +58,6 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noMatchingSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hiddenSessionCount > 0 && (
|
||||
|
||||
@@ -669,9 +669,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
evt,
|
||||
// No terminal settings loaded yet -> do not close the tab; auto-close is
|
||||
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
|
||||
terminalSettings?.autoCloseOnExit ?? false,
|
||||
terminalSettings?.autoCloseOnExit ?? true,
|
||||
);
|
||||
if (intent.kind === "closeSession") {
|
||||
handleCloseSession(sessionId);
|
||||
|
||||
@@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => {
|
||||
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import React from "react";
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
|
||||
|
||||
async function setup(t: TestContext, label: string) {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
DocumentFragment: env.window.DocumentFragment,
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
|
||||
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
|
||||
const { Tabs } = await import("../../ui/tabs");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const updates: string[][] = [];
|
||||
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
|
||||
hotkeyScheme="pc" setHotkeyScheme={() => {}}
|
||||
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
|
||||
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
|
||||
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
|
||||
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
|
||||
updateKeyBinding={(...args) => { updates.push(args); }}
|
||||
/></Tabs></I18nProvider>);
|
||||
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
|
||||
const button = row?.querySelector("button");
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
|
||||
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
|
||||
);
|
||||
return { updates, key, button };
|
||||
}
|
||||
|
||||
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Alt", code: "AltLeft" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Control", ctrlKey: true });
|
||||
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
|
||||
await key("keyup", { key: "Alt", ctrlKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Control" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("ordinary shortcuts still require a non-modifier key", async (t) => {
|
||||
const { updates, key } = await setup(t, "Next Tab");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keydown", { key: "ArrowRight", altKey: true });
|
||||
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
|
||||
});
|
||||
|
||||
test("Escape cancels an unfinished modifier recording", async (t) => {
|
||||
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keydown", { key: "Escape", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(button.textContent, "Ctrl");
|
||||
});
|
||||
@@ -64,7 +64,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
if (!recordingBindingId || !recordingScheme) return;
|
||||
|
||||
const specialSuffix = getSpecialSuffix(recordingBindingId);
|
||||
let pendingModifierBinding: string | null = null;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip while app lock overlay is up so password keys never bind (Codex P2).
|
||||
@@ -78,6 +77,8 @@ export default function SettingsShortcutsTab(props: {
|
||||
}
|
||||
|
||||
if (specialSuffix) {
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (recordingScheme === "mac") {
|
||||
if (e.metaKey) parts.push("⌘");
|
||||
@@ -94,11 +95,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
|
||||
const fullKeyString = modifierString + specialSuffix;
|
||||
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
|
||||
pendingModifierBinding = fullKeyString;
|
||||
return;
|
||||
}
|
||||
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
|
||||
cancelRecording();
|
||||
return;
|
||||
@@ -110,17 +106,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
|
||||
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelRecording();
|
||||
};
|
||||
@@ -130,11 +115,9 @@ export default function SettingsShortcutsTab(props: {
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { reconcileAutocompletePopupState, type AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
/**
|
||||
* Regression test for #3426: hovering autocomplete rows must not change the
|
||||
@@ -11,7 +10,7 @@ import { reconcileAutocompletePopupState, type AutocompleteState } from "./autoc
|
||||
* the hovered row and self-oscillating (violent mousemove jitter).
|
||||
*/
|
||||
|
||||
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async (t) => {
|
||||
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
@@ -195,38 +194,6 @@ test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", as
|
||||
}
|
||||
}
|
||||
|
||||
await t.test("edited snippet rows cannot be clicked before the provider refresh", async () => {
|
||||
let clicks = 0;
|
||||
const state: AutocompleteState = {
|
||||
suggestions: [{ text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
|
||||
snippet: { id: "deploy", label: "deploy", command: "echo deploy" } }],
|
||||
selectedIndex: -1, popupVisible: true,
|
||||
popupAnchorViewport: { left: 100, top: 300, bottom: 316 },
|
||||
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
|
||||
};
|
||||
const renderPopup = async (next: AutocompleteState) => {
|
||||
await act(async () => {
|
||||
root.render(<AutocompletePopup
|
||||
suggestions={next.suggestions} selectedIndex={next.selectedIndex}
|
||||
anchorViewport={next.popupAnchorViewport} visible={next.popupVisible}
|
||||
onSelect={() => { clicks++; }} containerRef={containerRef}
|
||||
/>);
|
||||
});
|
||||
};
|
||||
await renderPopup(state);
|
||||
const label = Array.from(rootNode.querySelectorAll<HTMLElement>("span"))
|
||||
.find((el) => el.textContent === "deploy");
|
||||
const row = label?.closest<HTMLElement>("div[style*='cursor']");
|
||||
assert.ok(row);
|
||||
await act(async () => {
|
||||
row.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
assert.equal(clicks, 1, "an unselected fresh snippet is clickable");
|
||||
await renderPopup(reconcileAutocompletePopupState(state, "git pull xyz"));
|
||||
assert.equal(Boolean(rootNode.querySelector(".xterm-autocomplete-popup")), false);
|
||||
assert.equal(clicks, 1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -406,32 +406,16 @@ export async function getCompletions(
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets: at the command position the whole typed line is the needle. Once
|
||||
// arguments are present, match against the word being typed so a snippet
|
||||
// (e.g. an email shortcut) stays reachable after a command like `git pull `
|
||||
// (#3622). Argument-position matching is prefix-only, needs at least two
|
||||
// typed characters, and yields when path completion owns the slot.
|
||||
// Snippets: only at the command position (typing the command name).
|
||||
// Push without the early seen-text skip: snippets score above history, so if
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0) {
|
||||
const atCommandPosition = ctx.wordIndex === 0;
|
||||
const argumentNeedle = normalizeHistoryPathPrefix(ctx.currentWord);
|
||||
const snippetArgumentMatch = !atCommandPosition
|
||||
&& !preferPathSuggestions
|
||||
&& argumentNeedle.trim().length >= 2;
|
||||
if (atCommandPosition || snippetArgumentMatch) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(
|
||||
atCommandPosition ? input : argumentNeedle,
|
||||
options.snippets,
|
||||
{
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
prefixOnly: !atCommandPosition,
|
||||
},
|
||||
)) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery, matchesWordPrefixQuery } from "../../../lib/searchMatcher";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
@@ -30,7 +30,7 @@ function snippetAvailableForAutocomplete(
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string; hostGroup?: string; prefixOnly?: boolean } = {},
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
@@ -44,16 +44,10 @@ export function getSnippetSuggestions(
|
||||
const labelPrefix = label.startsWith(needle);
|
||||
// Literal prefix/substring first (cheap); fall back to shared smart matcher
|
||||
// so Chinese titles surface for pinyin / initials the same way host search does.
|
||||
// In prefixOnly mode (argument position), matches must start at a word
|
||||
// boundary to avoid flooding mid-argument popups; pinyin still applies.
|
||||
const matches = options.prefixOnly
|
||||
? labelPrefix
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesWordPrefixQuery(needle, snippet.label, firstLine)
|
||||
: labelPrefix
|
||||
|| label.includes(needle)
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesSearchQuery(needle, snippet.label, firstLine);
|
||||
const matches = labelPrefix
|
||||
|| label.includes(needle)
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesSearchQuery(needle, snippet.label, firstLine);
|
||||
if (!matches) continue;
|
||||
|
||||
out.push({
|
||||
|
||||
@@ -144,8 +144,8 @@ function isSubsequence(query: string, target: string): boolean {
|
||||
|
||||
/**
|
||||
* Remove history rows that no longer match the edited input while a debounced
|
||||
* provider refresh is pending. Snippets must be re-queried for the new input
|
||||
* before they can be selected or executed. Other sources retain their matching.
|
||||
* provider refresh is pending. Other sources keep their provider-specific
|
||||
* matching semantics (snippet search, plugin replacement, etc.).
|
||||
*/
|
||||
export function reconcileAutocompletePopupState(
|
||||
prev: AutocompleteState,
|
||||
@@ -158,7 +158,6 @@ export function reconcileAutocompletePopupState(
|
||||
const inputContext = parseCommandLine(input);
|
||||
const allowFuzzyHistory = input.length >= 2 && inputContext.wordIndex === 0;
|
||||
const suggestions = prev.suggestions.filter((suggestion) => {
|
||||
if (suggestion.source === "snippet") return false;
|
||||
if (suggestion.source !== "history") return true;
|
||||
const text = suggestion.text.toLowerCase();
|
||||
if (suggestion.historyMatch === "path-argument") {
|
||||
|
||||
@@ -14,28 +14,8 @@ test("getCompletions includes snippet suggestions at the command position", asyn
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions surfaces prefix-matching snippets while typing an argument (#3622)", async () => {
|
||||
const out = await getCompletions("git pull dep", { snippets: [deploySnippet] });
|
||||
const snip = out.find((s) => s.source === "snippet");
|
||||
assert.ok(snip, "expected a snippet suggestion while typing an argument");
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets for mid-word argument matches", async () => {
|
||||
// Argument-position matching works on word boundaries only: "mai" sits
|
||||
// mid-word in "gmail" and must not surface the snippet.
|
||||
const substringSnippet: Snippet = { id: "gm", label: "gmail", command: "echo gmail" };
|
||||
const out = await getCompletions("git push mai", { snippets: [substringSnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets for single-character arguments", async () => {
|
||||
const out = await getCompletions("git pull d", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions yields argument-position snippets to path completion", async () => {
|
||||
const out = await getCompletions("cat dep", { snippets: [deploySnippet] });
|
||||
test("getCompletions does not surface snippets past the command position", async () => {
|
||||
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
attachWindowResizeFocusRestore,
|
||||
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
type MinimalDocument,
|
||||
} from "./restoreWindowResizeFocus";
|
||||
|
||||
type FakeWindow = {
|
||||
listeners: Map<string, Array<() => void>>;
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
emit: (type: string) => void;
|
||||
};
|
||||
|
||||
const createWindowStub = (): FakeWindow => {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
const index = bucket.indexOf(listener);
|
||||
if (index >= 0) bucket.splice(index, 1);
|
||||
},
|
||||
emit(type) {
|
||||
for (const listener of [...(listeners.get(type) ?? [])]) listener();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type SchedulerState = {
|
||||
schedule: (callback: () => void, delay: number) => number;
|
||||
cancel: (id: number) => void;
|
||||
runNext: () => void;
|
||||
pendingCount: () => number;
|
||||
};
|
||||
|
||||
const createScheduler = (): SchedulerState => {
|
||||
type Entry = { id: number; callback: () => void; cancelled: boolean };
|
||||
const queue: Entry[] = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
schedule(callback, delay) {
|
||||
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
|
||||
queue.push({ id: nextId, callback, cancelled: false });
|
||||
return nextId++;
|
||||
},
|
||||
cancel(id) {
|
||||
for (const entry of queue) {
|
||||
if (entry.id === id) entry.cancelled = true;
|
||||
}
|
||||
},
|
||||
runNext() {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift()!;
|
||||
if (!entry.cancelled) {
|
||||
entry.callback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
|
||||
};
|
||||
};
|
||||
|
||||
test("resize restores terminal focus once the resize settles", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
|
||||
assert.equal(refocusCalls, 0);
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("a continuous frame drag only triggers one restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus when the app window is not focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize restore is skipped while a modal dialog is open", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: (selector: string) => (
|
||||
selector === '[role="dialog"][data-state="open"]' ? {} : null
|
||||
),
|
||||
hasFocus: () => true,
|
||||
} as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from an active editable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "INPUT", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from a contenteditable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize still restores while the xterm helper textarea is focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: {
|
||||
tagName: "TEXTAREA",
|
||||
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
|
||||
},
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("detach removes the listener and cancels a pending restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
detach();
|
||||
|
||||
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
|
||||
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
});
|
||||
|
||||
test("a win without listener support degrades to a no-op detach", () => {
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win: null,
|
||||
refocus: () => {},
|
||||
scheduleTimeout: () => 1,
|
||||
cancelTimeout: () => {},
|
||||
});
|
||||
assert.doesNotThrow(() => detach());
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { hasOpenAppDialog } from "./focusTerminalSession";
|
||||
|
||||
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
|
||||
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
|
||||
|
||||
export type MinimalWindow = {
|
||||
addEventListener?: (type: string, listener: () => void) => unknown;
|
||||
removeEventListener?: (type: string, listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
export type MinimalActiveElement = {
|
||||
tagName?: string | null;
|
||||
isContentEditable?: boolean;
|
||||
matches?: (selector: string) => boolean;
|
||||
};
|
||||
|
||||
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
|
||||
hasFocus?: () => boolean;
|
||||
activeElement?: MinimalActiveElement | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when keyboard focus sits in an editable app field (search box, chat
|
||||
* input, SFTP filter, inline rename, ...) that is not an xterm helper
|
||||
* textarea. Resize-driven restores must not yank focus away from such fields.
|
||||
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
|
||||
* as editing another field.
|
||||
*/
|
||||
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
|
||||
const element = doc?.activeElement;
|
||||
if (!element) return false;
|
||||
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
|
||||
return false;
|
||||
}
|
||||
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
|
||||
return (
|
||||
tagName === "input"
|
||||
|| tagName === "textarea"
|
||||
|| tagName === "select"
|
||||
|| Boolean(element.isContentEditable)
|
||||
);
|
||||
};
|
||||
|
||||
type AttachWindowResizeFocusRestoreOptions = {
|
||||
win?: MinimalWindow | null;
|
||||
doc?: MinimalDocument | null;
|
||||
refocus?: () => void;
|
||||
delayMs?: number;
|
||||
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
cancelTimeout?: (id: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
|
||||
* helper textarea unfocused, so keystrokes do nothing until the user clicks
|
||||
* back into the terminal. Attach a window `resize` listener that re-runs the
|
||||
* tab-change refocus path after the resize settles.
|
||||
*
|
||||
* Restore is skipped when the app window does not hold OS input focus, when a
|
||||
* modal dialog is open, or when an editable element other than the xterm
|
||||
* helper textarea already holds focus, so the restore never steals typing
|
||||
* from elsewhere.
|
||||
*/
|
||||
export const attachWindowResizeFocusRestore = (
|
||||
{
|
||||
win = typeof window !== "undefined" ? window : null,
|
||||
doc = typeof document !== "undefined" ? document : null,
|
||||
refocus,
|
||||
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
}: AttachWindowResizeFocusRestoreOptions = {},
|
||||
): (() => void) => {
|
||||
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let timerId: unknown = null;
|
||||
const restoreFocus = () => {
|
||||
timerId = null;
|
||||
if (doc?.hasFocus?.() === false) return;
|
||||
if (hasOpenAppDialog(doc)) return;
|
||||
// The window holds OS focus, but the user may still be typing in a
|
||||
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
|
||||
// Never steal focus from an active editable element.
|
||||
if (hasActiveEditableElement(doc)) return;
|
||||
refocus();
|
||||
};
|
||||
const handleResize = () => {
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
}
|
||||
timerId = scheduleTimeout(restoreFocus, delayMs);
|
||||
};
|
||||
|
||||
win.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
|
||||
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
|
||||
onDispose: (handler: Handler) => { dispose: () => void };
|
||||
}> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean }> = [];
|
||||
|
||||
const term = {
|
||||
cols,
|
||||
@@ -477,84 +477,6 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
|
||||
// xterm-side disposal (decoration service reset / term.reset): no cursor
|
||||
// move and no write follow, only the next render frame.
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations[0]?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.decorations.at(-1)?.options.width, 80);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.markers.at(-1)?.disposed, false);
|
||||
|
||||
// term.clear() disposes every marker; decorations follow their markers.
|
||||
for (const marker of term.markers) marker.dispose();
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.markers.at(-1)?.line, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
assert.equal(
|
||||
term.decorations.filter((decoration) => !decoration.disposed).length,
|
||||
aliveBefore,
|
||||
);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
|
||||
const cols = 8;
|
||||
const term = createFakeTerm(cols);
|
||||
// Every cell has an explicit ANSI background and the row is full, so both
|
||||
// highlight ranges and the blank tail are legitimately empty.
|
||||
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
|
||||
assert.equal(term.decorations.length, 0);
|
||||
assert.equal(term.markers.length, 1);
|
||||
const marker = term.markers[0];
|
||||
assert.ok(marker);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
term.render();
|
||||
|
||||
assert.equal(term.markers.length, 1);
|
||||
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
assert.equal(term.decorations.length, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
|
||||
@@ -37,8 +37,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
private activeColor: string | null = null;
|
||||
private activeRanges: HighlightRange[] = [];
|
||||
private activeTailRanges: HighlightRange[] = [];
|
||||
/** False once any tracked decoration has been disposed out from under us. */
|
||||
private decorationsAlive = false;
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private pendingRefresh = false;
|
||||
private disposed = false;
|
||||
@@ -48,16 +46,10 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.term.onCursorMove(() => this.markPendingRefresh()),
|
||||
this.term.onWriteParsed(() => this.markPendingRefresh()),
|
||||
this.term.onRender(() => {
|
||||
const pending = this.pendingRefresh;
|
||||
this.pendingRefresh = false;
|
||||
if (this.disposed || !this.enabled) return;
|
||||
// Writes and cursor moves defer to render, but xterm can also dispose
|
||||
// or clear our decorations out from under us (term.clear/reset,
|
||||
// decoration service resets, marker reindexing) without any further
|
||||
// cursor move or write. Verify the tracked set on every render so the
|
||||
// highlight can self-heal at the cursor line instead of staying
|
||||
// anchored to stale bookkeeping (wrong/missing line).
|
||||
if (pending || !this.isTrackingIntact()) this.refresh();
|
||||
if (this.pendingRefresh) {
|
||||
this.pendingRefresh = false;
|
||||
this.refresh();
|
||||
}
|
||||
}),
|
||||
this.term.onSelectionChange(() => this.refresh({ force: true })),
|
||||
this.term.onResize(() => this.refresh({ force: true })),
|
||||
@@ -119,7 +111,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
color === this.activeColor &&
|
||||
rangesEqual(ranges, this.activeRanges) &&
|
||||
rangesEqual(tailRanges, this.activeTailRanges) &&
|
||||
this.decorationsAlive &&
|
||||
this.marker &&
|
||||
!this.marker.isDisposed &&
|
||||
this.marker.line === absoluteLine
|
||||
@@ -169,18 +160,13 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
|
||||
this.marker = marker;
|
||||
this.decorations = decorations;
|
||||
// An empty set is valid when the whole row already has ANSI backgrounds or
|
||||
// inverse cells. Tracking stays intact so later frames do not churn markers.
|
||||
this.decorationsAlive = true;
|
||||
this.decorationDisposeListeners = decorations.map((decoration) =>
|
||||
decoration.onDispose(() => {
|
||||
if (this.decorations.includes(decoration)) {
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
this.activeRanges = [];
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -258,7 +244,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.marker = null;
|
||||
this.decorations = [];
|
||||
this.decorationDisposeListeners = [];
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
@@ -266,19 +251,6 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap render-time check that the tracked marker and decorations are still
|
||||
* owned by xterm. xterm disposes highlight decorations on buffer clears,
|
||||
* resets, and marker reindexing without emitting cursor-move/write events,
|
||||
* so a stale tracking state must be recreated before the next frame paints.
|
||||
*/
|
||||
private isTrackingIntact(): boolean {
|
||||
if (this.marker === null || this.marker.isDisposed) {
|
||||
return this.marker === null && this.decorations.length === 0;
|
||||
}
|
||||
return this.decorationsAlive;
|
||||
}
|
||||
|
||||
private clearOwned(
|
||||
marker: IMarker | null,
|
||||
decorations: IDecoration[],
|
||||
|
||||
@@ -111,31 +111,3 @@ test("matches Chinese labels by pinyin and initials (smart suggest)", () => {
|
||||
assert.equal(getSnippetSuggestions("bushu", [s], {})[0]?.snippet?.id, "zh");
|
||||
assert.equal(getSnippetSuggestions("bsfw", [s], {})[0]?.snippet?.id, "zh");
|
||||
});
|
||||
|
||||
test("prefixOnly mode matches at word boundaries (argument position, #3622)", () => {
|
||||
const s = snip({ id: "mail", label: "set-email", command: "git config user.email a@b.c" });
|
||||
// First 3 letters of the label's last word still match while typing an
|
||||
// argument, e.g. after `git pull `.
|
||||
assert.equal(getSnippetSuggestions("ema", [s], { prefixOnly: true })[0]?.snippet?.id, "mail");
|
||||
// Mid-word substrings do not match.
|
||||
const gmail = snip({ id: "gm", label: "gmail", command: "echo gmail" });
|
||||
assert.deepEqual(getSnippetSuggestions("mai", [gmail], { prefixOnly: true }), []);
|
||||
});
|
||||
|
||||
test("prefixOnly mode matches Chinese labels via pinyin and initials", () => {
|
||||
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply" });
|
||||
assert.equal(getSnippetSuggestions("bush", [s], { prefixOnly: true })[0]?.snippet?.id, "zh");
|
||||
const mailbox = snip({ id: "yx", label: "邮箱", command: "user@example.com" });
|
||||
assert.equal(getSnippetSuggestions("yx", [mailbox], { prefixOnly: true })[0]?.snippet?.id, "yx");
|
||||
});
|
||||
|
||||
test("prefixOnly mode does not match option-prefixed needles (#3623)", () => {
|
||||
// The pinyin fallback must not strip the leading "--"/"-" punctuation:
|
||||
// "--ve" (from `git --ve`) would otherwise match "version", and "-d"
|
||||
// (from `git -d`) match "deploy" via a single typed letter, displacing
|
||||
// the real Fig option completions while typing flags.
|
||||
const version = snip({ id: "v", label: "version", command: "echo version" });
|
||||
const deploy = snip({ id: "d", label: "deploy", command: "kubectl apply" });
|
||||
assert.deepEqual(getSnippetSuggestions("--ve", [version], { prefixOnly: true }), []);
|
||||
assert.deepEqual(getSnippetSuggestions("-d", [deploy], { prefixOnly: true }), []);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ test("path history follows the current argument instead of the whole line", () =
|
||||
assert.equal(changedArgument.popupVisible, false);
|
||||
});
|
||||
|
||||
test("snippet rows are removed synchronously while their next query is pending", () => {
|
||||
test("provider-specific non-history rows remain until their debounced refresh", () => {
|
||||
const snippet: CompletionSuggestion = {
|
||||
text: "deploy production",
|
||||
displayText: "deploy production",
|
||||
@@ -186,8 +186,7 @@ test("snippet rows are removed synchronously while their next query is pending",
|
||||
};
|
||||
|
||||
const next = reconcileAutocompletePopupState(popupState([snippet]), "dp");
|
||||
assert.deepEqual(next.suggestions, []);
|
||||
assert.equal(next.popupVisible, false);
|
||||
assert.deepEqual(next.suggestions, [snippet]);
|
||||
assert.equal(next.selectedIndex, -1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
|
||||
|
||||
const suggestion = (text: string) => ({
|
||||
@@ -350,32 +348,3 @@ test("subdir Escape does not arm Meta prefix", () => {
|
||||
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
||||
});
|
||||
|
||||
|
||||
for (const edited of [false, true]) {
|
||||
test(`snippet navigation before provider refresh: edited=${edited}`, () => {
|
||||
let runs = 0;
|
||||
const initial: AutocompleteState = {
|
||||
suggestions: [{
|
||||
text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
|
||||
snippet: { id: "deploy", label: "deploy", command: "echo deploy" },
|
||||
}],
|
||||
selectedIndex: -1, popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
|
||||
};
|
||||
const stateRef = { current: edited ? reconcileAutocompletePopupState(initial, "git pull xyz") : initial };
|
||||
const { context } = createContext({
|
||||
stateRef,
|
||||
setState(update: AutocompleteState | ((prev: AutocompleteState) => AutocompleteState)) {
|
||||
stateRef.current = typeof update === "function" ? update(stateRef.current) : update;
|
||||
},
|
||||
acceptSnippet() { runs++; return true; },
|
||||
});
|
||||
// An unselected snippet does not execute on direct Enter.
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
|
||||
assert.equal(runs, 0);
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("ArrowDown"), context);
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
|
||||
assert.equal(runs, edited ? 0 : 1);
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
type SidePanelTabRemap,
|
||||
} from '../../domain/workspaceSidePanelTabRemap';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
import type { RendererCwdSource } from '../terminal/sftpCwd';
|
||||
@@ -748,23 +747,6 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
refocusActiveTerminalSession();
|
||||
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
|
||||
|
||||
// Restore keyboard focus after the OS window is resized or maximized.
|
||||
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
|
||||
// textarea unfocused on Windows, so keystrokes do nothing until a click.
|
||||
// Reuse the tab-change refocus path; the helper only fires once the resize
|
||||
// settles and is gated on the app window actually holding input focus.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible) return undefined;
|
||||
return attachWindowResizeFocusRestore({
|
||||
win: window,
|
||||
doc: document,
|
||||
refocus: () => {
|
||||
if (isComposeBarOpen) return;
|
||||
refocusActiveTerminalSession?.();
|
||||
},
|
||||
});
|
||||
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
|
||||
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="226.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="301.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="373.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="447.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="520.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="594.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="668.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="741.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="161.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="306.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="380.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="457.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="531.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="607.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="683.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="757.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<circle cx="765.0" cy="106.9" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6508</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="226.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="301.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="373.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="447.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="520.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="594.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="668.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="741.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="161.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="306.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="380.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="457.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="531.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="607.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="683.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="757.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
|
||||
<circle cx="765.0" cy="106.9" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6508</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -536,10 +536,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
middleClickPaste: true,
|
||||
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
|
||||
linkModifier: 'none',
|
||||
// Off by default: remote graceful channel closes can be reported as a clean
|
||||
// exit (code 0), which would auto-close tabs on network drops. Users who
|
||||
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
|
||||
autoCloseOnExit: false,
|
||||
autoCloseOnExit: true,
|
||||
// Issue #3087: keep terminal history visible after an established session disconnects.
|
||||
disconnectedNoticeMode: 'terminal',
|
||||
keywordHighlightEnabled: true,
|
||||
@@ -555,11 +552,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
|
||||
// in-place reconnect every 5s with the same session id. Clean shell exits
|
||||
// still close the tab, and administrative idle/timeout exits stay
|
||||
// disconnected. Users on always-stable links can turn it off.
|
||||
sshAutoReconnectEnabled: true,
|
||||
sshAutoReconnectEnabled: false,
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
|
||||
showHostInfoBar: true, // Preserve the existing host information bar by default
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
PINYIN_CACHE_MAX_CHARS,
|
||||
PINYIN_CACHE_MAX_ENTRIES,
|
||||
getPinyinCacheStatsForTests,
|
||||
getHostSearchMatch,
|
||||
@@ -12,21 +11,6 @@ import {
|
||||
resetPinyinCacheForTests,
|
||||
} from "../lib/searchMatcher.ts";
|
||||
|
||||
test("shared pinyin search reaches later fields after a long first field", () => {
|
||||
const prefix = "a".repeat(256);
|
||||
for (const query of ["fuwuqi", "fwq", "nginx fuwuqi"]) {
|
||||
assert.equal(matchesSearchQuery(query, `${prefix} nginx`, "服务器"), true);
|
||||
}
|
||||
assert.equal(matchesSearchQuery("fuwuqi", prefix, "服务器"), true);
|
||||
assert.equal(matchesSearchQuery("fuwuqi missing", prefix, "服务器"), false);
|
||||
});
|
||||
|
||||
test("shared pinyin search preserves full fields and host labels", () => {
|
||||
const label = `${"a".repeat(256)}服务器`;
|
||||
assert.equal(matchesSearchQuery("fuwuqi", label), true);
|
||||
assert.equal(matchesHostSearchQuery("fuwuqi", { label }), true);
|
||||
});
|
||||
|
||||
test("matches mixed Chinese and dash-separated numeric suffix with spaced query", () => {
|
||||
assert.equal(
|
||||
matchesSearchQuery("山东 6-1", "山东-业务交换机6-1"),
|
||||
@@ -141,18 +125,6 @@ test("punctuation-only query does not match every field", () => {
|
||||
assert.equal(matchesSearchQuery("-", "some-host"), false);
|
||||
});
|
||||
|
||||
test("over-limit query keeps its full meaning instead of matching on a truncated prefix", () => {
|
||||
// The shared matcher never truncates: a >256-char query whose distinguishing
|
||||
// suffix is beyond the suggested `MAX_SEARCH_QUERY_LENGTH` cap must not
|
||||
// match a field that only contains the cap-bounded prefix. Callers that
|
||||
// rescan many haystacks (session history) bound their own input instead.
|
||||
const field = "a".repeat(300);
|
||||
const query = `${"a".repeat(300)} missing-suffix`;
|
||||
assert.equal(matchesSearchQuery(query, field), false);
|
||||
// The same query still matches when the whole query is present.
|
||||
assert.equal(matchesSearchQuery(query, `${field} missing-suffix`), true);
|
||||
});
|
||||
|
||||
test("host search avoids compact hostname false positives on numeric segments", () => {
|
||||
assert.equal(
|
||||
matchesHostSearchQuery("61", {
|
||||
@@ -255,22 +227,3 @@ test("pinyin cache covers 8000 hosts and stays hard-bounded through import/edit
|
||||
assert.ok(!stats.keys.includes("主机1"));
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
|
||||
// Regression: short labels keep the pinyin cache under the entry cap, but the
|
||||
// entry cap alone is not a memory bound — a Latin query that matches nothing
|
||||
// transliterates every distinct field it scans, so long fields (e.g. up to
|
||||
// MAX_SEARCHABLE_FIELD_LENGTH-character session titles) could retain several
|
||||
// copies of each source in the global cache. The cache must also be bounded
|
||||
// by retained characters.
|
||||
test("pinyin cache stays hard-bounded by retained characters, not just entries", () => {
|
||||
resetPinyinCacheForTests();
|
||||
// 17 long CJK titles (~20_000 chars each): their entries can never all fit
|
||||
// under the 1,000,000-char budget, so earlier ones must be evicted instead
|
||||
// of pinning memory within the 16_384-entry cap.
|
||||
for (let index = 0; index < 17; index += 1) {
|
||||
matchesSearchQuery("unmatched-latin", "重启".repeat(10_000) + String(index));
|
||||
}
|
||||
assert.ok(getPinyinCacheStatsForTests().chars <= PINYIN_CACHE_MAX_CHARS);
|
||||
assert.ok(getPinyinCacheStatsForTests().size > 0);
|
||||
resetPinyinCacheForTests();
|
||||
});
|
||||
@@ -1,414 +0,0 @@
|
||||
import type { AgentActivity } from './agentActivity';
|
||||
import {
|
||||
matchesPreparedSearchQuery,
|
||||
prepareSearchFields,
|
||||
type PreparedSearchFields,
|
||||
type PreparedSearchQuery,
|
||||
} from '../lib/searchMatcher';
|
||||
|
||||
/**
|
||||
* Minimal structural shapes for session history search. Declared here (not
|
||||
* imported from `infrastructure/ai/types`) so this domain module keeps no
|
||||
* dependency on the infrastructure layer; persisted `AISession`s satisfy
|
||||
* these shapes structurally.
|
||||
*/
|
||||
export type SessionHistorySearchAttachment = {
|
||||
/** Rendered file label (absent for terminal selections and note-only files). */
|
||||
filename?: string;
|
||||
/** Rendered Vault note label. */
|
||||
vaultNoteTitle?: string;
|
||||
};
|
||||
|
||||
export type SessionHistorySearchMessage = {
|
||||
/** Persisted role; 'system' messages are hidden by the UI and never indexed. */
|
||||
role?: string;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
statusText?: string;
|
||||
errorInfo?: { message: string };
|
||||
attachments?: readonly SessionHistorySearchAttachment[];
|
||||
/** @deprecated Legacy attachment field, kept for backward compatibility. */
|
||||
images?: readonly SessionHistorySearchAttachment[];
|
||||
toolCalls?: readonly { name: string; arguments: Record<string, unknown> }[];
|
||||
toolResults?: readonly { toolName?: string; content: string }[];
|
||||
agentActivities?: readonly AgentActivity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Searchable shape for session history search. Pure domain logic consumed by
|
||||
* the session history drawer; matches on title plus user/assistant message
|
||||
* text (hidden `system` messages are excluded, mirroring the reopened-chat
|
||||
* rendering), including thinking, tool call names/arguments, tool result content,
|
||||
* persisted agent activities (web-search queries, file paths, plan items,
|
||||
* warnings) already stored on messages, visible attachment labels (file names
|
||||
* and Vault note titles) and persisted error messages.
|
||||
*/
|
||||
export type SessionHistorySearchTarget = {
|
||||
/** Raw persisted title; may be empty (the UI then renders a fallback). */
|
||||
title: string;
|
||||
messages: readonly SessionHistorySearchMessage[];
|
||||
};
|
||||
|
||||
/** Cap per-field text so very large tool result payloads stay cheap to scan. */
|
||||
const MAX_SEARCHABLE_FIELD_LENGTH = 20_000;
|
||||
|
||||
/** Bound history-only title transliteration without limiting shared search. */
|
||||
const MAX_TITLE_PINYIN_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Cap the total searchable text collected per session. Pinyin matching joins
|
||||
* every collected field into one haystack, so a long retained session with
|
||||
* hundreds of large messages could otherwise contribute megabytes that gets
|
||||
* re-normalized (and pinyin-transliterated) on every keystroke.
|
||||
*/
|
||||
const MAX_SESSION_SEARCHABLE_TOTAL_LENGTH = 64_000;
|
||||
|
||||
/** Cap serialized tool-call argument text so bulky payloads stay cheap to scan. */
|
||||
const MAX_TOOL_ARGUMENTS_FIELD_LENGTH = 2_000;
|
||||
|
||||
type SearchFieldCollector = {
|
||||
fields: string[];
|
||||
push: (text: unknown, maxLength?: number) => void;
|
||||
/** True once the per-session budget is exhausted: further fields are dropped. */
|
||||
isFull: boolean;
|
||||
};
|
||||
|
||||
function createSearchFieldCollector(): SearchFieldCollector {
|
||||
const fields: string[] = [];
|
||||
let totalLength = 0;
|
||||
const collector = {
|
||||
fields,
|
||||
push(text: unknown, maxLength: number = MAX_SEARCHABLE_FIELD_LENGTH) {
|
||||
if (typeof text !== 'string') return;
|
||||
// Fill the remaining per-session budget with the freshest evidence:
|
||||
// truncate an oversized field instead of discarding it wholesale (whole-
|
||||
// field rejection would let older, smaller fields win the leftover space).
|
||||
const remaining = MAX_SESSION_SEARCHABLE_TOTAL_LENGTH - totalLength;
|
||||
if (remaining <= 0) return;
|
||||
const effectiveMaxLength = Math.min(maxLength, MAX_SEARCHABLE_FIELD_LENGTH, remaining);
|
||||
// Bound the input before trimming so whitespace outside the searchable
|
||||
// window cannot trigger an unbounded scan on every keystroke.
|
||||
const capped = text.slice(0, effectiveMaxLength).trim();
|
||||
if (!capped) return;
|
||||
totalLength += capped.length;
|
||||
fields.push(capped);
|
||||
},
|
||||
get isFull() {
|
||||
return totalLength >= MAX_SESSION_SEARCHABLE_TOTAL_LENGTH;
|
||||
},
|
||||
};
|
||||
return collector;
|
||||
}
|
||||
|
||||
/** Depth bound for argument traversal so pathological nesting cannot recurse unboundedly. */
|
||||
const MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH = 12;
|
||||
|
||||
/**
|
||||
* Serialize tool-call arguments within a hard character budget: traversal and
|
||||
* emitted text both stop as soon as the field cap is reached, so a retained
|
||||
* call carrying a multi-megabyte blob (e.g. an uncapped `content` for
|
||||
* `sftp_write_file`) never materializes in full on every search keystroke.
|
||||
* The output mirrors `JSON.stringify` for the region it covers.
|
||||
*/
|
||||
function serializeToolCallArguments(args: Record<string, unknown>): string {
|
||||
try {
|
||||
let out = '';
|
||||
let exhausted = false;
|
||||
const emit = (text: string): boolean => {
|
||||
const remaining = MAX_TOOL_ARGUMENTS_FIELD_LENGTH - out.length;
|
||||
if (remaining <= 0) {
|
||||
exhausted = true;
|
||||
return false;
|
||||
}
|
||||
if (text.length > remaining) {
|
||||
out += text.slice(0, remaining);
|
||||
exhausted = true;
|
||||
return false;
|
||||
}
|
||||
out += text;
|
||||
return true;
|
||||
};
|
||||
const writeValue = (value: unknown, depth: number): boolean => {
|
||||
if (exhausted) return false;
|
||||
if (value === null || value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return emit('null');
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
return emit(value ? 'true' : 'false');
|
||||
case 'number':
|
||||
return emit(Number.isFinite(value) ? String(value) : 'null');
|
||||
case 'bigint':
|
||||
// `JSON.stringify` throws on BigInt; degrade to a cappable string.
|
||||
return emit(JSON.stringify(String(value)) ?? 'null');
|
||||
case 'string':
|
||||
// Keep per-value allocation bounded too: escaping a multi-megabyte
|
||||
// string whole would defeat the budget even if `emit` slices after.
|
||||
return emit(
|
||||
JSON.stringify(
|
||||
value.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
|
||||
? value.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
|
||||
: value,
|
||||
) ?? 'null',
|
||||
);
|
||||
default: {
|
||||
if (depth <= 0) return emit('null');
|
||||
if (Array.isArray(value)) {
|
||||
if (!emit('[')) return false;
|
||||
let first = true;
|
||||
for (const item of value) {
|
||||
if (!first && !emit(',')) return false;
|
||||
first = false;
|
||||
if (!writeValue(item, depth - 1)) return false;
|
||||
}
|
||||
return emit(']');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (!emit('{')) return false;
|
||||
let first = true;
|
||||
// Iterate own properties lazily instead of materializing the full
|
||||
// `Object.entries` array first: an argument object with thousands
|
||||
// of keys (or one very large key) must not allocate for every
|
||||
// keystroke just to have most of it sliced away again.
|
||||
for (const key in value as Record<string, unknown>) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
||||
if (!first && !emit(',')) return false;
|
||||
first = false;
|
||||
// Cap the key before escaping it: escaping a multi-megabyte
|
||||
// property name whole would defeat the per-field budget.
|
||||
const serializedKey = JSON.stringify(
|
||||
key.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH
|
||||
? key.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH)
|
||||
: key,
|
||||
) ?? 'null';
|
||||
if (!emit(serializedKey)) return false;
|
||||
if (!emit(':')) return false;
|
||||
if (!writeValue(value[key as keyof typeof value], depth - 1)) return false;
|
||||
}
|
||||
return emit('}');
|
||||
}
|
||||
return emit('null');
|
||||
}
|
||||
}
|
||||
};
|
||||
writeValue(args, MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH);
|
||||
return out;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the human-visible text carried by persisted agent activities
|
||||
* (web-search queries, changed file paths, plan items, warnings) as
|
||||
* individual fields. For external SDK sessions these live only in
|
||||
* `message.agentActivities` and are rendered when the conversation is
|
||||
* reopened, so they must be indexed too. Each value is fed to the collector
|
||||
* on its own (capped per value, not per message) so activities accumulated
|
||||
* later in one message stay searchable, and values are pushed newest-first to
|
||||
* match the newest-first budget allocation of the message loop.
|
||||
*/
|
||||
function collectAgentActivityFields(
|
||||
collector: SearchFieldCollector,
|
||||
activities: readonly AgentActivity[],
|
||||
): void {
|
||||
for (let i = activities.length - 1; i >= 0; i--) {
|
||||
if (collector.isFull) return;
|
||||
const activity = activities[i];
|
||||
switch (activity.type) {
|
||||
case 'file_change':
|
||||
for (let j = activity.changes.length - 1; j >= 0; j--) {
|
||||
if (collector.isFull) return;
|
||||
collector.push(activity.changes[j].path, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
}
|
||||
break;
|
||||
case 'web_search':
|
||||
collector.push(activity.query, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
break;
|
||||
case 'plan_update':
|
||||
for (let j = activity.items.length - 1; j >= 0; j--) {
|
||||
if (collector.isFull) return;
|
||||
collector.push(activity.items[j].text, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
}
|
||||
break;
|
||||
case 'warning':
|
||||
collector.push(activity.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the human-visible labels of a message's attachments: file names
|
||||
* and Vault note titles. Rendered from `message.attachments` when the
|
||||
* conversation is reopened (with the legacy `images` field as fallback), so
|
||||
* a session whose only mention of a term sits in an attachment label must
|
||||
* stay findable. Only the short labels are indexed — never the base64
|
||||
* payloads — so attaching files cannot blow up the search haystack.
|
||||
*/
|
||||
function collectAttachmentLabelFields(
|
||||
collector: SearchFieldCollector,
|
||||
attachments: readonly SessionHistorySearchAttachment[],
|
||||
): void {
|
||||
// Individual labels are handed to the collector newest-first so the 2,000-
|
||||
// character field cap (and the remaining session budget) can truncate the
|
||||
// list at its oldest end instead of swallowing every later label from the
|
||||
// reopened conversation.
|
||||
for (let i = attachments.length - 1; i >= 0; i--) {
|
||||
if (collector.isFull) return;
|
||||
const attachment = attachments[i];
|
||||
collector.push(attachment.vaultNoteTitle, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
if (collector.isFull) return;
|
||||
collector.push(attachment.filename, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The title exactly as the history drawer renders it: an empty persisted
|
||||
* title is displayed with the localized "Untitled" fallback, so the fallback
|
||||
* text (passed by the caller) must be indexed too — searching for the label
|
||||
* the user actually sees must not hide the session.
|
||||
*/
|
||||
function resolveDisplayTitle(session: SessionHistorySearchTarget, untitledLabel: string): string {
|
||||
return session.title || untitledLabel;
|
||||
}
|
||||
|
||||
export function collectSessionSearchFields(
|
||||
session: SessionHistorySearchTarget,
|
||||
untitledLabel: string = '',
|
||||
): string[] {
|
||||
const collector = createSearchFieldCollector();
|
||||
// The displayed title is indexed first so it always survives the
|
||||
// total-length cap. Still skipped entirely when both the raw title and the
|
||||
// fallback are blank.
|
||||
collector.push(resolveDisplayTitle(session, untitledLabel));
|
||||
// Fields pushed before the message loop (0 or 1 entries; the title may be
|
||||
// blank and therefore skipped).
|
||||
const headCount = collector.fields.length;
|
||||
// Allocate the remaining budget newest-first: when a long session exceeds
|
||||
// the cap, recent messages stay searchable instead of being silently
|
||||
// dropped in favor of the oldest content.
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
// Stop as soon as the per-session budget is exhausted: older messages
|
||||
// cannot contribute anything, and traversing them (or serializing their
|
||||
// tool arguments/activities) would waste work on every keystroke.
|
||||
if (collector.isFull) break;
|
||||
const message = session.messages[i];
|
||||
// `ChatMessageList` removes `system` messages from the reopened
|
||||
// conversation, so their (hidden) content must not make a session
|
||||
// searchable — otherwise a match would show no matching text when opened.
|
||||
if (message.role === 'system') continue;
|
||||
collector.push(message.content);
|
||||
// `push` drops fields once the budget is spent; skip the remaining
|
||||
// (potentially expensive) serialization for this message too.
|
||||
if (collector.isFull) continue;
|
||||
collector.push(message.thinking);
|
||||
if (collector.isFull) continue;
|
||||
// Persisted status text: when an external SDK turn stops after an
|
||||
// `onStatus` update, the final status remains in `message.statusText`,
|
||||
// is persisted, and is the only distinctive visible text rendered in the
|
||||
// reopened conversation. Status strings stay short, hence the tight cap.
|
||||
collector.push(message.statusText, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
if (collector.isFull) continue;
|
||||
// Persisted failure diagnostics: when a Catty turn fails with empty
|
||||
// content the error message is the only visible text in the reopened
|
||||
// conversation, so it must be indexed. Error strings are short, hence the
|
||||
// tight cap.
|
||||
collector.push(message.errorInfo?.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
collectAttachmentLabelFields(collector, message.attachments ?? message.images ?? []);
|
||||
// Tool calls accumulate at the end of `message.toolCalls`, so iterate
|
||||
// newest-first: if a burst of calls from this message exhausts the
|
||||
// remaining session budget, the newest (still visible) calls stay
|
||||
// searchable instead of being dropped in favor of the oldest ones.
|
||||
const toolCalls = message.toolCalls ?? [];
|
||||
for (let i = toolCalls.length - 1; i >= 0; i--) {
|
||||
if (collector.isFull) break;
|
||||
const toolCall = toolCalls[i];
|
||||
collector.push(toolCall.name);
|
||||
collector.push(serializeToolCallArguments(toolCall.arguments), MAX_TOOL_ARGUMENTS_FIELD_LENGTH);
|
||||
}
|
||||
// Tool results accumulate at the end of `message.toolResults` (and
|
||||
// `ChatMessageList` renders them in array order), so iterate newest-first:
|
||||
// if a burst of large results from this message exhausts the remaining
|
||||
// session budget, the newest (still visible) results stay searchable
|
||||
// instead of being dropped in favor of the oldest ones.
|
||||
const toolResults = message.toolResults ?? [];
|
||||
for (let i = toolResults.length - 1; i >= 0; i--) {
|
||||
if (collector.isFull) break;
|
||||
const toolResult = toolResults[i];
|
||||
collector.push(toolResult.toolName);
|
||||
collector.push(toolResult.content);
|
||||
}
|
||||
if (collector.isFull) continue;
|
||||
collectAgentActivityFields(collector, message.agentActivities ?? []);
|
||||
}
|
||||
const fields = collector.fields;
|
||||
// Restore chronological output order: the head (title) stays first, while
|
||||
// the message fields — collected newest-to-oldest — are reversed back.
|
||||
const head = fields.slice(0, headCount);
|
||||
const tail = fields.slice(headCount);
|
||||
tail.reverse();
|
||||
return [...head, ...tail];
|
||||
}
|
||||
|
||||
export type SessionSearchIndex = {
|
||||
/**
|
||||
* Displayed title the index was built for (raw title or the fallback), capped
|
||||
* exactly like the title field admitted by `collectSessionSearchFields`:
|
||||
* this string feeds the pinyin fallback (`pinyinFields`) on every keystroke,
|
||||
* so an uncapped restored title could push megabytes through pinyin
|
||||
* normalization, and characters beyond the collector's cap are never indexed
|
||||
* (and must not become pinyin-matchable either).
|
||||
*/
|
||||
displayTitle: string;
|
||||
prepared: PreparedSearchFields;
|
||||
};
|
||||
|
||||
export function getSessionSearchTitle(
|
||||
session: SessionHistorySearchTarget,
|
||||
untitledLabel: string = '',
|
||||
): string {
|
||||
const displayTitle = resolveDisplayTitle(session, untitledLabel);
|
||||
// Cap the title exactly like the collector does for the title field (the
|
||||
// first field pushed, so the per-field cap applies in full; trailing
|
||||
// whitespace is trimmed the same way). The capped form is what the index
|
||||
// stores and what the cache-change check compares, so a title that changes
|
||||
// only beyond the cap reuses the cached index — correct, because the
|
||||
// collector never admits those characters into the prepared haystacks
|
||||
// either. A title whose capped form is blank was skipped by the collector
|
||||
// entirely; the blank pinyin field is filtered out downstream.
|
||||
return displayTitle.slice(0, MAX_SEARCHABLE_FIELD_LENGTH).trim();
|
||||
}
|
||||
|
||||
export function prepareSessionSearchIndex(
|
||||
session: SessionHistorySearchTarget,
|
||||
untitledLabel: string = '',
|
||||
): SessionSearchIndex {
|
||||
const fields = collectSessionSearchFields(session, untitledLabel);
|
||||
return {
|
||||
displayTitle: getSessionSearchTitle(session, untitledLabel),
|
||||
prepared: prepareSearchFields(fields),
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSessionSearchIndex(
|
||||
query: string | PreparedSearchQuery,
|
||||
index: SessionSearchIndex,
|
||||
): boolean {
|
||||
const titlePrefix = index.displayTitle.slice(0, MAX_TITLE_PINYIN_LENGTH);
|
||||
// Do not pass a split UTF-16 surrogate pair to the transliterator.
|
||||
const tail = titlePrefix.charCodeAt(titlePrefix.length - 1);
|
||||
const pinyinTitle = tail >= 0xd800 && tail <= 0xdbff
|
||||
? titlePrefix.slice(0, -1)
|
||||
: titlePrefix;
|
||||
// Pinyin transliteration is too expensive to run over every collected
|
||||
// message field on each keystroke; the pinyin fallback is restricted to
|
||||
// bounded title prefix, while literal/compact matching stays
|
||||
// global. Restricting pinyin per field still allows tokens within one
|
||||
// query to span fields: "chongqi nginx" matches a session titled
|
||||
// "重启服务器" whose message contains "nginx" ("chongqi" via title
|
||||
// pinyin, "nginx" literally in the message content).
|
||||
return matchesPreparedSearchQuery(query, index.prepared, {
|
||||
pinyinFields: [pinyinTitle],
|
||||
});
|
||||
}
|
||||
@@ -280,76 +280,3 @@ test("mergeSnippetImportPayload keeps existing empty snippet packages", () => {
|
||||
|
||||
assert.deepEqual(result.snippetPackages, ["local-empty", "imported", "imported/ops"]);
|
||||
});
|
||||
|
||||
test("snippet export and import preserve the all-hosts scope", () => {
|
||||
const exported = buildSnippetExportPayload({
|
||||
snippets: [
|
||||
snippet({
|
||||
id: "all-hosts",
|
||||
label: "Patch everything",
|
||||
command: "apt-get update",
|
||||
targetsAllHosts: true,
|
||||
}),
|
||||
],
|
||||
snippetPackages: [],
|
||||
exportedAt: "2026-10-11T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// New exports carry the all-hosts scope, so they must advertise a version
|
||||
// that pre-change releases do not silently accept while dropping the field.
|
||||
assert.equal(exported.version, 3);
|
||||
const parsed = parseSnippetImportPayload(JSON.stringify(exported));
|
||||
const result = mergeSnippetImportPayload({
|
||||
existingSnippets: [],
|
||||
existingSnippetPackages: [],
|
||||
payload: parsed,
|
||||
conflictAction: "skip",
|
||||
createId: () => "imported-all-hosts",
|
||||
});
|
||||
|
||||
assert.equal(exported.snippets[0].targetsAllHosts, true);
|
||||
assert.equal("targets" in exported.snippets[0], false);
|
||||
assert.equal(parsed.snippets[0].targetsAllHosts, true);
|
||||
assert.equal(result.snippets[0].targetsAllHosts, true);
|
||||
assert.deepEqual(result.snippets[0].targets, []);
|
||||
assert.equal(result.snippets[0].targetGroups, undefined);
|
||||
});
|
||||
|
||||
test("snippet import resolves all-hosts scope against dynamic groups", () => {
|
||||
const parsed = parseSnippetImportPayload(JSON.stringify({
|
||||
kind: "netcatty.snippets",
|
||||
version: 2,
|
||||
exportedAt: "2026-10-11T00:00:00.000Z",
|
||||
snippetPackages: [],
|
||||
snippets: [
|
||||
{
|
||||
label: "All hosts beats groups",
|
||||
command: "uptime",
|
||||
targetGroups: ["Production"],
|
||||
targetsAllHosts: true,
|
||||
},
|
||||
{
|
||||
label: "Explicit false stays unscoped",
|
||||
command: "whoami",
|
||||
targetsAllHosts: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
assert.equal(parsed.snippets[0].targetsAllHosts, true);
|
||||
assert.equal(parsed.snippets[0].targetGroups, undefined);
|
||||
assert.deepEqual(parsed.snippets[0].targetGroups ?? [], []);
|
||||
assert.deepEqual([...(parsed.snippets[0].tags ?? [])], []);
|
||||
assert.equal(parsed.snippets[1].targetsAllHosts, undefined);
|
||||
const merged = mergeSnippetImportPayload({
|
||||
existingSnippets: [],
|
||||
existingSnippetPackages: [],
|
||||
payload: parsed,
|
||||
conflictAction: "skip",
|
||||
createId: () => "import-1",
|
||||
});
|
||||
assert.equal(merged.snippets[0].targetsAllHosts, true);
|
||||
assert.deepEqual(merged.snippets[0].targetGroups, undefined);
|
||||
assert.equal(merged.snippets[1].targetsAllHosts, undefined);
|
||||
assert.deepEqual(merged.snippets[1].targetGroups, undefined);
|
||||
});
|
||||
+11
-46
@@ -4,11 +4,7 @@ import { normalizeVaultOrder } from "./vaultOrder";
|
||||
import { normalizeGroupTargetPaths } from "./hostGroupPathMutations";
|
||||
|
||||
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
|
||||
// Version 3 introduced the portable all-hosts scope (targetsAllHosts); older
|
||||
// releases silently drop that field when they accept a version-2 payload, so
|
||||
// new exports advertise 3 while imports still accept versions 1 and 2.
|
||||
export const SNIPPET_EXPORT_VERSION = 3 as const;
|
||||
export const SNIPPET_EXPORT_VERSION_PREVIOUS = 2 as const;
|
||||
export const SNIPPET_EXPORT_VERSION = 2 as const;
|
||||
export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const;
|
||||
|
||||
export type SnippetImportConflictAction = "skip" | "overwrite";
|
||||
@@ -19,7 +15,6 @@ export type SnippetExportItem = {
|
||||
tags?: string[];
|
||||
package?: string;
|
||||
targetGroups?: string[];
|
||||
targetsAllHosts?: boolean;
|
||||
shortkey?: string;
|
||||
noAutoRun?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
@@ -32,10 +27,7 @@ export type SnippetExportItem = {
|
||||
|
||||
export type SnippetExportPayload = {
|
||||
kind: typeof SNIPPET_EXPORT_KIND;
|
||||
version:
|
||||
| typeof SNIPPET_EXPORT_VERSION
|
||||
| typeof SNIPPET_EXPORT_VERSION_PREVIOUS
|
||||
| typeof SNIPPET_EXPORT_VERSION_LEGACY;
|
||||
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
|
||||
exportedAt: string;
|
||||
snippetPackages: string[];
|
||||
snippets: SnippetExportItem[];
|
||||
@@ -96,20 +88,12 @@ const mergeSnippetPackagePaths = (...groups: string[][]): string[] => {
|
||||
return uniqueStrings(paths);
|
||||
};
|
||||
|
||||
/**
|
||||
* Concrete host-id bindings (targets) are intentionally omitted for portability;
|
||||
* the portable all-hosts scope rides along and suppresses group scope on import.
|
||||
*/
|
||||
const toExportItem = (snippet: Snippet): SnippetExportItem => ({
|
||||
label: snippet.label,
|
||||
command: snippet.command,
|
||||
tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [],
|
||||
package: snippet.package || "",
|
||||
...(snippet.targetsAllHosts
|
||||
? { targetsAllHosts: true }
|
||||
: snippet.targetGroups !== undefined
|
||||
? { targetGroups: [...snippet.targetGroups] }
|
||||
: {}),
|
||||
...(snippet.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}),
|
||||
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
|
||||
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
|
||||
...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}),
|
||||
@@ -150,8 +134,6 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
|
||||
const label = typeof value.label === "string" && value.label.trim()
|
||||
? value.label.trim()
|
||||
: fallbackLabel(value.command);
|
||||
// "Apply to all hosts" is a portable scope; when set it wins over group scope.
|
||||
const targetsAllHosts = value.targetsAllHosts === true;
|
||||
return {
|
||||
label,
|
||||
command: value.command,
|
||||
@@ -159,14 +141,11 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
|
||||
? uniqueStrings(value.tags)
|
||||
: [],
|
||||
package: typeof value.package === "string" ? value.package.trim() : "",
|
||||
targetGroups: targetsAllHosts
|
||||
? undefined
|
||||
: Array.isArray(value.targetGroups)
|
||||
? normalizeGroupTargetPaths(value.targetGroups.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
))
|
||||
: undefined,
|
||||
targetsAllHosts: targetsAllHosts || undefined,
|
||||
targetGroups: Array.isArray(value.targetGroups)
|
||||
? normalizeGroupTargetPaths(value.targetGroups.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
))
|
||||
: undefined,
|
||||
shortkey: typeof value.shortkey === "string" && value.shortkey.trim()
|
||||
? value.shortkey.trim()
|
||||
: undefined,
|
||||
@@ -195,11 +174,7 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
|
||||
if (parsed.kind !== SNIPPET_EXPORT_KIND) {
|
||||
throw new Error("Unsupported snippet import file.");
|
||||
}
|
||||
if (
|
||||
version !== SNIPPET_EXPORT_VERSION &&
|
||||
version !== SNIPPET_EXPORT_VERSION_PREVIOUS &&
|
||||
version !== SNIPPET_EXPORT_VERSION_LEGACY
|
||||
) {
|
||||
if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) {
|
||||
throw new Error("Unsupported snippet import file.");
|
||||
}
|
||||
if (!Array.isArray(parsed.snippets)) {
|
||||
@@ -212,12 +187,7 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
|
||||
|
||||
return {
|
||||
kind: SNIPPET_EXPORT_KIND,
|
||||
version:
|
||||
version === SNIPPET_EXPORT_VERSION
|
||||
? SNIPPET_EXPORT_VERSION
|
||||
: version === SNIPPET_EXPORT_VERSION_PREVIOUS
|
||||
? SNIPPET_EXPORT_VERSION_PREVIOUS
|
||||
: SNIPPET_EXPORT_VERSION_LEGACY,
|
||||
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
|
||||
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
|
||||
snippetPackages: collectSnippetPackagePaths(
|
||||
snippets,
|
||||
@@ -274,13 +244,8 @@ const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number):
|
||||
command: item.command,
|
||||
tags: item.tags || [],
|
||||
package: item.package || "",
|
||||
// Host-id bindings are never imported; the all-hosts flag wins over group scope.
|
||||
targets: [],
|
||||
...(item.targetsAllHosts
|
||||
? { targetsAllHosts: true }
|
||||
: item.targetGroups !== undefined
|
||||
? { targetGroups: [...item.targetGroups] }
|
||||
: {}),
|
||||
...(item.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}),
|
||||
...(item.shortkey ? { shortkey: item.shortkey } : {}),
|
||||
...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}),
|
||||
...(item.multiLineRunMode ? { multiLineRunMode: item.multiLineRunMode } : {}),
|
||||
|
||||
@@ -313,12 +313,6 @@ export interface SyncPayload {
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
/**
|
||||
* Present once the sender has run the autoCloseOnExit default migration.
|
||||
* Legacy payloads omit it; a materialized `true` in those payloads is the
|
||||
* old default, not an explicit opt-in.
|
||||
*/
|
||||
autoCloseOnExitDefaultMigrated?: boolean;
|
||||
terminalSidePanelAutoOpen?: boolean;
|
||||
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen?: boolean;
|
||||
@@ -396,27 +390,6 @@ export interface SyncPayload {
|
||||
convergentSync?: ConvergentSyncEnvelopeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
|
||||
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
|
||||
* false. A payload that records the migration keeps an explicit true.
|
||||
*/
|
||||
export function reconcileInboundAutoCloseOnExit(
|
||||
autoCloseOnExit: unknown,
|
||||
payloadRecordsMigratedChoice: unknown,
|
||||
): unknown {
|
||||
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
|
||||
return autoCloseOnExit;
|
||||
}
|
||||
|
||||
/** True when this side both migrated and explicitly enabled auto-close. */
|
||||
export function payloadOptsIntoAutoClose(
|
||||
settings: SyncPayload['settings'] | undefined,
|
||||
): boolean {
|
||||
return settings?.autoCloseOnExitDefaultMigrated === true
|
||||
&& settings.terminalSettings?.autoCloseOnExit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip device-local connection telemetry from hosts before any sync
|
||||
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
|
||||
|
||||
@@ -631,44 +631,3 @@ test("group notes sync edits and clearing without dropping connection settings",
|
||||
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
|
||||
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
|
||||
assert.equal(merged?.terminalSettings?.scrollback, 3000);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: true },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
|
||||
});
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
|
||||
import {
|
||||
payloadOptsIntoAutoClose,
|
||||
sanitizeHostsForSync,
|
||||
type CloudSyncPayloadEntityKey,
|
||||
type SyncPayload,
|
||||
@@ -404,24 +403,6 @@ function mergeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// A three-way merge can keep this device's migration flag while adopting a
|
||||
// legacy remote autoCloseOnExit: true. That true is the old default unless
|
||||
// one of the sides explicitly opted in after migrating.
|
||||
const terminalSettings = merged.terminalSettings;
|
||||
if (
|
||||
terminalSettings
|
||||
&& typeof terminalSettings === 'object'
|
||||
&& !Array.isArray(terminalSettings)
|
||||
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
|
||||
&& !payloadOptsIntoAutoClose(local)
|
||||
&& !payloadOptsIntoAutoClose(remote)
|
||||
) {
|
||||
merged.terminalSettings = {
|
||||
...(terminalSettings as Record<string, unknown>),
|
||||
autoCloseOnExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => {
|
||||
assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
|
||||
test("normalizeTerminalSettings enables terminal auto-close by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
|
||||
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
|
||||
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
|
||||
|
||||
@@ -58,25 +58,11 @@ function subscribeToPtyData(ptyStream, onData) {
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
// Only the end of the output can hold the idle prompt, and this check runs for
|
||||
// every PTY chunk while the retained output may be hundreds of thousands of
|
||||
// characters long. Normalising the whole text (ANSI and carriage returns) on
|
||||
// each chunk stalls the worker during a flood, so look at a tail window that is
|
||||
// comfortably larger than the prompt including the escape codes it may carry.
|
||||
const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048;
|
||||
const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
if (!normalizedPrompt) return false;
|
||||
const fullText = String(text || "");
|
||||
const windowChars = Math.max(
|
||||
PROMPT_SUFFIX_MIN_WINDOW_CHARS,
|
||||
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
|
||||
);
|
||||
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
|
||||
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
// The behaviour before the tail window was introduced: normalise the whole
|
||||
// text, then compare its end.
|
||||
function referenceHasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# ";
|
||||
|
||||
const CASES = [
|
||||
["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "],
|
||||
["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "],
|
||||
["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "],
|
||||
["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT],
|
||||
["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "],
|
||||
["empty text", "", "root@3160T:~# "],
|
||||
["empty expected prompt", "root@3160T:~# ", ""],
|
||||
["null inputs", null, null],
|
||||
["text shorter than the prompt", "~# ", "root@3160T:~# "],
|
||||
];
|
||||
|
||||
for (const [name, text, prompt] of CASES) {
|
||||
test(`prompt suffix matches the full-text result: ${name}`, () => {
|
||||
assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt));
|
||||
});
|
||||
}
|
||||
|
||||
test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
const flood = "y\r\n".repeat(700_000);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false);
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("prompt suffix check does not scan the whole buffer on every call", () => {
|
||||
// 2M characters of CRLF output used to cost seconds per call because every
|
||||
// call stripped ANSI and carriage returns over the entire text.
|
||||
const flood = "y\r\n".repeat(700_000);
|
||||
const started = process.hrtime.bigint();
|
||||
for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# ");
|
||||
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
||||
assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`);
|
||||
});
|
||||
@@ -3,9 +3,7 @@
|
||||
*
|
||||
* Log files are stored as JSONL (one JSON object per line) under
|
||||
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
|
||||
* atomic. Files older than 30 days are pruned on startup and every file is
|
||||
* capped at 16 MB: once today's file is full, further entries are dropped,
|
||||
* and oversized files left by previous runs are trimmed to their tail.
|
||||
* atomic. Files older than 30 days are pruned on startup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -26,15 +24,6 @@ let electronShell = null;
|
||||
let sessionsMap = null;
|
||||
|
||||
const LOG_RETENTION_DAYS = 30;
|
||||
// Hard caps so a recurring error can never fill the disk (see issue #3626:
|
||||
// a single crash-YYYY-MM-DD.log grew to ~150 GB). The per-day file stops
|
||||
// accepting entries once it reaches MAX_LOG_FILE_BYTES and existing oversized
|
||||
// files are trimmed down to their tail at startup.
|
||||
const MAX_LOG_FILE_BYTES = 16 * 1024 * 1024;
|
||||
// Cap the size of a single serialized entry before it is appended.
|
||||
const MAX_LOG_ENTRY_BYTES = 128 * 1024;
|
||||
const MAX_MESSAGE_CHARS = 4096;
|
||||
const MAX_STACK_CHARS = 16384;
|
||||
const TERMINAL_PERF_DEBUG_ENV_KEYS = [
|
||||
"NETCATTY_TERMINAL_PERF_DEBUG",
|
||||
"NETCATTY_TERMINAL_DEBUG",
|
||||
@@ -85,11 +74,6 @@ function todayFileName() {
|
||||
return `crash-${ymd}.log`;
|
||||
}
|
||||
|
||||
function boundedText(text, maxChars) {
|
||||
if (typeof text !== "string" || text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)} ... [truncated]`;
|
||||
}
|
||||
|
||||
function buildEntry(source, err, extra) {
|
||||
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
|
||||
|
||||
@@ -116,8 +100,8 @@ function buildEntry(source, err, extra) {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
source,
|
||||
message: boundedText(error.message || String(err), MAX_MESSAGE_CHARS),
|
||||
stack: boundedText(error.stack, MAX_STACK_CHARS) || undefined,
|
||||
message: error.message || String(err),
|
||||
stack: error.stack || undefined,
|
||||
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
|
||||
extra: extra || undefined,
|
||||
pid: process.pid,
|
||||
@@ -136,70 +120,6 @@ function buildEntry(source, err, extra) {
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Append one JSONL line to filePath as long as the file stays under
|
||||
* MAX_LOG_FILE_BYTES. Oversized entries are rejected; once a day's file is
|
||||
* full, later entries are dropped instead of growing the file further.
|
||||
* Returns true when the line was written.
|
||||
*/
|
||||
function appendEntryLine(filePath, line) {
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8");
|
||||
if (lineBytes + 1 > MAX_LOG_ENTRY_BYTES) return false;
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch {
|
||||
size = 0; // file does not exist yet
|
||||
}
|
||||
if (size + lineBytes + 1 > MAX_LOG_FILE_BYTES) return false;
|
||||
|
||||
try {
|
||||
fs.appendFileSync(filePath, line + "\n", "utf-8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `line` (plus its trailing newline) fits inside the per-entry byte
|
||||
* cap. JSON.stringify measures UTF-16 code units, but the file stores UTF-8
|
||||
* bytes, so multibyte text must be measured with Buffer.byteLength here to
|
||||
* match what appendEntryLine actually enforces.
|
||||
*/
|
||||
function fitsEntryByteBudget(line) {
|
||||
return Buffer.byteLength(line, "utf-8") + 1 <= MAX_LOG_ENTRY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a crash entry into a bounded JSONL line. If the caller-supplied
|
||||
* extra payload (or anything else) blows past MAX_LOG_ENTRY_BYTES, retry with
|
||||
* progressively less detail so the line length is always bounded.
|
||||
*/
|
||||
function serializeEntry(entry) {
|
||||
let line = JSON.stringify(entry);
|
||||
if (fitsEntryByteBudget(line)) return line;
|
||||
|
||||
const leanEntry = {
|
||||
...entry,
|
||||
message: boundedText(entry.message, 1024),
|
||||
stack: boundedText(entry.stack, MAX_STACK_CHARS),
|
||||
extra: undefined,
|
||||
};
|
||||
line = JSON.stringify(leanEntry);
|
||||
if (fitsEntryByteBudget(line)) return line;
|
||||
|
||||
const minimalEntry = {
|
||||
timestamp: entry.timestamp,
|
||||
source: boundedText(entry.source, 256),
|
||||
message: boundedText(entry.message, 512),
|
||||
pid: entry.pid,
|
||||
version: entry.version,
|
||||
};
|
||||
return JSON.stringify(minimalEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a crash/error entry to today's log file (sync, safe for use in
|
||||
* uncaughtException handlers).
|
||||
@@ -210,9 +130,8 @@ function captureError(source, err, extra) {
|
||||
if (!dir) return;
|
||||
|
||||
const entry = buildEntry(source, err, extra);
|
||||
const line = serializeEntry(entry);
|
||||
const filePath = path.join(dir, todayFileName());
|
||||
appendEntryLine(filePath, line);
|
||||
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
||||
} catch {
|
||||
// Never throw from the crash logger itself.
|
||||
}
|
||||
@@ -255,89 +174,6 @@ function pruneOldLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an oversized log file in place so only its last MAX_LOG_FILE_BYTES
|
||||
* (aligned to a line boundary so every remaining line parses) is kept. This
|
||||
* reads and writes at most MAX_LOG_FILE_BYTES, so it is safe even for a
|
||||
* multi-gigabyte file. Returns true when the file was trimmed.
|
||||
*/
|
||||
function truncateFileToTail(filePath, maxBytes) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isFile() || stat.size <= maxBytes) return false;
|
||||
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
|
||||
// Validate the opened object before reading or truncating it. O_NOFOLLOW
|
||||
// is not available on every platform, and the path may have changed.
|
||||
const openedStat = fs.fstatSync(fd);
|
||||
if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) {
|
||||
return false;
|
||||
}
|
||||
if (openedStat.size <= maxBytes) return false;
|
||||
const buf = Buffer.alloc(maxBytes);
|
||||
const read = fs.readSync(fd, buf, 0, buf.length, openedStat.size - buf.length);
|
||||
const tail = buf.subarray(0, read);
|
||||
// Drop the first (possibly partial) line of the tail so every remaining
|
||||
// line is a complete JSONL entry. Also trim anything after the last
|
||||
// newline: a final write interrupted before its trailing newline would
|
||||
// otherwise leave a partial object that the next appendEntryLine appends
|
||||
// to, corrupting both lines. If the tail contains no newline at all it
|
||||
// is one giant partial entry (e.g. from an interrupted write), so
|
||||
// discard it entirely rather than keeping an unappendable file: the file
|
||||
// would sit exactly at the cap and reject every later report for the day.
|
||||
const newline = tail.indexOf("\n");
|
||||
let keep = Buffer.alloc(0);
|
||||
if (newline >= 0) {
|
||||
const lastNewline = tail.lastIndexOf("\n");
|
||||
keep = tail.subarray(newline + 1, lastNewline + 1);
|
||||
}
|
||||
fs.ftruncateSync(fd, keep.length);
|
||||
if (keep.length > 0) {
|
||||
fs.writeSync(fd, keep, 0, keep.length, 0);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap every existing crash log file at MAX_LOG_FILE_BYTES. Runs at startup
|
||||
* before any new entries are appended, so a bloated file from a previous run
|
||||
* (e.g. issue #3626) is shrunk instead of filling the disk for another day.
|
||||
*/
|
||||
function trimOversizedLogs() {
|
||||
try {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return;
|
||||
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||||
try {
|
||||
if (truncateFileToTail(path.join(dir, file), MAX_LOG_FILE_BYTES)) {
|
||||
console.log(`[CrashLog] Trimmed oversized log: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -495,7 +331,6 @@ function init(deps) {
|
||||
// ignore
|
||||
}
|
||||
pruneOldLogs();
|
||||
trimOversizedLogs();
|
||||
|
||||
console.log(`[CrashLog] Crash log directory: ${logDir}`);
|
||||
}
|
||||
@@ -524,22 +359,9 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
// Internals exposed for tests only; not part of the public bridge API.
|
||||
const __internals = {
|
||||
MAX_LOG_FILE_BYTES,
|
||||
MAX_LOG_ENTRY_BYTES,
|
||||
MAX_MESSAGE_CHARS,
|
||||
MAX_STACK_CHARS,
|
||||
appendEntryLine,
|
||||
boundedText,
|
||||
serializeEntry,
|
||||
truncateFileToTail,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
captureError,
|
||||
captureDiagnostic,
|
||||
registerHandlers,
|
||||
__internals,
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
MAX_LOG_FILE_BYTES,
|
||||
MAX_LOG_ENTRY_BYTES,
|
||||
appendEntryLine,
|
||||
boundedText,
|
||||
serializeEntry,
|
||||
truncateFileToTail,
|
||||
} = require("./crashLogBridge.cjs").__internals;
|
||||
|
||||
// Creates a unique temp directory for one test and registers teardown on the
|
||||
// test context so fixture files never outlive the run.
|
||||
function tmpFile(t, name = "crash-2026-10-10.log") {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-crashlog-"));
|
||||
t.after(() => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best effort cleanup
|
||||
}
|
||||
});
|
||||
return { dir, filePath: path.join(dir, name) };
|
||||
}
|
||||
|
||||
function entryLine(padLen = 0) {
|
||||
return JSON.stringify({
|
||||
timestamp: "2026-10-10T00:00:00.000Z",
|
||||
source: "test",
|
||||
message: "boom",
|
||||
...(padLen > 0 ? { pad: "p".repeat(padLen) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
test("appendEntryLine stops accepting entries once the per-day file reaches the size cap", (t) => {
|
||||
const { filePath } = tmpFile(t);
|
||||
|
||||
assert.equal(appendEntryLine(filePath, entryLine()), true, "first entry written");
|
||||
assert.equal(
|
||||
appendEntryLine(filePath, "x".repeat(MAX_LOG_ENTRY_BYTES)),
|
||||
false,
|
||||
"oversized entry rejected",
|
||||
);
|
||||
|
||||
// Model the boundary with a small real file, without allocating a full log.
|
||||
const filler = JSON.stringify({ message: "\u4e2d" });
|
||||
const lineBytes = Buffer.byteLength(filler, "utf-8");
|
||||
const before = fs.readFileSync(filePath, "utf-8");
|
||||
const stat = t.mock.method(fs, "statSync", () => ({ size: MAX_LOG_FILE_BYTES - lineBytes }));
|
||||
assert.equal(appendEntryLine(filePath, filler), false, "newline would exceed cap by one byte");
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), before);
|
||||
stat.mock.mockImplementation(() => ({ size: MAX_LOG_FILE_BYTES - lineBytes - 1 }));
|
||||
assert.equal(appendEntryLine(filePath, filler), true, "entry plus newline fits exactly");
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), before + filler + "\n");
|
||||
});
|
||||
|
||||
test("serializeEntry keeps serialized lines bounded even for huge extras", () => {
|
||||
const huge = { blob: "z".repeat(MAX_LOG_ENTRY_BYTES + 1) };
|
||||
const line = serializeEntry({
|
||||
timestamp: "2026-10-10T00:00:00.000Z",
|
||||
source: "test",
|
||||
message: "boom",
|
||||
stack: new Error("boom").stack,
|
||||
extra: huge,
|
||||
});
|
||||
assert.ok(line.length <= MAX_LOG_ENTRY_BYTES, `line length ${line.length} exceeds cap`);
|
||||
const entry = JSON.parse(line);
|
||||
assert.equal(entry.source, "test");
|
||||
assert.equal(entry.message, "boom");
|
||||
});
|
||||
|
||||
test("boundedText truncates long strings and leaves short ones alone", () => {
|
||||
assert.equal(boundedText("short", 100), "short");
|
||||
const cut = boundedText("a".repeat(5000), 4096);
|
||||
assert.equal(cut.length, 4096 + " ... [truncated]".length);
|
||||
assert.ok(cut.startsWith("a".repeat(4096)));
|
||||
assert.ok(cut.endsWith("[truncated]"));
|
||||
});
|
||||
|
||||
test("truncateFileToTail trims oversized files to their last lines", (t) => {
|
||||
const { filePath } = tmpFile(t);
|
||||
|
||||
const maxBytes = 1024;
|
||||
const line = entryLine(128);
|
||||
const content = (line + "\n").repeat(8);
|
||||
fs.writeFileSync(filePath, content);
|
||||
assert.ok(fs.statSync(filePath).size > maxBytes);
|
||||
|
||||
assert.equal(truncateFileToTail(filePath, maxBytes), true);
|
||||
const newSize = fs.statSync(filePath).size;
|
||||
assert.ok(newSize <= maxBytes, `trimmed size ${newSize} exceeds cap`);
|
||||
assert.equal(appendEntryLine(filePath, entryLine()), true, "trimmed log remains appendable");
|
||||
// Every remaining line must be a complete, parseable JSONL entry.
|
||||
const remaining = fs.readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
|
||||
assert.ok(remaining.length > 0);
|
||||
for (const l of remaining) assert.doesNotThrow(() => JSON.parse(l));
|
||||
|
||||
// Files under the cap are left untouched.
|
||||
assert.equal(truncateFileToTail(filePath, maxBytes), false);
|
||||
});
|
||||
|
||||
test("truncateFileToTail tolerates a tail with no newline and missing files", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
|
||||
// Missing file
|
||||
assert.equal(truncateFileToTail(path.join(dir, "crash-2026-01-01.log"), MAX_LOG_FILE_BYTES), false);
|
||||
|
||||
// Discard an oversized unterminated line so later entries can be appended.
|
||||
fs.appendFileSync(filePath, "g".repeat(8192));
|
||||
assert.equal(truncateFileToTail(filePath, 4096), true);
|
||||
assert.equal(fs.statSync(filePath).size, 0);
|
||||
|
||||
// Oversized tail whose final write was interrupted before its trailing
|
||||
// newline: three terminated entries followed by one entry whose trailing
|
||||
// newline was never written. The partial final line must be dropped so the
|
||||
// rewritten file ends on a complete JSONL boundary and the next append does
|
||||
// not merge with a partial object.
|
||||
fs.appendFileSync(
|
||||
filePath,
|
||||
Array.from({ length: 3 }, () => entryLine(1200) + "\n").join("") +
|
||||
entryLine(1200),
|
||||
);
|
||||
assert.ok(fs.statSync(filePath).size > 4096);
|
||||
assert.equal(truncateFileToTail(filePath, 4096), true);
|
||||
const trimmed = fs.readFileSync(filePath, "utf-8");
|
||||
assert.ok(trimmed.endsWith("\n"), "trimmed file must end on a line boundary");
|
||||
for (const l of trimmed.split("\n").filter(Boolean)) {
|
||||
assert.doesNotThrow(() => JSON.parse(l));
|
||||
}
|
||||
});
|
||||
|
||||
test("truncateFileToTail leaves symlink targets and non-regular files untouched", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
const target = path.join(dir, "unrelated.txt");
|
||||
const content = (entryLine() + "\n").repeat(4);
|
||||
fs.writeFileSync(target, content);
|
||||
fs.symlinkSync(target, filePath);
|
||||
assert.equal(truncateFileToTail(filePath, 128), false);
|
||||
assert.equal(fs.readFileSync(target, "utf-8"), content);
|
||||
assert.ok(fs.lstatSync(filePath).isSymbolicLink());
|
||||
assert.equal(truncateFileToTail(dir, 1), false);
|
||||
});
|
||||
|
||||
test("truncateFileToTail verifies the opened file before modifying it", (t) => {
|
||||
const { dir, filePath } = tmpFile(t);
|
||||
const replacement = path.join(dir, "replacement.log");
|
||||
const content = (entryLine() + "\n").repeat(4);
|
||||
fs.writeFileSync(filePath, content);
|
||||
fs.writeFileSync(replacement, content);
|
||||
const openSync = fs.openSync;
|
||||
const open = t.mock.method(fs, "openSync", (_filePath, flags) => openSync(replacement, flags));
|
||||
assert.equal(truncateFileToTail(filePath, 128), false);
|
||||
open.mock.restore();
|
||||
assert.equal(fs.readFileSync(filePath, "utf-8"), content);
|
||||
assert.equal(fs.readFileSync(replacement, "utf-8"), content);
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB
|
||||
// operations, deployments); a 30-minute silent close destroyed more in-flight
|
||||
// work than it saved. 8h covers a workday without sitting on the 24h clamp
|
||||
// maximum. Users can still set a shorter window in Settings -> AI.
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Server } = require("ssh2");
|
||||
const bridge = require("./sshBridge.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const listen = (server, ...args) => new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(...args, resolve);
|
||||
});
|
||||
const close = (server) => new Promise((resolve) => server.close(resolve));
|
||||
|
||||
test("normal SSH startup preserves fallback after an unavailable system agent", {
|
||||
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
|
||||
timeout: 20_000,
|
||||
}, async (t) => {
|
||||
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
|
||||
const originalHomedir = os.homedir;
|
||||
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
os.homedir = () => fixture; // Never discover the developer's default keys.
|
||||
t.after(() => {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
|
||||
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
});
|
||||
const hostKey = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
}).privateKey;
|
||||
|
||||
let sequence = 0;
|
||||
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
|
||||
const senderId = 3640 + sequence++;
|
||||
const socketPath = path.join(fixture, "s");
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
const agentClients = new Set();
|
||||
let agentChecks = 0;
|
||||
const agent = net.createServer((socket) => {
|
||||
agentClients.add(socket);
|
||||
agentChecks += 1;
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => agentClients.delete(socket));
|
||||
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
|
||||
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
|
||||
});
|
||||
await listen(agent, socketPath);
|
||||
if (stale) {
|
||||
// Preserve a real socket entry with no listener, without a timing race.
|
||||
fs.renameSync(socketPath, socketPath + ".stale");
|
||||
await close(agent);
|
||||
fs.renameSync(socketPath + ".stale", socketPath);
|
||||
assert.ok(fs.statSync(socketPath).isSocket());
|
||||
}
|
||||
process.env.SSH_AUTH_SOCK = socketPath;
|
||||
const sshClients = new Set();
|
||||
const forwardingSockets = new Set();
|
||||
const methods = [];
|
||||
let shells = 0;
|
||||
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
sshClients.add(client);
|
||||
client.on("error", () => {});
|
||||
client.on("close", () => sshClients.delete(client));
|
||||
client.on("tcpip", (accept, reject, info) => {
|
||||
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
|
||||
const socket = net.connect(info.destPort, info.destIP, () => {
|
||||
const stream = accept();
|
||||
stream.on("error", () => socket.destroy());
|
||||
stream.pipe(socket).pipe(stream);
|
||||
});
|
||||
forwardingSockets.add(socket);
|
||||
socket.on("close", () => forwardingSockets.delete(socket));
|
||||
socket.on("error", () => reject());
|
||||
});
|
||||
client.on("authentication", (ctx) => {
|
||||
methods.push(ctx.method);
|
||||
if (ctx.method === "password" && stall) return;
|
||||
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
|
||||
else ctx.reject(["publickey", "password"]);
|
||||
});
|
||||
client.on("ready", () => client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("pty", (acceptPty) => acceptPty());
|
||||
session.on("shell", (acceptShell) => {
|
||||
shells += 1;
|
||||
acceptShell().write("fixture ready\r\n");
|
||||
});
|
||||
}));
|
||||
});
|
||||
await listen(ssh, 0, "127.0.0.1");
|
||||
const handlers = new Map();
|
||||
const sent = [];
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
|
||||
const event = { sender: { id: senderId, isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }) } };
|
||||
let guard;
|
||||
try {
|
||||
const start = handlers.get("netcatty:start")(event, {
|
||||
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
|
||||
username: "fixture", password, authMethod: "auto", useSshAgent,
|
||||
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
...(jump ? { jumpHosts: [{
|
||||
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
|
||||
password, authMethod: "auto", useSshAgent,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
}] } : {}),
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await Promise.race([start, new Promise((_, reject) => {
|
||||
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
|
||||
})]);
|
||||
} catch (err) { error = err; }
|
||||
return { error, methods, shells, sent: sent.slice(), agentChecks };
|
||||
} finally {
|
||||
clearTimeout(guard);
|
||||
for (const client of sshClients) client.end();
|
||||
for (const socket of forwardingSockets) socket.destroy();
|
||||
for (const client of agentClients) client.destroy();
|
||||
if (agent.listening) await close(agent);
|
||||
await close(ssh);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, options] of [
|
||||
["stale agent", {}],
|
||||
["disabled agent", { useSshAgent: false }],
|
||||
["healthy empty agent", { stale: false }],
|
||||
]) {
|
||||
await t.test(`${name} still opens a real shell using the saved password`, async () => {
|
||||
const result = await run(options);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
|
||||
});
|
||||
}
|
||||
for (const [name, password, methods] of [
|
||||
["agent without other credentials", undefined, ["none"]],
|
||||
["incorrect password", "wrong-password", ["none", "password"]],
|
||||
]) {
|
||||
await t.test(`${name} still reports a real authentication failure`, async () => {
|
||||
// null deliberately omits a password rather than taking run's default.
|
||||
const result = await run({ password: password ?? null });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.match(result.error.message, /All configured authentication methods failed/);
|
||||
assert.deepEqual(result.methods, methods);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
|
||||
});
|
||||
}
|
||||
await t.test("stalled password fallback retains its authentication deadline", async () => {
|
||||
const result = await run({ stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel, payload }) => (
|
||||
channel === "netcatty:exit" && payload.reason === "timeout"
|
||||
)));
|
||||
});
|
||||
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
|
||||
const result = await run({ jump: true });
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
});
|
||||
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
|
||||
const result = await run({ jump: true, password: "wrong-password" });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.equal(result.error?.isJumpHostAuthError, true);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
|
||||
const result = await run({ jump: true, stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
});
|
||||
@@ -813,12 +813,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
// ssh2 continues to the next auth method after agent errors.
|
||||
if (err.level === 'agent') {
|
||||
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
|
||||
return;
|
||||
}
|
||||
conn.once('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAuthReadyTimer();
|
||||
|
||||
@@ -2415,90 +2415,7 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "signaled");
|
||||
assert.equal(exits[0].exitSignal, "SIGUSR2");
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
|
||||
});
|
||||
|
||||
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
|
||||
const session = sessions.get("signal-then-conn-close");
|
||||
|
||||
// ssh2 can emit conn "close" before the channel's paced finalize. The
|
||||
// signal recorded on "exit" must still win over a generic transport close.
|
||||
session.stream.emit("exit", null, "SIGHUP");
|
||||
session.conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "signaled");
|
||||
assert.equal(exits[0].exitSignal, "SIGHUP");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
});
|
||||
|
||||
test("a connection close without an exit-signal stays a transport close", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
|
||||
sessions.get("transport-conn-close").conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
|
||||
assert.equal(exits[0].exitSignal, undefined);
|
||||
});
|
||||
|
||||
for (const exitCode of [0, 7]) {
|
||||
test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const sessionId = `status-then-conn-close-${exitCode}`;
|
||||
await start({ sender }, {
|
||||
hostname: "10.0.0.50", username: "alice", port: 22,
|
||||
authMethod: "password", password: "secret",
|
||||
useSshAgent: false, verifyHostKeys: false, sessionId,
|
||||
});
|
||||
const session = sessions.get(sessionId);
|
||||
session.stream.emit("exit", exitCode);
|
||||
session.conn.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "exited");
|
||||
assert.equal(exits[0].exitCode, exitCode);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Classify how a remote SSH shell channel ended.
|
||||
*
|
||||
* An exit-signal (ssh2: code null, signal set) is an administrative
|
||||
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
|
||||
* Those stay "signaled" so auto-reconnect can leave them alone.
|
||||
* A channel that closes with neither exit-status nor exit-signal stays
|
||||
* "closed" and is still eligible to reconnect.
|
||||
*/
|
||||
|
||||
function normalizeRemoteExitSignal(signal) {
|
||||
return typeof signal === "string" && signal.length > 0 ? signal : null;
|
||||
}
|
||||
|
||||
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
|
||||
if (idleTimedOut) return "timeout";
|
||||
const signal = normalizeRemoteExitSignal(exitSignal);
|
||||
if (signal) return "signaled";
|
||||
if (shellExited) return "exited";
|
||||
return "closed";
|
||||
}
|
||||
|
||||
function buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
generation,
|
||||
exitCode,
|
||||
exitSignal,
|
||||
shellExited,
|
||||
idleTimedOut,
|
||||
}) {
|
||||
const signal = normalizeRemoteExitSignal(exitSignal);
|
||||
const reason = classifyRemoteShellClose({
|
||||
idleTimedOut: idleTimedOut === true,
|
||||
exitSignal: signal,
|
||||
shellExited: shellExited === true,
|
||||
});
|
||||
const payload = {
|
||||
sessionId,
|
||||
reason,
|
||||
_terminalSessionGeneration: generation,
|
||||
};
|
||||
if (typeof exitCode === "number") {
|
||||
payload.exitCode = exitCode;
|
||||
} else if (!signal) {
|
||||
payload.remoteClosedWithoutExitStatus = true;
|
||||
}
|
||||
if (signal) {
|
||||
payload.exitSignal = signal;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeRemoteExitSignal,
|
||||
classifyRemoteShellClose,
|
||||
buildRemoteShellExitPayload,
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildRemoteShellExitPayload,
|
||||
classifyRemoteShellClose,
|
||||
normalizeRemoteExitSignal,
|
||||
} = require("./remoteShellExit.cjs");
|
||||
|
||||
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
|
||||
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
|
||||
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
|
||||
assert.equal(normalizeRemoteExitSignal(""), null);
|
||||
assert.equal(normalizeRemoteExitSignal(null), null);
|
||||
assert.equal(normalizeRemoteExitSignal(undefined), null);
|
||||
});
|
||||
|
||||
test("an exit-signal is an administrative signaled close, not a transport close", () => {
|
||||
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
|
||||
"signaled",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a channel close without exit-status or exit-signal stays closed", () => {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
|
||||
"exited",
|
||||
);
|
||||
assert.equal(
|
||||
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
|
||||
"timeout",
|
||||
);
|
||||
});
|
||||
|
||||
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
|
||||
const payload = buildRemoteShellExitPayload({
|
||||
sessionId: "signal-exit",
|
||||
generation: 3,
|
||||
exitSignal: "SIGUSR2",
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
});
|
||||
|
||||
assert.equal(payload.reason, "signaled");
|
||||
assert.equal(payload.exitSignal, "SIGUSR2");
|
||||
assert.equal(payload.exitCode, undefined);
|
||||
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
|
||||
assert.equal(payload._terminalSessionGeneration, 3);
|
||||
});
|
||||
|
||||
test("a bare channel-close payload stays a reconnectable transport close", () => {
|
||||
const payload = buildRemoteShellExitPayload({
|
||||
sessionId: "transport-close",
|
||||
exitSignal: null,
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
});
|
||||
|
||||
assert.equal(payload.reason, "closed");
|
||||
assert.equal(payload.exitSignal, undefined);
|
||||
assert.equal(payload.exitCode, undefined);
|
||||
assert.equal(payload.remoteClosedWithoutExitStatus, true);
|
||||
});
|
||||
@@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) {
|
||||
disks, // Array of all mounted disks
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
|
||||
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
|
||||
netInterfaces, // Per-interface network stats
|
||||
hostname,
|
||||
osName,
|
||||
|
||||
@@ -746,46 +746,6 @@ test("getServerStats measures latency by pinging the stats connection", async ()
|
||||
assert.deepEqual(pingedConns, [session.moshStatsConn]);
|
||||
});
|
||||
|
||||
test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => {
|
||||
const net = require("node:net");
|
||||
const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs");
|
||||
let accepted = 0;
|
||||
const server = net.createServer((socket) => {
|
||||
accepted += 1;
|
||||
socket.destroy();
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(() => resolve())));
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
const conn = Object.assign(fakeConn(LINUX_STATS), {
|
||||
config: { keepaliveInterval: 30000 },
|
||||
});
|
||||
const sessions = new Map([["sid", {
|
||||
type: "ssh",
|
||||
conn,
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
_reuseEndpoint: { hostname: "127.0.0.1", port },
|
||||
}]]);
|
||||
let pingTarget = null;
|
||||
const measureSshPingLatency = createSshPingLatencyProbe();
|
||||
const api = makeSessionOps(sessions, {
|
||||
measureSshPingLatency: (target, timeoutMs) => {
|
||||
pingTarget = target;
|
||||
return measureSshPingLatency(target, timeoutMs);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(pingTarget, conn);
|
||||
assert.equal(result.stats.latencyMs, null);
|
||||
assert.equal(accepted, 0);
|
||||
});
|
||||
|
||||
test("getServerStats pings the companion connection for jump-host sessions", async () => {
|
||||
const sessions = new Map([["sid", {
|
||||
type: "mosh",
|
||||
|
||||
@@ -27,10 +27,6 @@ const {
|
||||
annotateMacLocalNetworkErrorMessage,
|
||||
resolveFirstTcpEndpoint,
|
||||
} = require("../macLocalNetworkAccess.cjs");
|
||||
const {
|
||||
buildRemoteShellExitPayload,
|
||||
normalizeRemoteExitSignal,
|
||||
} = require("./remoteShellExit.cjs");
|
||||
|
||||
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
||||
@@ -548,15 +544,6 @@ function createStartSessionApi(ctx) {
|
||||
),
|
||||
});
|
||||
},
|
||||
// Upload progress tracks bytes the transport actually accepted instead
|
||||
// of bytes queued (issue #3663): ssh2 keeps writableLength unchanged
|
||||
// until one queued write fully completes but shrinks _chunk as the
|
||||
// channel window opens, so summing both approximates the outstanding
|
||||
// backlog while it drains. Without this, an rz progress bar freezes
|
||||
// mid-file for as long as the channel window takes to flush.
|
||||
getTransportPendingBytes() {
|
||||
return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0);
|
||||
},
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
@@ -678,8 +665,7 @@ function createStartSessionApi(ctx) {
|
||||
// "close" fires whenever the channel closes (could be network drop).
|
||||
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
||||
// code and no signal. Signal terminations (e.g. server kill, idle
|
||||
// timeout) have code=null and signal set — those are not user exits
|
||||
// and are reported as "signaled", not the reconnectable "closed".
|
||||
// timeout) have code=null and signal set — those are not user exits.
|
||||
// A third case exists: a remote that closes the channel without sending
|
||||
// exit-status or exit-signal never fires "exit" at all (#3591). The
|
||||
// initial values below stay null in that case and must not be reported
|
||||
@@ -690,17 +676,8 @@ function createStartSessionApi(ctx) {
|
||||
stream.on("exit", (code, signal) => {
|
||||
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
|
||||
streamExitCode = typeof code === "number" ? code : null;
|
||||
streamExitSignal = normalizeRemoteExitSignal(signal);
|
||||
streamExitSignal = typeof signal === "string" && signal ? signal : null;
|
||||
streamExited = typeof code === "number" && !streamExitSignal;
|
||||
// conn "close" can win the race against this channel's paced
|
||||
// finalize. Stamp the signal so that path can still tell an
|
||||
// administrative exit-signal from a transport drop.
|
||||
if (streamExitSignal && sessions.get(sessionId) === session) {
|
||||
session._remoteExitSignal = streamExitSignal;
|
||||
}
|
||||
if (streamExited && sessions.get(sessionId) === session) {
|
||||
session._remoteExitCode = streamExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
let closeFinalized = false;
|
||||
@@ -709,7 +686,6 @@ function createStartSessionApi(ctx) {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
streamExitCode,
|
||||
streamExitSignal,
|
||||
streamExited,
|
||||
reused: !!isReused,
|
||||
transportError: sessions.get(sessionId)?._transportError,
|
||||
@@ -741,20 +717,27 @@ function createStartSessionApi(ctx) {
|
||||
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
||||
});
|
||||
} else {
|
||||
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
|
||||
// is reported as "timeout" via the shell banner (#1062 / #977).
|
||||
// An exit-signal is a separate administrative kick ("signaled").
|
||||
// A channel close with neither exit-status nor exit-signal stays
|
||||
// "closed" and must not be reported as code 0 (#3591).
|
||||
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
||||
// signal) — identical to a user-typed `exit` by code/signal —
|
||||
// so detect it via the banner the shell prints just before
|
||||
// exiting and report it as a timeout. That keeps the tab open
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
|
||||
const exitPayload = buildRemoteShellExitPayload({
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
const exitPayload = {
|
||||
sessionId,
|
||||
generation: liveSession?._terminalSessionGeneration,
|
||||
exitCode: streamExitCode,
|
||||
exitSignal: streamExitSignal,
|
||||
shellExited: streamExited,
|
||||
idleTimedOut,
|
||||
});
|
||||
reason,
|
||||
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
||||
};
|
||||
// Report only exit facts the remote actually sent (#3591). When
|
||||
// the peer closed the channel without exit-status/exit-signal,
|
||||
// there is no code to show — flag it instead so the renderer can
|
||||
// distinguish an abnormal teardown from a user-typed `exit 0`.
|
||||
if (typeof streamExitCode === "number") {
|
||||
exitPayload.exitCode = streamExitCode;
|
||||
} else if (streamExitSignal === null) {
|
||||
exitPayload.remoteClosedWithoutExitStatus = true;
|
||||
}
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
|
||||
}
|
||||
liveSession?.zmodemSentry?.cancel();
|
||||
@@ -2481,16 +2464,6 @@ function createStartSessionApi(ctx) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// ssh2 reports agent failures before trying the next auth method.
|
||||
// Keep the connection and its authentication deadline alive.
|
||||
if (err.level === "agent") {
|
||||
log("non-fatal agent auth error (will try next method)", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
error: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearAuthReadyTimer();
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
@@ -2665,28 +2638,13 @@ function createStartSessionApi(ctx) {
|
||||
reason: "error",
|
||||
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
||||
});
|
||||
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
|
||||
// The shell already reported an exit-signal. This connection
|
||||
// close is the server finishing that administrative kick, not
|
||||
// a transport flap — keep it out of the reconnect loop.
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
|
||||
sessionId,
|
||||
generation: session?._terminalSessionGeneration,
|
||||
exitSignal: session._remoteExitSignal,
|
||||
shellExited: false,
|
||||
idleTimedOut: false,
|
||||
}));
|
||||
} else {
|
||||
// Preserve a shell status already received before transport
|
||||
// close; otherwise do not invent a successful exit code.
|
||||
const shellExited = typeof session?._remoteExitCode === "number";
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
||||
sessionId,
|
||||
generation: session?._terminalSessionGeneration,
|
||||
exitCode: session?._remoteExitCode,
|
||||
shellExited,
|
||||
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
|
||||
}));
|
||||
exitCode: 0,
|
||||
reason: "closed",
|
||||
_terminalSessionGeneration: session?._terminalSessionGeneration,
|
||||
});
|
||||
}
|
||||
// Use this connection's captured token so a late close from an
|
||||
// old transport can't stop a newer same-sessionId stream (#916).
|
||||
|
||||
@@ -832,19 +832,13 @@ main();
|
||||
try {
|
||||
// Resolve the real OpenSSH binary to an absolute path BEFORE we
|
||||
// prepend the wrapper directory to PATH. Embedding a bare `ssh`
|
||||
// name would recurse into the wrapper forever. findExecutable
|
||||
// returns that bare name off Windows, so only an absolute result
|
||||
// is usable; otherwise fall back to command -v and well-known paths.
|
||||
let realSsh = "";
|
||||
try {
|
||||
const found = typeof findExecutable === "function" ? findExecutable("ssh") : "";
|
||||
if (found && path.isAbsolute(String(found))) realSsh = String(found);
|
||||
} catch {
|
||||
// Fall through to command -v / well-known paths.
|
||||
}
|
||||
// name would recurse into the wrapper forever.
|
||||
let realSsh = process.platform === "win32"
|
||||
? (findExecutable("ssh") || "")
|
||||
: "";
|
||||
if (!realSsh || !path.isAbsolute(realSsh)) {
|
||||
try {
|
||||
const lookup = execFileSync(
|
||||
const resolved = execFileSync(
|
||||
process.platform === "win32" ? "where" : "sh",
|
||||
process.platform === "win32" ? ["ssh"] : ["-c", "command -v ssh"],
|
||||
{
|
||||
@@ -853,9 +847,8 @@ main();
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
const resolved = String(lookup ?? "").trim().split(/\r?\n/).find(Boolean) || "";
|
||||
if (resolved && path.isAbsolute(resolved)) realSsh = resolved;
|
||||
).trim().split(/\r?\n/)[0];
|
||||
if (resolved) realSsh = resolved;
|
||||
} catch {
|
||||
// Fall through to common absolute locations.
|
||||
}
|
||||
|
||||
@@ -26,13 +26,6 @@ function makeApi(t, overrides = {}) {
|
||||
getTempFilePath: (name) => path.join(base, name),
|
||||
getTempDir: () => base,
|
||||
};
|
||||
// Absolute stand-in for OpenSSH. prepareEtSshEnvironment must be able to
|
||||
// inject the PATH wrapper without the host image having /usr/bin/ssh, and
|
||||
// without the execFileSync stub (used to skip Windows icacls) answering
|
||||
// `command -v ssh`.
|
||||
const fakeSsh = path.join(base, "openssh", process.platform === "win32" ? "ssh.exe" : "ssh");
|
||||
fs.mkdirSync(path.dirname(fakeSsh), { recursive: true });
|
||||
fs.writeFileSync(fakeSsh, "#!/bin/sh\nexit 0\n");
|
||||
const osMock = {
|
||||
homedir: () => fakeHome,
|
||||
userInfo: () => ({ username: "tester" }),
|
||||
@@ -60,7 +53,7 @@ function makeApi(t, overrides = {}) {
|
||||
trackSessionIdlePrompt: () => {},
|
||||
createPtyOutputBuffer: overrides.createPtyOutputBuffer
|
||||
|| (() => ({ bufferData() {}, flush() {}, flushPaced() {} })),
|
||||
findExecutable: (name) => (name === "ssh" ? fakeSsh : name),
|
||||
findExecutable: () => "ssh",
|
||||
bundledEtClient,
|
||||
});
|
||||
return { api, base, sessions };
|
||||
|
||||
@@ -76,9 +76,6 @@ const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
// Minimum window size: enough to render the expanded sidebar + a usable
|
||||
// host list + the 420px host details / new-host aside panel without overflow.
|
||||
// Small displays shrink this fit to their work area at window-creation time
|
||||
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
|
||||
// the minimum tracks the display the window currently occupies.
|
||||
const MIN_WINDOW_WIDTH = 1100;
|
||||
const MIN_WINDOW_HEIGHT = 640;
|
||||
|
||||
|
||||
@@ -6,104 +6,6 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
|
||||
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
|
||||
// logical px), which made those windows impossible to fit — let alone shrink.
|
||||
// Keep the layout minimum on capable screens, but shrink it to the work area
|
||||
// of the display the window targets so the window can always fit with a small
|
||||
// margin.
|
||||
const MIN_SIZE_FIT_FACTOR = 0.9;
|
||||
const MIN_WINDOW_FLOOR_WIDTH = 320;
|
||||
const MIN_WINDOW_FLOOR_HEIGHT = 240;
|
||||
|
||||
function pickTargetDisplay(screenModule, bounds) {
|
||||
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
|
||||
return screenModule?.getPrimaryDisplay?.() || null;
|
||||
}
|
||||
try {
|
||||
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
try {
|
||||
return screenModule.getPrimaryDisplay?.() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
|
||||
let effectiveWidth = minWidth;
|
||||
let effectiveHeight = minHeight;
|
||||
try {
|
||||
// Size for the display the window actually lives on (saved position or
|
||||
// current placement), falling back to the primary display.
|
||||
const display = bounds
|
||||
? pickTargetDisplay(screenModule, bounds)
|
||||
: screenModule?.getPrimaryDisplay?.();
|
||||
const workArea = display?.workArea;
|
||||
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
|
||||
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
|
||||
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
|
||||
}
|
||||
} catch {
|
||||
// screen unavailable (e.g. before app ready): keep the static minimums
|
||||
}
|
||||
return {
|
||||
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
|
||||
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the BrowserWindow minimum in sync with the display it currently lives
|
||||
// on: docking, unplugging a screen, or display-scaling changes can move the
|
||||
// window onto a display with a different work area.
|
||||
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
|
||||
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
|
||||
|
||||
const applyMinimumForCurrentDisplay = () => {
|
||||
try {
|
||||
if (win.isDestroyed?.()) return;
|
||||
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
|
||||
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
|
||||
if (next.width === currentWidth && next.height === currentHeight) return;
|
||||
win.setMinimumSize(next.width, next.height);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
};
|
||||
|
||||
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
|
||||
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
|
||||
const onWindowMoved = () => applyMinimumForCurrentDisplay();
|
||||
try {
|
||||
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.on("display-removed", onDisplayRemoved);
|
||||
win.on("move", onWindowMoved);
|
||||
} catch {
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
return function detachDisplayMinimumSync() {
|
||||
try {
|
||||
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
|
||||
screenModule.removeListener?.("display-removed", onDisplayRemoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.removeListener?.("move", onWindowMoved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setTerminalKeyboardFocusForWindow(win, focused) {
|
||||
if (!win || win.isDestroyed?.() || !win.webContents) return false;
|
||||
const isFocused = focused === true;
|
||||
@@ -211,24 +113,11 @@ function createMainWindowApi(ctx) {
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
};
|
||||
|
||||
// Size the minimum to the display the restored window will live on
|
||||
// (a saved position may target a smaller secondary display), falling
|
||||
// back to the primary display when there is no usable saved position.
|
||||
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
|
||||
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
|
||||
: null;
|
||||
const windowMinimumSize = resolveWindowMinimumSize(
|
||||
screen,
|
||||
MIN_WINDOW_WIDTH,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
savedTargetBounds,
|
||||
);
|
||||
|
||||
if (savedState) {
|
||||
// Use saved dimensions, but clamp to the minimum so a previously
|
||||
// shrunk window from an older build cannot start below the minimum.
|
||||
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
|
||||
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
|
||||
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
|
||||
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
|
||||
|
||||
// Only use saved position if the screen is available at that location
|
||||
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
|
||||
@@ -260,8 +149,8 @@ function createMainWindowApi(ctx) {
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...windowBounds,
|
||||
minWidth: windowMinimumSize.width,
|
||||
minHeight: windowMinimumSize.height,
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
show: false,
|
||||
@@ -301,20 +190,6 @@ function createMainWindowApi(ctx) {
|
||||
detachDisplayRecovery = null;
|
||||
}
|
||||
|
||||
// #3647: docking / display-scaling changes can move the window onto a
|
||||
// display with a different work area; keep the minimum in sync so the
|
||||
// window can always fit its current display.
|
||||
const detachDisplayMinimumSync = attachDisplayMinimumSync({
|
||||
win,
|
||||
screen,
|
||||
// Pass the configured (layout) minimum as the baseline, not the
|
||||
// display-fitted value resolved above: otherwise a reduced minimum
|
||||
// would become a permanent ceiling and could never grow back to
|
||||
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
|
||||
minWidth: MIN_WINDOW_WIDTH,
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
});
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
@@ -322,11 +197,6 @@ function createMainWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
detachDisplayMinimumSync?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
@@ -725,8 +595,6 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachDisplayMinimumSync,
|
||||
createMainWindowApi,
|
||||
resolveWindowMinimumSize,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
|
||||
|
||||
test("uses the static layout minimums when they fit the display", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 1100, height: 640 });
|
||||
});
|
||||
|
||||
test("sizes the minimum for the display containing the target bounds", () => {
|
||||
const primary = { workArea: { width: 1920, height: 1080 } };
|
||||
const secondary = { workArea: { width: 1093, height: 585 } };
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => primary,
|
||||
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 2000, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("falls back to the primary display when the target cannot be matched", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getDisplayMatching: () => null,
|
||||
},
|
||||
1100,
|
||||
640,
|
||||
{ x: 0, y: 0, width: 1000, height: 700 },
|
||||
);
|
||||
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
});
|
||||
|
||||
test("refreshes the window minimum when display metrics change", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
let minimum = { width: 1100, height: 640 };
|
||||
win.getMinimumSize = () => [minimum.width, minimum.height];
|
||||
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
try {
|
||||
// Docking change shrinks the primary work area under the window.
|
||||
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
|
||||
// No-op emission keeps the same minimum.
|
||||
screenModule.emit("display-metrics-changed");
|
||||
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
|
||||
} finally {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores display events after detach", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const screenModule = new EventEmitter();
|
||||
const win = new EventEmitter();
|
||||
win.isDestroyed = () => false;
|
||||
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
|
||||
win.getMinimumSize = () => [1100, 640];
|
||||
let called = 0;
|
||||
win.setMinimumSize = () => { called += 1; };
|
||||
|
||||
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
|
||||
detach();
|
||||
screenModule.emit("display-metrics-changed");
|
||||
screenModule.emit("display-removed");
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
|
||||
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.equal(size.width, Math.floor(1093 * 0.9));
|
||||
assert.equal(size.height, Math.floor(585 * 0.9));
|
||||
assert.ok(size.width < 1100 && size.height < 640);
|
||||
});
|
||||
|
||||
test("never shrinks below a small usable floor", () => {
|
||||
const size = resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
|
||||
1100,
|
||||
640,
|
||||
);
|
||||
assert.deepEqual(size, { width: 320, height: 240 });
|
||||
});
|
||||
|
||||
test("keeps the static minimums when screen is unavailable", () => {
|
||||
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
|
||||
assert.deepEqual(
|
||||
resolveWindowMinimumSize(
|
||||
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
|
||||
1100,
|
||||
640,
|
||||
),
|
||||
{ width: 1100, height: 640 },
|
||||
);
|
||||
});
|
||||
|
||||
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMainWindowApi } = require("./mainWindow.cjs");
|
||||
|
||||
let constructedOptions = null;
|
||||
class Window extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
constructedOptions = options;
|
||||
this.destroyed = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), {
|
||||
id: 71,
|
||||
isDestroyed: () => this.destroyed,
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
send() {},
|
||||
});
|
||||
}
|
||||
close() {}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { width: 1000, height: 700 }; }
|
||||
setBackgroundColor() {}
|
||||
loadURL() { return Promise.resolve(); }
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
|
||||
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
|
||||
getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
|
||||
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
|
||||
shouldCloseWindowFromInput() { return false; },
|
||||
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
|
||||
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
|
||||
});
|
||||
|
||||
await api.createWindow(
|
||||
{
|
||||
BrowserWindow: Window,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {
|
||||
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: true,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(constructedOptions);
|
||||
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
|
||||
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
|
||||
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
|
||||
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
|
||||
});
|
||||
@@ -107,11 +107,6 @@ function buildModeRestores(dir, names, removeIndices, modes) {
|
||||
* Optional. When writeToRemote returns false, the upload loop awaits this
|
||||
* before sending more file data (typically waitForWritableDrain(stream)).
|
||||
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
|
||||
* @param {() => number} [opts.getTransportPendingBytes]
|
||||
* Optional. Return the bytes the transport has queued but not delivered yet
|
||||
* (e.g. the SSH channel backlog). Enables delivered-byte upload progress:
|
||||
* while parked on backpressure the loop reports queued-minus-outstanding
|
||||
* bytes so the progress indicator tracks actual delivery (issue #3663).
|
||||
* @param {() => import('electron').WebContents | null} opts.getWebContents
|
||||
* Returns the Electron WebContents for sending progress IPC events.
|
||||
* @param {string} [opts.label]
|
||||
@@ -136,11 +131,6 @@ function createZmodemSentry(opts) {
|
||||
const pendingEchoes = [];
|
||||
let pendingTerminalSuppression = null;
|
||||
let pendingTerminalSuppressionAt = 0;
|
||||
let pendingHexHeaderFragment = null;
|
||||
let pendingHexHeaderFragmentTimer = null;
|
||||
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
|
||||
// next terminal-output chunk; at most one byte is then dropped.
|
||||
let suppressNextTerminalXon = false;
|
||||
|
||||
function setPendingTerminalSuppression(buf) {
|
||||
pendingTerminalSuppression = buf;
|
||||
@@ -162,16 +152,6 @@ function createZmodemSentry(opts) {
|
||||
const COOLDOWN_MS = 2000;
|
||||
const ECHO_TTL_MS = 1500;
|
||||
const ECHO_MAX_BYTES = 256;
|
||||
// "**" + ZDLE + 'B': the start of every ZMODEM hex header.
|
||||
const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]);
|
||||
// 4-byte pattern + 14 hex digits + CR is the most a still-incomplete
|
||||
// header can look like before the final LF (plus optional XON) arrives.
|
||||
const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19;
|
||||
// How long a withheld partial-header fragment may sit unresolved before
|
||||
// it is flushed through to the terminal anyway.
|
||||
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
|
||||
? Math.max(1, opts.hexHeaderFragmentTtlMs)
|
||||
: 1000;
|
||||
/**
|
||||
* Upper bound on how long a drag-drop echo suppression may stay armed.
|
||||
* The suppression hides the terminal echo of the upload command, whose echo
|
||||
@@ -260,14 +240,6 @@ function createZmodemSentry(opts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
function isHexDigitByte(byte) {
|
||||
return (
|
||||
(byte >= 0x30 && byte <= 0x39)
|
||||
|| (byte >= 0x41 && byte <= 0x46)
|
||||
|| (byte >= 0x61 && byte <= 0x66)
|
||||
);
|
||||
}
|
||||
|
||||
function stripVisibleZmodemHeaders(data) {
|
||||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
let searchFrom = 0;
|
||||
@@ -281,7 +253,12 @@ function createZmodemSentry(opts) {
|
||||
|
||||
let isHexHeader = true;
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
|
||||
const byte = buf[prefixAt + 4 + i];
|
||||
const isHexDigit =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66);
|
||||
if (!isHexDigit) {
|
||||
isHexHeader = false;
|
||||
break;
|
||||
}
|
||||
@@ -297,10 +274,6 @@ function createZmodemSentry(opts) {
|
||||
headerLength = 20;
|
||||
if (buf[prefixAt + 20] === 0x11) {
|
||||
headerLength = 21;
|
||||
} else if (prefixAt + 20 === buf.length) {
|
||||
// The optional XON that follows the CR/LF may arrive in the next
|
||||
// chunk; strip at most one leading XON byte from it (#3651).
|
||||
suppressNextTerminalXon = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,119 +287,6 @@ function createZmodemSentry(opts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
function flushPendingHexHeaderFragment() {
|
||||
pendingHexHeaderFragmentTimer = null;
|
||||
const buf = pendingHexHeaderFragment;
|
||||
pendingHexHeaderFragment = null;
|
||||
if (buf?.length) onData(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be
|
||||
* split across transport chunks. zmodem.js hands each original chunk to
|
||||
* to_terminal verbatim and only complete headers can be stripped, so a
|
||||
* chunk that ends in the middle of the preamble painted it as terminal
|
||||
* garble (#3651). Detect the trailing bytes that may still form one and
|
||||
* hold them back; the next chunk either completes the header (stripped
|
||||
* above) or proves the buffer invalid. A TTL guard flushes held bytes so
|
||||
* deferrals can never lose legitimate terminal output.
|
||||
*
|
||||
* Returns the index from which the buffer should be withheld, or -1.
|
||||
*/
|
||||
function findTrailingHexHeaderFragment(buf) {
|
||||
if (!buf.length) return -1;
|
||||
|
||||
// Longest still-incomplete header tail: the 4-byte pattern, 14 hex
|
||||
// digits, and a CR — i.e. everything up to the final LF (+ optional XON).
|
||||
const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL);
|
||||
|
||||
for (let i = windowStart; i <= buf.length - 4; i += 1) {
|
||||
let matched = true;
|
||||
for (let j = 0; j < 4; j += 1) {
|
||||
if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) continue;
|
||||
|
||||
let digits = 0;
|
||||
while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) {
|
||||
digits += 1;
|
||||
}
|
||||
|
||||
// Chunk ends inside the hex digits — the header may still complete.
|
||||
if (i + 4 + digits === buf.length) return i;
|
||||
|
||||
// Digit run ended before the 14 required digits, or overshoots them:
|
||||
// not a hex header.
|
||||
if (digits !== 14) continue;
|
||||
|
||||
// A CR (but not yet the trailing LF) may still complete the header.
|
||||
if (buf.length - i <= 19) return i;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// The "**" + ZDLE + 'B' pattern itself may be split: withhold any
|
||||
// trailing bytes that are a prefix of it until the next chunk resolves.
|
||||
for (let k = Math.min(3, buf.length); k >= 1; k -= 1) {
|
||||
let matches = true;
|
||||
for (let j = 0; j < k; j += 1) {
|
||||
if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return buf.length - k;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join any previously withheld partial-header bytes onto the new chunk so
|
||||
* the suppression/stripping stages can see a complete header again. This
|
||||
* must run BEFORE the stripping stages, or a header split across two
|
||||
* chunks would be judged complete too late and reach the terminal.
|
||||
*/
|
||||
function assembleTerminalChunk(octets) {
|
||||
const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets);
|
||||
if (!pendingHexHeaderFragment?.length) {
|
||||
return buf;
|
||||
}
|
||||
if (pendingHexHeaderFragmentTimer) {
|
||||
clearTimeout(pendingHexHeaderFragmentTimer);
|
||||
pendingHexHeaderFragmentTimer = null;
|
||||
}
|
||||
const merged = Buffer.concat([pendingHexHeaderFragment, buf]);
|
||||
pendingHexHeaderFragment = null;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withhold a trailing partial hex header (if any) and arm a timer that
|
||||
* flushes it when no more bytes arrive to resolve it.
|
||||
*/
|
||||
function deferIncompleteHexHeader(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
|
||||
const fragmentAt = findTrailingHexHeaderFragment(buf);
|
||||
if (fragmentAt === -1) return buf;
|
||||
|
||||
if (fragmentAt < buf.length) {
|
||||
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
|
||||
if (!pendingHexHeaderFragmentTimer) {
|
||||
pendingHexHeaderFragmentTimer = setTimeout(
|
||||
flushPendingHexHeaderFragment,
|
||||
HEX_HEADER_FRAGMENT_TTL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function looksLikeResidualZmodemData(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (!buf.length) return true;
|
||||
@@ -593,19 +453,8 @@ function createZmodemSentry(opts) {
|
||||
const sentry = new Zmodem.Sentry({
|
||||
to_terminal(octets) {
|
||||
// Normal data – pass raw bytes to the caller for charset-aware decoding.
|
||||
// Bytes that may be the start of a ZMODEM hex header split across
|
||||
// chunks are withheld first (and later stripped or flushed) so the
|
||||
// preamble of sz/rz never reaches the terminal as garble (#3651).
|
||||
let sanitizedOctets = assembleTerminalChunk(octets);
|
||||
if (suppressNextTerminalXon) {
|
||||
suppressNextTerminalXon = false;
|
||||
if (sanitizedOctets[0] === 0x11) {
|
||||
sanitizedOctets = sanitizedOctets.subarray(1);
|
||||
}
|
||||
}
|
||||
sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets);
|
||||
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
|
||||
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
|
||||
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
|
||||
if (!sanitizedOctets.length) return;
|
||||
onData(sanitizedOctets);
|
||||
},
|
||||
@@ -917,8 +766,6 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
|
||||
const UPLOAD_CHUNK_SIZE = 64 * 1024;
|
||||
/** Default interval between non-final upload progress IPC events. */
|
||||
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
|
||||
/** Interval between delivered-byte progress reports while parked on drain. */
|
||||
const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
function resolveTimeoutMs(value, fallback) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
@@ -1134,76 +981,6 @@ function createZmodemUploadDrainWaiter(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Park the upload loop on transport backpressure while keeping progress
|
||||
* updates alive (issue #3663).
|
||||
*
|
||||
* Once a large write backlog fills the transport, the upload loop otherwise
|
||||
* sits silent on the "drain" event while the transport keeps delivering the
|
||||
* queued backlog at full link speed: on SSH the entire channel window drains
|
||||
* between two upload-loop steps, so the progress indicator freezes mid-file
|
||||
* and jumps when the loop resumes. While the drain wait stays pending, poll
|
||||
* it and let the caller emit progress events showing bytes the transport has
|
||||
* actually accepted instead.
|
||||
*
|
||||
* Falls back to a plain drain wait when the transport cannot report its
|
||||
* outstanding byte count. Rejections (timeout, cancellation) propagate.
|
||||
*
|
||||
* @param {{
|
||||
* waitForDrain?: () => Promise<void>,
|
||||
* getTransportPendingBytes?: () => unknown,
|
||||
* progressThrottleMs?: number,
|
||||
* }} opts
|
||||
* @param {() => void} emitProgress Called at most once per throttle interval
|
||||
* while the drain wait stays pending.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function awaitUploadDrainWithProgress(opts, emitProgress) {
|
||||
if (!opts.waitForDrain) return;
|
||||
if (typeof opts.getTransportPendingBytes !== "function") {
|
||||
await opts.waitForDrain();
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const drain = opts.waitForDrain().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
drain.catch(() => {}); // observed at the final await; never surface early
|
||||
// A drain wait without pending backpressure resolves after one event-loop
|
||||
// turn; yield once so backpressure-free uploads do not gain polling delay.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (settled) {
|
||||
await drain;
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(
|
||||
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
|
||||
resolveProgressThrottleMs(opts.progressThrottleMs),
|
||||
);
|
||||
// Parked: report accepted bytes right away, then once per interval while
|
||||
// the transport drains its backlog. Progress emissions must never break
|
||||
// (or slow) the transfer itself.
|
||||
try { emitProgress(); } catch { /* ignore */ }
|
||||
// Race each poll delay against drain. A wait that settles mid-interval
|
||||
// must resume the upload immediately; sleeping out the rest of the
|
||||
// interval throttles every backpressure cycle by up to ~100ms.
|
||||
while (!settled) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
drain.then(() => {}, () => {}),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, intervalMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (settled) break;
|
||||
try { emitProgress(); } catch { /* ignore */ }
|
||||
}
|
||||
await drain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
|
||||
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
|
||||
@@ -1406,42 +1183,19 @@ async function handleUpload(zsession, opts) {
|
||||
const { originalIndex, filePath, stat, name } = offers[i];
|
||||
opts.resetUploadBackpressure?.();
|
||||
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
// Progress should track bytes the transport accepted, not just bytes
|
||||
// queued (#3663): on SSH the channel can buffer its whole window before
|
||||
// write() returns false, so queued-only reporting freezes the progress
|
||||
// bar while the backlog drains at link speed. When the transport can
|
||||
// report outstanding bytes, show `sent - outstanding` and never move
|
||||
// the bar backwards.
|
||||
const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function"
|
||||
? opts.getTransportPendingBytes
|
||||
: null;
|
||||
let sent = 0;
|
||||
let displayedTransferred = 0;
|
||||
const emitFileProgress = () => {
|
||||
const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN;
|
||||
const delivered = Number.isFinite(outstanding)
|
||||
? sent - Math.max(outstanding, 0)
|
||||
: sent;
|
||||
displayedTransferred = Math.min(
|
||||
Math.max(delivered, displayedTransferred),
|
||||
stat.size,
|
||||
);
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: displayedTransferred,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
};
|
||||
|
||||
emitFileProgress();
|
||||
|
||||
// The offer handshake is the only upload step without a built-in
|
||||
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
|
||||
// send_offer() would park this loop forever and block all future
|
||||
@@ -1469,6 +1223,7 @@ async function handleUpload(zsession, opts) {
|
||||
// Read and send in chunks
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
|
||||
let sent = 0;
|
||||
// Progress IPC is throttled by default for every transport. Pass 0 only
|
||||
// when a caller explicitly needs one event per chunk.
|
||||
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
|
||||
@@ -1488,16 +1243,21 @@ async function handleUpload(zsession, opts) {
|
||||
|
||||
const now = Date.now();
|
||||
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
|
||||
emitFileProgress();
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
lastProgressEmitAt = now;
|
||||
}
|
||||
|
||||
// Wait for transport to drain if its buffer is full (emitting
|
||||
// delivered-byte progress while parked, #3663), then yield so
|
||||
// inbound ZMODEM control frames can be processed.
|
||||
if (opts.waitForDrain) {
|
||||
await awaitUploadDrainWithProgress(opts, emitFileProgress);
|
||||
}
|
||||
// Wait for transport to drain if its buffer is full, then yield
|
||||
// so inbound ZMODEM control frames can be processed.
|
||||
if (opts.waitForDrain) await opts.waitForDrain();
|
||||
await yieldToIO();
|
||||
}
|
||||
// All data written to Node.js buffer — but TCP may still be
|
||||
@@ -1782,8 +1542,6 @@ module.exports = {
|
||||
handleDownload,
|
||||
waitForWritableDrain,
|
||||
createZmodemUploadDrainWaiter,
|
||||
awaitUploadDrainWithProgress,
|
||||
UPLOAD_CHUNK_SIZE,
|
||||
UPLOAD_DRAIN_TIMEOUT_MS,
|
||||
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
|
||||
};
|
||||
@@ -11,7 +11,6 @@ const {
|
||||
handleDownload,
|
||||
waitForWritableDrain,
|
||||
createZmodemUploadDrainWaiter,
|
||||
awaitUploadDrainWithProgress,
|
||||
UPLOAD_CHUNK_SIZE,
|
||||
UPLOAD_DRAIN_TIMEOUT_MS,
|
||||
} = require("./zmodemHelper.cjs");
|
||||
@@ -423,282 +422,6 @@ test("handleUpload does not read the next chunk until transport backpressure cle
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-"));
|
||||
const filePath = path.join(tempDir, "upload.bin");
|
||||
const size = UPLOAD_CHUNK_SIZE * 2;
|
||||
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
|
||||
const progress = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => ({
|
||||
isDestroyed: () => false,
|
||||
send(channel, data) {
|
||||
if (channel === "netcatty:zmodem:progress") progress.push(data);
|
||||
},
|
||||
}),
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.bin"],
|
||||
}),
|
||||
progressThrottleMs: 0,
|
||||
// 4096 bytes of every queued amount are still outstanding in the transport.
|
||||
getTransportPendingBytes: () => 4096,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
|
||||
[
|
||||
[0, false],
|
||||
[UPLOAD_CHUNK_SIZE - 4096, false],
|
||||
[size - 4096, false],
|
||||
[size, true],
|
||||
],
|
||||
);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleUpload emits delivered-byte progress while parked on transport drain", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-"));
|
||||
const filePath = path.join(tempDir, "upload.bin");
|
||||
const size = UPLOAD_CHUNK_SIZE * 2 + 1;
|
||||
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
|
||||
const progress = [];
|
||||
// Bytes queued into the transport (never consumed; delivery is simulated
|
||||
// below on the pending-byte readout).
|
||||
let outstanding = 0;
|
||||
let drainStartedAt = null;
|
||||
let releaseFirstDrain;
|
||||
const firstDrain = new Promise((resolve) => {
|
||||
releaseFirstDrain = resolve;
|
||||
});
|
||||
let drainCalls = 0;
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
return {
|
||||
send(chunk) {
|
||||
outstanding += chunk.byteLength;
|
||||
},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
const upload = handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => ({
|
||||
isDestroyed: () => false,
|
||||
send(channel, data) {
|
||||
if (channel === "netcatty:zmodem:progress") progress.push(data);
|
||||
},
|
||||
}),
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.bin"],
|
||||
}),
|
||||
progressThrottleMs: 0,
|
||||
getTransportPendingBytes: () => {
|
||||
if (drainStartedAt === null) return outstanding;
|
||||
// The backlog drains at 512 B/ms once the parked wait begins.
|
||||
const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512);
|
||||
return outstanding - delivered;
|
||||
},
|
||||
async waitForDrain() {
|
||||
drainCalls += 1;
|
||||
if (drainCalls === 1) {
|
||||
drainStartedAt = Date.now();
|
||||
await firstDrain;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
while (drainCalls === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
const progressedBefore = progress.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const parkedEvents = progress.slice(progressedBefore);
|
||||
releaseFirstDrain();
|
||||
await upload;
|
||||
|
||||
// While parked, delivered-byte progress must keep flowing (previously the
|
||||
// indicator stayed frozen until the whole backlog drained).
|
||||
assert.ok(
|
||||
parkedEvents.some((event) => event.transferred > 0 && !event.finalizing),
|
||||
`expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`,
|
||||
);
|
||||
|
||||
const values = progress.map((event) => event.transferred);
|
||||
for (let k = 1; k < values.length; k++) {
|
||||
assert.ok(
|
||||
values[k] >= values[k - 1],
|
||||
`progress must never move backwards: ${values.join(",")}`,
|
||||
);
|
||||
assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`);
|
||||
}
|
||||
assert.equal(progress.at(-1).finalizing, true);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => {
|
||||
let drainCalls = 0;
|
||||
await awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => {
|
||||
drainCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
() => {
|
||||
throw new Error("no progress emissions without pending-byte accounting");
|
||||
},
|
||||
);
|
||||
assert.equal(drainCalls, 1);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => {
|
||||
const startedAt = Date.now();
|
||||
await awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => Promise.resolve(),
|
||||
getTransportPendingBytes: () => 0,
|
||||
},
|
||||
() => {
|
||||
throw new Error("no emissions without parked backpressure");
|
||||
},
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 50);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => {
|
||||
let releaseDrain;
|
||||
const drainGate = new Promise((resolve) => {
|
||||
releaseDrain = resolve;
|
||||
});
|
||||
let parked = false;
|
||||
const done = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 2048,
|
||||
},
|
||||
() => {
|
||||
parked = true;
|
||||
},
|
||||
);
|
||||
|
||||
const waitStarted = Date.now();
|
||||
while (!parked) {
|
||||
if (Date.now() - waitStarted > 1000) {
|
||||
throw new Error("upload drain wait never parked");
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
// The immediate park emission has run and the poll timer (~100ms) is armed.
|
||||
// Settle on the next turn, still inside that interval.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const releasedAt = Date.now();
|
||||
releaseDrain();
|
||||
await done;
|
||||
const elapsed = Date.now() - releasedAt;
|
||||
assert.ok(
|
||||
elapsed < 50,
|
||||
`drain settlement during a poll interval resumed in ${elapsed}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => {
|
||||
let rejectDrain;
|
||||
const drainGate = new Promise((_, reject) => {
|
||||
rejectDrain = reject;
|
||||
});
|
||||
const err = new Error("Transport drain timeout");
|
||||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||||
let parked = false;
|
||||
const done = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 2048,
|
||||
},
|
||||
() => {
|
||||
parked = true;
|
||||
},
|
||||
);
|
||||
|
||||
const waitStarted = Date.now();
|
||||
while (!parked) {
|
||||
if (Date.now() - waitStarted > 1000) {
|
||||
throw new Error("upload drain wait never parked");
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const releasedAt = Date.now();
|
||||
rejectDrain(err);
|
||||
await assert.rejects(done, (caught) => caught === err);
|
||||
const elapsed = Date.now() - releasedAt;
|
||||
assert.ok(
|
||||
elapsed < 50,
|
||||
`drain rejection during a poll interval resumed in ${elapsed}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => {
|
||||
let releaseDrain;
|
||||
const drainGate = new Promise((resolve) => {
|
||||
releaseDrain = resolve;
|
||||
});
|
||||
let displayed = 0;
|
||||
const emitted = [];
|
||||
const deliverTimer = setInterval(() => {
|
||||
displayed += 512;
|
||||
}, 40);
|
||||
|
||||
try {
|
||||
const parked = awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => drainGate,
|
||||
getTransportPendingBytes: () => 1024,
|
||||
},
|
||||
() => emitted.push(displayed),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 280));
|
||||
assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`);
|
||||
assert.ok(emitted.every((v) => v === 0 || v % 512 === 0));
|
||||
releaseDrain();
|
||||
await parked;
|
||||
} finally {
|
||||
clearInterval(deliverTimer);
|
||||
}
|
||||
});
|
||||
|
||||
test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => {
|
||||
const err = new Error("Transport drain timeout");
|
||||
err.code = "NETCATTY_ZMODEM_TIMEOUT";
|
||||
await assert.rejects(
|
||||
awaitUploadDrainWithProgress(
|
||||
{
|
||||
waitForDrain: () => Promise.reject(err),
|
||||
getTransportPendingBytes: () => 0,
|
||||
},
|
||||
() => {},
|
||||
),
|
||||
(caught) => caught === err,
|
||||
);
|
||||
});
|
||||
|
||||
test("handleUpload throttles progress IPC by default for every transport", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
|
||||
const filePath = path.join(tempDir, "large-upload.bin");
|
||||
@@ -1766,132 +1489,3 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo
|
||||
await waitForDrain();
|
||||
assert.equal(needsDrain, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens
|
||||
// every sz/rz session can be fragmented across transport chunks; it must
|
||||
// never reach the terminal as garble, and withheld bytes must never be lost.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ZmodemLib = require("zmodem.js");
|
||||
|
||||
const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r";
|
||||
|
||||
function buildHexHeaderBytes(name, args) {
|
||||
return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex());
|
||||
}
|
||||
|
||||
function createProbeSentry(extra) {
|
||||
const terminal = [];
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "probe",
|
||||
onData: (buf) => terminal.push(Buffer.from(buf)),
|
||||
writeToRemote: () => true,
|
||||
getWebContents: () => null,
|
||||
selectDownloadDirectory: async () => ({ canceled: true }),
|
||||
selectUploadFiles: async () => null,
|
||||
hexHeaderFragmentTtlMs: 30,
|
||||
...extra,
|
||||
});
|
||||
return { sentry, terminal };
|
||||
}
|
||||
|
||||
function joinedText(terminal) {
|
||||
return Buffer.concat(terminal).toString("latin1");
|
||||
}
|
||||
|
||||
function assertNoZmodemHeaderBytes(terminal, context) {
|
||||
const joined = Buffer.concat(terminal);
|
||||
assert.equal(
|
||||
joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])),
|
||||
-1,
|
||||
`${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`,
|
||||
);
|
||||
assert.equal(
|
||||
joined.includes(Buffer.from([0x18])),
|
||||
false,
|
||||
`${context}: ZDLE/CAN byte leaked to the terminal`,
|
||||
);
|
||||
}
|
||||
|
||||
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
const stream = Buffer.concat([
|
||||
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
|
||||
buildHexHeaderBytes("ZRQINIT"),
|
||||
Buffer.from("$ "),
|
||||
]);
|
||||
|
||||
for (const byte of stream) sentry.consume(Buffer.from([byte]));
|
||||
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
`${DOWNLOAD_PREAMBLE_TEXT}$ `,
|
||||
"only the human-readable sz preamble text should be shown",
|
||||
);
|
||||
assertNoZmodemHeaderBytes(terminal, "byte-at-a-time");
|
||||
});
|
||||
|
||||
test("split sz preamble leaks nothing at any chunk boundary", () => {
|
||||
const header = buildHexHeaderBytes("ZRQINIT");
|
||||
// Header.build().to_hex() already includes the trailing XON byte.
|
||||
const stream = Buffer.concat([
|
||||
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
|
||||
header,
|
||||
Buffer.from("$ "),
|
||||
]);
|
||||
|
||||
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `);
|
||||
assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fragmented rz ZRINIT preamble never reaches the terminal", () => {
|
||||
const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]);
|
||||
const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]);
|
||||
|
||||
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
"root@host:~$ ",
|
||||
`split ZRINIT preamble leaked for chunkSize=${chunkSize}`,
|
||||
);
|
||||
assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("bytes withheld as a header fragment are flushed after the TTL", async () => {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
const stream = Buffer.from("hello *");
|
||||
for (const byte of stream) sentry.consume(Buffer.from([byte]));
|
||||
|
||||
// The trailing "*" may be the start of a hex header, so it is withheld.
|
||||
assert.equal(joinedText(terminal), "hello ");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output");
|
||||
});
|
||||
|
||||
test("output that disproves a header fragment is emitted without delay", () => {
|
||||
const stream = Buffer.from("text **\x18Bxy more");
|
||||
for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) {
|
||||
const { sentry, terminal } = createProbeSentry();
|
||||
for (let i = 0; i < stream.length; i += chunkSize) {
|
||||
sentry.consume(stream.subarray(i, i + chunkSize));
|
||||
}
|
||||
assert.equal(
|
||||
joinedText(terminal),
|
||||
"text **\x18Bxy more",
|
||||
"a non-hex byte after the pattern stops the deferral",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import test from 'node:test';
|
||||
import { normalizeStoredAgentModelSelection, shouldUseStoredAgentModel } from '../../components/AIChatSidePanelHelpers';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
@@ -20,22 +19,10 @@ import {
|
||||
} from './types';
|
||||
|
||||
test('Claude presets advertise effort levels separately from the model id', () => {
|
||||
assert.equal(CLAUDE_MODEL_PRESETS.length, 15);
|
||||
const withLevels = CLAUDE_MODEL_PRESETS.filter((preset) => preset.thinkingLevels);
|
||||
assert.ok(withLevels.length > 0);
|
||||
for (const preset of withLevels) {
|
||||
for (const preset of CLAUDE_MODEL_PRESETS) {
|
||||
assert.deepEqual(preset.thinkingLevels, ['low', 'medium', 'high', 'max']);
|
||||
assert.ok(preset.defaultThinkingLevel);
|
||||
}
|
||||
// Current-generation entries keep explicit effort levels; older fallback
|
||||
// entries and unverified generations omit them rather than inventing.
|
||||
const byId = new Map(CLAUDE_MODEL_PRESETS.map((preset) => [preset.id, preset]));
|
||||
for (const id of ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-haiku-4-5']) {
|
||||
assert.ok(byId.get(id)?.thinkingLevels, `${id} should keep effort levels`);
|
||||
}
|
||||
for (const id of ['claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-4-6']) {
|
||||
assert.equal(byId.get(id)?.thinkingLevels, undefined, `${id} should not invent effort levels`);
|
||||
}
|
||||
assert.equal(resolveAgentModelSelection(CLAUDE_MODEL_PRESETS[0]!), 'default/medium');
|
||||
});
|
||||
|
||||
@@ -265,26 +252,3 @@ test('getAgentModelPresets resolves Windows command paths with backslashes', ()
|
||||
CLAUDE_MODEL_PRESETS,
|
||||
);
|
||||
});
|
||||
|
||||
test('Claude fallback preserves saved family aliases and effort', () => {
|
||||
for (const selection of ['sonnet/medium', 'haiku/low']) {
|
||||
assert.equal(shouldUseStoredAgentModel(selection, CLAUDE_MODEL_PRESETS), true);
|
||||
assert.equal(normalizeStoredAgentModelSelection(selection, CLAUDE_MODEL_PRESETS), selection);
|
||||
}
|
||||
assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'sonnet')?.name, 'Sonnet');
|
||||
assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'haiku')?.name, 'Haiku');
|
||||
});
|
||||
|
||||
|
||||
test('explicit Claude fallback IDs preserve unspecified effort and explicit effort choices', () => {
|
||||
for (const preset of CLAUDE_MODEL_PRESETS.filter((model) => model.id.startsWith('claude-'))) {
|
||||
assert.equal(normalizeStoredAgentModelSelection(preset.id, CLAUDE_MODEL_PRESETS), preset.id);
|
||||
assert.equal(resolveAgentModelSelection(preset), preset.id);
|
||||
for (const level of preset.thinkingLevels ?? []) {
|
||||
const explicit = `${preset.id}/${level}`;
|
||||
assert.equal(normalizeStoredAgentModelSelection(explicit, CLAUDE_MODEL_PRESETS), explicit);
|
||||
}
|
||||
}
|
||||
assert.equal(normalizeStoredAgentModelSelection('sonnet/medium', CLAUDE_MODEL_PRESETS), 'sonnet/medium');
|
||||
assert.equal(normalizeStoredAgentModelSelection('haiku/low', CLAUDE_MODEL_PRESETS), 'haiku/low');
|
||||
});
|
||||
@@ -559,76 +559,25 @@ export interface AgentModelPreset {
|
||||
|
||||
const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
|
||||
|
||||
// Fallback presets used when the live catalog (SDK listModels) is unavailable.
|
||||
// Mirrors the live model picker catalog observed 2026-10-10 (claude CLI
|
||||
// 2.1.296); ids are explicit model ids sent to the CLI verbatim and were
|
||||
// verified against the binary's model-id strings and alias table
|
||||
// (opus/sonnet/haiku → claude-*-5-5, fable → claude-fable-5-1). Live
|
||||
// discovery supersedes this list whenever it succeeds. `default` has no
|
||||
// fixed alias target and keeps the CLI's own neutral label. Explicit IDs
|
||||
// preserve a saved bare selection so CLI/settings effort remains in control.
|
||||
export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default (recommended)',
|
||||
name: 'Opus 4.6',
|
||||
description: 'Recommended',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-5-5',
|
||||
name: 'Opus 5.5',
|
||||
description: 'Flagship',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5-1',
|
||||
name: 'Fable 5.1',
|
||||
description: 'Latest Fable',
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-5-5',
|
||||
name: 'Sonnet 5.5',
|
||||
description: 'Everyday tasks',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'medium',
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-5-5',
|
||||
name: 'Haiku 5.5',
|
||||
description: 'Fastest current',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'low',
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5',
|
||||
name: 'Haiku 4.5',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'low',
|
||||
encodeDefaultThinking: false,
|
||||
},
|
||||
{ id: 'claude-sonnet-5', name: 'Sonnet 5' },
|
||||
{ id: 'claude-opus-5', name: 'Opus 5' },
|
||||
{ id: 'claude-fable-5', name: 'Fable 5' },
|
||||
{ id: 'claude-opus-4-8', name: 'Opus 4.8' },
|
||||
{ id: 'claude-opus-4-7', name: 'Opus 4.7' },
|
||||
{ id: 'claude-opus-4-6', name: 'Opus 4.6' },
|
||||
{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6' },
|
||||
// Keep persisted pre-expansion selections valid. The installed CLI resolves
|
||||
// these aliases, so their labels must not claim a particular generation.
|
||||
{
|
||||
id: 'sonnet',
|
||||
name: 'Sonnet',
|
||||
name: 'Sonnet 4.6',
|
||||
description: 'Everyday tasks',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'haiku',
|
||||
name: 'Haiku',
|
||||
name: 'Haiku 4.5',
|
||||
description: 'Fastest',
|
||||
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
|
||||
defaultThinkingLevel: 'low',
|
||||
},
|
||||
|
||||
@@ -24,8 +24,6 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
|
||||
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
|
||||
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
|
||||
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
|
||||
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
|
||||
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
|
||||
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
|
||||
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
|
||||
|
||||
+20
-224
@@ -6,19 +6,7 @@ const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u;
|
||||
const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]/u;
|
||||
const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu;
|
||||
export const PINYIN_CACHE_MAX_ENTRIES = 16_384;
|
||||
/**
|
||||
* Hard cap on the total characters the pinyin cache retains (cache key +
|
||||
* full + initials transliterations per entry, i.e. several copies of the
|
||||
* source text). A per-entry cap alone is not enough: callers transliterate
|
||||
* long fields (e.g. up to 20,000-character session titles), so a scan over
|
||||
* many distinct large fields could retain tens or hundreds of megabytes
|
||||
* within the entry count. Sources whose transliterations alone exceed this
|
||||
* budget are not cached at all — they are simply re-transliterated on the
|
||||
* next lookup instead of pinning memory.
|
||||
*/
|
||||
export const PINYIN_CACHE_MAX_CHARS = 1_000_000;
|
||||
const PINYIN_CACHE = new Map<string, { full: string; initials: string }>();
|
||||
let pinyinCacheChars = 0;
|
||||
const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/;
|
||||
const HOST_PHASE_SCORE_BONUS = {
|
||||
strict: 1_000_000,
|
||||
@@ -45,13 +33,6 @@ function compactText(input: string): string {
|
||||
return normalizeText(input).replace(SEARCH_REMOVE_REGEX, "");
|
||||
}
|
||||
|
||||
function pinyinCacheEntryChars(
|
||||
key: string,
|
||||
entry: { full: string; initials: string },
|
||||
): number {
|
||||
return key.length + entry.full.length + entry.initials.length;
|
||||
}
|
||||
|
||||
function getPinyinVariants(sourceText: string): { full: string; initials: string } {
|
||||
const cacheKey = normalizeText(sourceText);
|
||||
const cached = PINYIN_CACHE.get(cacheKey);
|
||||
@@ -81,22 +62,11 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
|
||||
}
|
||||
|
||||
const next = { full, initials };
|
||||
const entryChars = pinyinCacheEntryChars(cacheKey, next);
|
||||
// Never cache an entry that by itself exceeds the retained-char budget:
|
||||
// admitting it (and evicting everything older to fit) would still let one
|
||||
// oversized field pin memory above the budget, and skipping keeps it a
|
||||
// simple re-transliteration on the next lookup instead of unbounded decay.
|
||||
if (entryChars > PINYIN_CACHE_MAX_CHARS) return next;
|
||||
pinyinCacheChars += entryChars;
|
||||
PINYIN_CACHE.delete(cacheKey);
|
||||
PINYIN_CACHE.set(cacheKey, next);
|
||||
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES
|
||||
|| pinyinCacheChars > PINYIN_CACHE_MAX_CHARS) {
|
||||
while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined;
|
||||
if (oldestKey === undefined) break;
|
||||
const oldest = PINYIN_CACHE.get(oldestKey);
|
||||
if (!oldest) break;
|
||||
pinyinCacheChars -= pinyinCacheEntryChars(oldestKey, oldest);
|
||||
PINYIN_CACHE.delete(oldestKey);
|
||||
}
|
||||
return next;
|
||||
@@ -104,19 +74,10 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string
|
||||
|
||||
export function resetPinyinCacheForTests(): void {
|
||||
PINYIN_CACHE.clear();
|
||||
pinyinCacheChars = 0;
|
||||
}
|
||||
|
||||
export function getPinyinCacheStatsForTests(): {
|
||||
size: number;
|
||||
chars: number;
|
||||
keys: string[];
|
||||
} {
|
||||
return {
|
||||
size: PINYIN_CACHE.size,
|
||||
chars: pinyinCacheChars,
|
||||
keys: [...PINYIN_CACHE.keys()],
|
||||
};
|
||||
export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } {
|
||||
return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] };
|
||||
}
|
||||
|
||||
export function tokenizeSearchQuery(query: string): string[] {
|
||||
@@ -125,132 +86,35 @@ export function tokenizeSearchQuery(query: string): string[] {
|
||||
return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional matching tweaks. Callers with large unbounded haystacks (e.g.
|
||||
* full conversation histories) can keep literal/compact matching global
|
||||
* while restricting the (expensive) pinyin fallback to specific fields via
|
||||
* `pinyinFields`: each token may still match literally anywhere in the
|
||||
* haystack, but is only transliteration-matched against the listed fields.
|
||||
*/
|
||||
export type SearchMatchOptions = {
|
||||
allowPinyin?: boolean;
|
||||
/** Restrict the pinyin fallback to these fields; defaults to every field. */
|
||||
pinyinFields?: string[];
|
||||
};
|
||||
export function matchesSearchQuery(
|
||||
query: string,
|
||||
...fields: Array<string | null | undefined>
|
||||
): boolean {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
/**
|
||||
* Suggested query bound for callers that rescan many haystacks per keystroke
|
||||
* (e.g. session-history search). The shared matcher never truncates: callers
|
||||
* apply this cap at their own input so the bound stays local to them and the
|
||||
* global match semantics (full-query meaning) are unchanged for everyone else.
|
||||
*/
|
||||
export const MAX_SEARCH_QUERY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Bound a caller-side search query to `MAX_SEARCH_QUERY_LENGTH` UTF-16 code
|
||||
* units without splitting a UTF-16 surrogate pair when the cap lands mid-pair
|
||||
* (e.g. an astral-plane character such as an emoji; drop the lone high
|
||||
* surrogate). Truncation is a caller concern: see `MAX_SEARCH_QUERY_LENGTH`.
|
||||
*/
|
||||
export function truncateQueryForMatch(query: string): string {
|
||||
if (query.length <= MAX_SEARCH_QUERY_LENGTH) return query;
|
||||
const truncated = query.slice(0, MAX_SEARCH_QUERY_LENGTH);
|
||||
// Avoid splitting a UTF-16 surrogate pair when the cap lands mid-pair (e.g.
|
||||
// an astral-plane character such as an emoji); drop the lone high surrogate.
|
||||
const tail = truncated.charCodeAt(truncated.length - 1);
|
||||
return tail >= 0xd800 && tail <= 0xdbff ? truncated.slice(0, -1) : truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-normalized search query. Callers that match the same query against many
|
||||
* haystacks (e.g. session-history search scans every session per keystroke)
|
||||
* can prepare the query once per scan instead of paying NFKC normalization,
|
||||
* tokenization and compaction once per session.
|
||||
*/
|
||||
export type PreparedSearchQuery = {
|
||||
/** Normalized (NFKC, dash-folded, lowercased, trimmed) query. */
|
||||
normalized: string;
|
||||
/** Non-empty tokens of `normalized` after separator splitting. */
|
||||
tokens: string[];
|
||||
/** Separator-removed form of `normalized` (same as `compactText(normalized)`). */
|
||||
compact: string;
|
||||
/** `normalized` looks like a dotted IPv4 address (contiguous-literal mode). */
|
||||
ipv4Like: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_PREPARED_QUERY: PreparedSearchQuery = {
|
||||
normalized: "",
|
||||
tokens: [],
|
||||
compact: "",
|
||||
ipv4Like: false,
|
||||
};
|
||||
|
||||
export function prepareSearchQuery(query: string): PreparedSearchQuery {
|
||||
// No length cap here: truncating globally would silently change match
|
||||
// semantics for every caller (an over-limit query's tail would be dropped,
|
||||
// so a field containing only the prefix would report a match). Callers that
|
||||
// rescan many haystacks bound their own input via `truncateQueryForMatch`.
|
||||
const normalized = normalizeText(query);
|
||||
if (!normalized) return EMPTY_PREPARED_QUERY;
|
||||
return {
|
||||
normalized,
|
||||
tokens: normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean),
|
||||
compact: compactText(normalized),
|
||||
ipv4Like: IPV4_LIKE_REGEX.test(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-normalized search haystack. Callers that match the same fields against
|
||||
* many queries (e.g. session-history search fires on every keystroke) can
|
||||
* prepare the haystack once, cache it, and reuse it per query instead of
|
||||
* re-running NFKC normalization and compaction over megabytes each time.
|
||||
*/
|
||||
export type PreparedSearchFields = {
|
||||
/** Per-field normalized text, in input order. */
|
||||
normalizedFields: string[];
|
||||
/** Normalized fields joined with a single space. */
|
||||
haystack: string;
|
||||
/** Separator-removed form of `haystack` (same as `compactText(haystack)`). */
|
||||
haystackCompact: string;
|
||||
};
|
||||
|
||||
export function prepareSearchFields(
|
||||
fields: Array<string | null | undefined>,
|
||||
): PreparedSearchFields {
|
||||
const normalizedFields = fields
|
||||
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
|
||||
.map((field) => normalizeText(field));
|
||||
const haystack = normalizedFields.join(" ");
|
||||
return { normalizedFields, haystack, haystackCompact: compactText(haystack) };
|
||||
}
|
||||
|
||||
export function matchesPreparedSearchQuery(
|
||||
query: string | PreparedSearchQuery,
|
||||
prepared: PreparedSearchFields,
|
||||
options: SearchMatchOptions = {},
|
||||
): boolean {
|
||||
// Raw strings are still accepted (and prepared inline) for one-off callers;
|
||||
// repeated-match callers pass `prepareSearchQuery(query)` instead.
|
||||
const { normalized: normalizedQuery, tokens, compact: compactQuery, ipv4Like } =
|
||||
typeof query === "string" ? prepareSearchQuery(query) : query;
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
if (prepared.normalizedFields.length === 0) return false;
|
||||
if (normalizedFields.length === 0) return false;
|
||||
|
||||
// For dotted numeric input (IPv4-like), require contiguous literal match.
|
||||
if (ipv4Like) {
|
||||
return prepared.normalizedFields.some((field) => field.includes(normalizedQuery));
|
||||
if (IPV4_LIKE_REGEX.test(normalizedQuery)) {
|
||||
return normalizedFields.some((field) => field.includes(normalizedQuery));
|
||||
}
|
||||
|
||||
const tokens = tokenizeSearchQuery(normalizedQuery);
|
||||
if (tokens.length === 0) return false;
|
||||
|
||||
const haystack = prepared.haystack;
|
||||
const sourceText = normalizedFields.join(" ");
|
||||
const haystack = sourceText;
|
||||
if (haystack.includes(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (compactQuery && prepared.haystackCompact.includes(compactQuery)) {
|
||||
const haystackCompact = compactText(sourceText);
|
||||
const compactQuery = compactText(normalizedQuery);
|
||||
if (compactQuery && haystackCompact.includes(compactQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -259,15 +123,9 @@ export function matchesPreparedSearchQuery(
|
||||
}
|
||||
|
||||
const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token));
|
||||
if (!hasLatinToken || options.allowPinyin === false) return false;
|
||||
if (!hasLatinToken) return false;
|
||||
|
||||
const pinyinSource = options.pinyinFields === undefined
|
||||
? haystack
|
||||
: options.pinyinFields
|
||||
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
|
||||
.map((field) => normalizeText(field))
|
||||
.join(" ");
|
||||
const { full, initials } = getPinyinVariants(pinyinSource);
|
||||
const { full, initials } = getPinyinVariants(sourceText);
|
||||
if (!full && !initials) return false;
|
||||
|
||||
return tokens.every((token) => {
|
||||
@@ -280,68 +138,6 @@ export function matchesPreparedSearchQuery(
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_SEARCH_MATCH_OPTIONS: Required<Pick<SearchMatchOptions, "allowPinyin">> = {
|
||||
allowPinyin: true,
|
||||
};
|
||||
|
||||
export function matchesSearchQuery(
|
||||
query: string,
|
||||
...rest: Array<string | null | undefined | SearchMatchOptions>
|
||||
): boolean {
|
||||
const args = [...rest];
|
||||
const options: Pick<SearchMatchOptions, "allowPinyin"> & { pinyinFields?: string[] } =
|
||||
args.length > 0
|
||||
&& typeof args[args.length - 1] === "object"
|
||||
&& args[args.length - 1] !== null
|
||||
? { ...DEFAULT_SEARCH_MATCH_OPTIONS, ...(args.pop() as SearchMatchOptions) }
|
||||
: DEFAULT_SEARCH_MATCH_OPTIONS;
|
||||
const fields = args as Array<string | null | undefined>;
|
||||
return matchesPreparedSearchQuery(
|
||||
query,
|
||||
prepareSearchFields(fields),
|
||||
{ allowPinyin: options.allowPinyin, pinyinFields: options.pinyinFields },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-prefix matcher: the query must prefix-match a whole token (word) of one
|
||||
* of the fields — including that token's pinyin full/initials forms. Strictly
|
||||
* weaker than matchesSearchQuery's substring behavior; used where substring
|
||||
* noise is undesirable, e.g. snippet completions while typing an argument
|
||||
* (issue #3622). Dashes/underscores separate tokens, so "set-email" still
|
||||
* matches from the word "email".
|
||||
*/
|
||||
export function matchesWordPrefixQuery(
|
||||
query: string,
|
||||
...fields: Array<string | null | undefined>
|
||||
): boolean {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const words = fields
|
||||
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
|
||||
.flatMap((field) => normalizeText(field).split(SEARCH_SPLIT_REGEX))
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 0) return false;
|
||||
|
||||
// The compact/pinyin fallback must not discard the query's leading
|
||||
// punctuation: option-style needles like "--ve" or "-d" would otherwise
|
||||
// match words ("version", "deploy") that never literally start with them
|
||||
// and displace the real Fig option completions while typing flags.
|
||||
const compactQuery = /^[a-z0-9]/.test(normalizedQuery)
|
||||
? compactText(normalizedQuery)
|
||||
: "";
|
||||
return words.some((word) => {
|
||||
if (word.startsWith(normalizedQuery)) return true;
|
||||
if (!compactQuery) return false;
|
||||
const { full, initials } = getPinyinVariants(word);
|
||||
return (
|
||||
(full !== "" && full.startsWith(compactQuery))
|
||||
|| (initials !== "" && initials.startsWith(normalizedQuery))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host search should avoid mixing label/group tokens with hostname/IP tokens.
|
||||
* Otherwise queries like "山东 6-1" can accidentally match:
|
||||
|
||||
+12
-16
@@ -2,7 +2,6 @@ import type { UploadBridge } from "./uploadService.types";
|
||||
|
||||
export class UploadController {
|
||||
private cancelled = false;
|
||||
private cancelledFileIds = new Set<string>();
|
||||
private activeFileTransferIds = new Set<string>();
|
||||
private activeCompressionIds = new Set<string>();
|
||||
private currentTransferId = "";
|
||||
@@ -48,7 +47,16 @@ export class UploadController {
|
||||
// Cancel all active file uploads
|
||||
const activeIds = Array.from(this.activeFileTransferIds);
|
||||
for (const transferId of activeIds) {
|
||||
await this.cancelFile(transferId);
|
||||
try {
|
||||
if (this.bridge?.cancelStagedUploadFile) {
|
||||
await this.bridge.cancelStagedUploadFile(transferId);
|
||||
}
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
await this.bridge.cancelTransfer(transferId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
|
||||
// Also cancel current one if not in the set
|
||||
@@ -63,22 +71,11 @@ export class UploadController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel one standalone file without stopping the shared upload queue. */
|
||||
async cancelFile(transferId: string): Promise<void> {
|
||||
this.cancelledFileIds.add(transferId);
|
||||
try {
|
||||
await this.bridge?.cancelStagedUploadFile?.(transferId);
|
||||
await this.bridge?.cancelTransfer?.(transferId);
|
||||
} catch {
|
||||
// The transfer runtime reconciles backend cancellation failures.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if upload was cancelled
|
||||
*/
|
||||
isCancelled(fileId?: string): boolean {
|
||||
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
|
||||
isCancelled(): boolean {
|
||||
return this.cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +98,6 @@ export class UploadController {
|
||||
*/
|
||||
reset(): void {
|
||||
this.cancelled = false;
|
||||
this.cancelledFileIds.clear();
|
||||
this.cancelListeners.clear();
|
||||
this.activeFileTransferIds.clear();
|
||||
this.activeCompressionIds.clear();
|
||||
|
||||
@@ -769,7 +769,6 @@ async function uploadEntries(
|
||||
standaloneTransferId: string,
|
||||
fileTotalBytes: number,
|
||||
): Promise<{ cancelled?: boolean; error?: string }> => {
|
||||
if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true };
|
||||
let localFilePath = getDropEntryLocalPath(entry);
|
||||
let ownedTempPath: string | undefined;
|
||||
|
||||
@@ -785,13 +784,13 @@ async function uploadEntries(
|
||||
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
|
||||
} catch (error) {
|
||||
controller?.removeActiveTransfer(standaloneTransferId);
|
||||
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
localFilePath = ownedTempPath;
|
||||
if (controller?.isCancelled(standaloneTransferId)) {
|
||||
if (controller?.isCancelled()) {
|
||||
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
|
||||
return { cancelled: true };
|
||||
}
|
||||
@@ -957,11 +956,6 @@ async function uploadEntries(
|
||||
);
|
||||
|
||||
if (uploadResult.cancelled) {
|
||||
if (!bundleTaskId && !controller?.isCancelled()) {
|
||||
settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
results.push({ fileName: entry.relativePath, success: false, cancelled: true });
|
||||
continue;
|
||||
}
|
||||
wasCancelled = true;
|
||||
settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
|
||||
|
||||
Generated
+58
-16
@@ -100,7 +100,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.11.12",
|
||||
"electron": "^42.3.3",
|
||||
"electron-builder": "^26.15.2",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -2400,16 +2400,6 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz",
|
||||
@@ -10086,6 +10076,16 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -11366,15 +11366,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "42.11.12",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-42.11.12.tgz",
|
||||
"integrity": "sha512-fHlCHh1uqfvWykQUq8eoSoB5q1gej7z/KKd3m7rqEEhHhihm7qz/EvBIkDat7vQoIq3kGTJcrskLbJ6UFkYGyg==",
|
||||
"version": "42.3.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz",
|
||||
"integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js",
|
||||
@@ -12203,6 +12203,27 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
|
||||
@@ -12318,6 +12339,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
@@ -20167,6 +20198,17 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.31",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz",
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.11.12",
|
||||
"electron": "^42.3.3",
|
||||
"electron-builder": "^26.15.2",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
+2
-2
@@ -318,7 +318,7 @@ declare global {
|
||||
}>;
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
netInterfaces: Array<{ // Per-interface network stats
|
||||
name: string; // Interface name (e.g., eth0, ens33)
|
||||
rxBytes: number; // Total received bytes
|
||||
@@ -531,7 +531,7 @@ declare global {
|
||||
): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
|
||||
): () => void;
|
||||
onTelnetAutoLoginComplete?(
|
||||
sessionId: string,
|
||||
|
||||
Reference in new issue
Block a user