Compare commits

...
Author SHA1 Message Date
bincxz 506dcab734 style(sync): align convergent sync panel with settings 2026-10-18 16:08:57 +08:00
陈大猫 4b8803369f refactor(snippets): consolidate creation buttons into a New dropdown (#3727)
* refactor(snippets): combine creation actions in a dropdown

* fix(snippets): support keyboard navigation in creation menu

* fix(snippets): retain focus when choosing a creation action
2026-10-18 16:01:31 +08:00
陈大猫 ef4caf8b08 chore(packaging): omit duplicate renderer and unused native files (#3726)
Keep renderer-only libraries available as development dependencies so Vite
bundles their runtime code without shipping their dependency trees again.
Exclude native compilation helpers from packaged resources, and prune
node-pty/serialport prebuilds for other platforms and architectures while
preserving matching universal binaries and rebuilt runtime files.

Validation:
- npm run lint; 33 packaging tests pass.
- npm run pack:dir, real packaged application UI, and 29 packaged runtime
  smoke checks pass (SDK imports, PTY, serial port listing, SQLite query).
- All 999 UI files retain their code/content; differences are generated
  asset references and the embedded dependency classification metadata.
- Dependency versions are unchanged.
- macOS arm64 app: 394,959,135 -> 360,740,625 bytes.
- macOS arm64 ZIP: 150,847,369 -> 143,151,622 bytes; ZIP integrity passes.
- Full suite: 14,035 pass, 36 skipped, 5 fail. The four FIDO helper failures
  and transfer FileHandle-GC failure reproduce on unchanged main with Node 26.
2026-10-18 15:26:58 +08:00
Netcatty-Bot ebd77e43bb fix(ssh): cancel background jobs via in-band Ctrl+C (#3725)
Deliver PTY job interrupts as terminal data instead of sending SSH signal channel requests. This addresses the background-cancellation log noise reported in #3577 while preserving the existing cancellation retries and result semantics.

Add regression coverage asserting cancellation writes ETX without invoking the stream signal method.
2026-10-18 15:20:56 +08:00
陈大猫 f63be66ede fix(sftp): keep large file panes responsive when opening and hiding (#3724)
* fix(sftp): bound file panes before measurement and while hidden

* fix(sftp): clamp retained windows after background listing updates
2026-10-18 14:01:26 +08:00
陈大猫 6a7c883337 fix(sftp): keep expanded folder transfer lists responsive (#3722)
* fix(sftp): keep expanded folder transfer lists responsive

* fix(sftp): refresh batch eligibility on capability updates

* fix(sftp): publish repaired hierarchy before ignoring progress

* fix(sftp): persist repaired background transfer hierarchy
2026-10-18 13:37:45 +08:00
陈大猫 47dcbf3a41 fix(browser): stop idle native pane layout polling (#3723) 2026-10-18 13:27:44 +08:00
陈大猫 7c371372a3 fix(vault): skip redundant ASCII host-field pinyin matching (#3721) 2026-10-18 13:20:46 +08:00
陈大猫 99e9879c9a fix(ai): keep large chat history searches responsive (#3719)
* fix(ai): keep large chat history searches responsive

* fix(ai): reconcile pending search matches with current sessions
2026-10-18 13:20:06 +08:00
48 changed files with 2707 additions and 310 deletions

No files matched your search

+1
View File
@@ -538,6 +538,7 @@ export const enAiMessages: Messages = {
'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.searchingSessions': 'Searching...',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.forkFromHere': 'Fork from here',
+1
View File
@@ -727,6 +727,7 @@ export const enTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': 'Search scripts...',
'snippets.action.new': 'New',
'snippets.action.newSnippet': 'New Snippet',
'snippets.action.newPackage': 'New Script Package',
'snippets.action.import': 'Import',
+1
View File
@@ -529,6 +529,7 @@ export const esAiMessages: Messages = {
'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.searchingSessions': 'Buscando...',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
+1
View File
@@ -689,6 +689,7 @@ export const esTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': 'Buscar scripts...',
'snippets.action.new': 'Nuevo',
'snippets.action.newSnippet': 'Nuevo snippet',
'snippets.action.newPackage': 'Nuevo paquete de scripts',
'snippets.action.import': 'Importar',
+1
View File
@@ -485,6 +485,7 @@ export const ruAiMessages: Messages = {
'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…',
'ai.chat.clearSessionSearch': 'Очистить поиск сессий',
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.searchingSessions': 'Поиск...',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.forkFromHere': 'Ветвление отсюда',
+1
View File
@@ -742,6 +742,7 @@ export const ruTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': 'Поиск сниппетов...',
'snippets.action.new': 'Создать',
'snippets.action.newSnippet': 'Новый сниппет',
'snippets.action.newPackage': 'Новый пакет',
'snippets.action.import': 'Импорт',
+1
View File
@@ -538,6 +538,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.searchSessions': '搜索标题和近期内容…',
'ai.chat.clearSessionSearch': '清空会话搜索',
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.searchingSessions': '搜索中…',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.forkFromHere': '从此处分叉',
@@ -813,6 +813,7 @@ export const zhCNTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': '搜索脚本...',
'snippets.action.new': '新建',
'snippets.action.newSnippet': '新建代码片段',
'snippets.action.newPackage': '新建脚本包',
'snippets.action.import': '导入',
+1
View File
@@ -528,6 +528,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.searchSessions': '搜尋標題和近期內容…',
'ai.chat.clearSessionSearch': '清空會話搜尋',
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.searchingSessions': '搜尋中…',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.forkFromHere': '從此處分叉',
@@ -813,6 +813,7 @@ export const zhTWTerminalMessages: Messages = {
// Snippets
'snippets.searchPlaceholder': '搜尋程式碼片段...',
'snippets.action.new': '新增',
'snippets.action.newSnippet': '新增程式碼片段',
'snippets.action.newPackage': '新增程式碼包',
'snippets.action.import': '匯入',
@@ -788,3 +788,44 @@ test("history alone bounds title pinyin while retaining literal title and body m
assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]);
assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions);
});
test("cooperative search yields and preserves the synchronous matching and order", () => {
const search = createSessionHistorySearch();
const sessions = Array.from({ length: 90 }, (_, index) => createSession(
String(index), `Session ${index}`, [{ content: index % 7 === 0 ? "needle 中文" : "ordinary body" }],
));
const expected = search.filterSessionHistory(sessions, "needle");
const scan = search.createScan<typeof sessions[number]>("needle");
assert.equal(scan.step(sessions), null, "a large cached search still yields between slices");
let result: typeof sessions | null = null;
for (let step = 0; step < 20 && result === null; step += 1) result = scan.step(sessions);
assert.deepEqual(result, expected);
scan.dispose();
assert.equal(scan.step(sessions), null);
});
test("cooperative search incorporates live replacements and removals without restarting stable work", () => {
let reads = 0;
const stable = Array.from({ length: 90 }, (_, index) => {
const session = createSession(String(index), `Session ${index}`, []);
session.messages = [{ get content() { reads += 1; return index % 7 === 0 ? "needle" : "ordinary"; } }];
return session;
});
const search = createSessionHistorySearch();
const scan = search.createScan<typeof stable[number]>("needle");
let result: typeof stable | null = null;
let sessions = stable;
let steps = 0;
while (result === null && steps < 30) {
// The newest session changes on every slice, like an active stream.
const streaming = createSession("streaming", `Streaming ${steps}`, [{ content: "needle" }]);
sessions = [streaming, ...stable.slice(0, -1).reverse()];
result = scan.step(sessions);
steps += 1;
}
assert.ok(result, "a live stream cannot starve the other sessions");
assert.ok(steps < 10, `stable work restarted (${steps} slices)`);
assert.deepEqual(result, sessions.filter((session) => session.id === "streaming" || Number(session.id) % 7 === 0));
assert.equal(reads, 89, "each retained stable session is indexed only once");
scan.dispose();
});
+57
View File
@@ -349,6 +349,56 @@ function filterSessionHistoryStore<T extends SessionHistorySearchTarget>(
}
}
/** One query retains only match decisions while its indexes stay memory-bounded. */
export type SessionHistorySearchScan<T extends SessionHistorySearchTarget> = {
/** Null means more work remains; each call yields after a short bounded slice. */
step: (sessions: readonly T[]) => T[] | null;
dispose: () => void;
};
function createSessionHistorySearchScan<T extends SessionHistorySearchTarget>(
store: SessionHistorySearchStore,
query: string,
options: SessionHistorySearchOptions,
): SessionHistorySearchScan<T> {
const preparedQuery = prepareSearchQuery(truncateQueryForMatch(query.trim()));
const matches = new Map<T, boolean>();
let disposed = false;
store.scanTouched.clear();
return {
step(sessions) {
if (disposed) return null;
const live = new Set<SessionHistorySearchTarget>(sessions);
for (const session of matches.keys()) {
if (!live.has(session)) matches.delete(session);
}
for (const session of store.scanTouched) {
if (!live.has(session)) store.scanTouched.delete(session);
}
pruneSessionSearchIndexCache(store, sessions);
const startedAt = performance.now();
let processed = 0;
for (const session of sessions) {
if (matches.has(session)) continue;
const index = getSessionSearchIndex(store, session, options.untitledLabel ?? '');
matches.set(session, matchesSessionSearchIndex(preparedQuery, index));
processed += 1;
// A session already has a bounded searchable corpus. Also cap the
// number per slice so a fast clock/cache cannot make a huge task.
if (processed >= 16 || performance.now() - startedAt >= 4) return null;
}
pruneSessionSearchIndexCache(store, sessions);
store.scanTouched.clear();
return sessions.filter((session) => matches.get(session));
},
dispose() {
disposed = true;
matches.clear();
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
@@ -362,6 +412,10 @@ export type SessionHistorySearch = {
query: string,
options?: SessionHistorySearchOptions,
) => T[];
createScan: <T extends SessionHistorySearchTarget>(
query: string,
options?: SessionHistorySearchOptions,
) => SessionHistorySearchScan<T>;
clearSessionHistorySearchCache: () => void;
};
@@ -375,6 +429,9 @@ export function createSessionHistorySearch(): SessionHistorySearch {
filterSessionHistory(sessions, query, options = {}) {
return filterSessionHistoryStore(store, sessions, query, options);
},
createScan(query, options = {}) {
return createSessionHistorySearchScan(store, query, options);
},
clearSessionHistorySearchCache() {
clearSessionHistorySearchStore(store);
},
@@ -4007,3 +4007,111 @@ test("partial cancel failure resets recovered folder children so the re-walk can
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
}
});
test("lifecycle hierarchy snapshot stays stable on bytes and changes on actual state transitions", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel", [makeTask("hierarchy-live")]);
const hierarchy = store.getLifecycleSnapshot();
store.patchTask("hierarchy-live", { transferredBytes: 5, speed: 3 });
assert.equal(store.getLifecycleSnapshot(), hierarchy);
assert.equal(store.getTask("hierarchy-live")?.transferredBytes, 5);
store.getSnapshot(); // Other progress consumers must not invalidate the hierarchy.
assert.equal(store.getLifecycleSnapshot(), hierarchy);
store.patchTask("hierarchy-live", { status: "failed", error: "fixture failure" });
assert.notEqual(store.getLifecycleSnapshot(), hierarchy);
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "failed");
});
test("progress that admits or changes a task lifecycle also refreshes the hierarchy", () => {
const store = createSftpTransferCenterStore();
const initial = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "late-start", transferred: 1 });
const admitted = store.getLifecycleSnapshot();
assert.notEqual(admitted, initial);
assert.equal(admitted.tasks[0].id, "late-start");
store.publishOwner("panel", [makeTask("queued-progress", "queued")]);
const queued = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 3 });
assert.notEqual(store.getLifecycleSnapshot(), queued);
assert.equal(store.getLifecycleSnapshot().tasks.find((task) => task.id === "queued-progress")?.status, "transferring");
const live = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 4 });
assert.equal(store.getLifecycleSnapshot(), live);
});
test("admission updates hierarchy state without forcing full owner lifecycle work", () => {
const store = createSftpTransferCenterStore();
const task = makeTask("admitted-hierarchy", "queued");
store.publishOwner("panel", [task]);
const queued = store.getLifecycleSnapshot();
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
assert.equal(store.admitTaskRun(task), "ready");
assert.notEqual(store.getLifecycleSnapshot(), queued);
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "transferring");
assert.equal(lifecycleNotifications, 0, "dispatch admission keeps its existing lightweight fanout");
});
test("same-byte and ignored terminal events publish repaired background hierarchy immediately", () => {
for (const { status, type } of [
{ status: "transferring", type: "progress" },
{ status: "completed", type: "progress" },
{ status: "completed", type: "completed" },
{ status: "failed", type: "failed" },
] as const) {
const store = createSftpTransferCenterStore();
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
const beforeRepair = store.getLifecycleSnapshot();
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
store.ingestBackgroundEvent({ type, transferId: "orphan", parentTaskId: "folder", transferred: 2 });
const repaired = store.getLifecycleSnapshot();
assert.notEqual(repaired, beforeRepair, status);
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.parentTaskId, "folder", status);
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.background, false, status);
assert.equal(lifecycleNotifications, 0, "hierarchy repair keeps lightweight owner fanout");
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
assert.equal(store.getLifecycleSnapshot(), repaired, "subsequent byte ticks preserve the repaired hierarchy without rebuilding it");
assert.equal(store.getTask("orphan")?.parentTaskId, "folder");
}
});
test("repaired terminal hierarchy survives restart without another lifecycle event", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.now() });
for (const status of ["completed", "failed", "cancelled"] as const) {
let persisted = "";
let writes = 0;
const store = createSftpTransferCenterStore({ read: () => null, write: (value) => { persisted = value; writes += 1; } });
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
t.mock.timers.tick(300);
assert.equal(JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan")?.parentTaskId, undefined);
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
const beforeRepairWrites = writes;
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 2 });
t.mock.timers.tick(300);
assert.equal(writes, beforeRepairWrites + 1, "structural repair is saved even when the terminal event is ignored");
const savedChild = JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan");
assert.equal(savedChild?.parentTaskId, "folder", status);
assert.equal(savedChild?.background, false, status);
assert.equal(savedChild?.ownerId, "panel", status);
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
const child = restored.getTask("orphan");
if (status === "completed") {
assert.equal(child, undefined, "existing restore compaction retains completed children in their folder checkpoint");
assert.equal(restored.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1);
} else {
assert.equal(child?.parentTaskId, "folder", status);
assert.equal(child?.background, false, status);
assert.equal(child?.ownerId, "panel", status);
}
assert.equal(lifecycleNotifications, 0, "repair retains lightweight owner notifications");
const settledWrites = writes;
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
t.mock.timers.tick(300);
assert.equal(writes, settledWrites, "unchanged late progress does not schedule recurring writes");
}
});
+48 -4
View File
@@ -109,6 +109,8 @@ export interface SftpTransferCenterStore {
*/
subscribeProgress(listener: Listener): () => void;
getSnapshot(): SftpTransferCenterSnapshot;
/** Stable task hierarchy between lifecycle changes; live rows read getTask. */
getLifecycleSnapshot(): SftpTransferCenterSnapshot;
/** Stable identity when badge counts are unchanged (avoids TopTabs thrash). */
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
@@ -213,6 +215,21 @@ function buildBadgeSnapshot(tasks: readonly TransferTask[]): { count: number; ha
};
}
/** Fields used by grouping, ordering and batch-action eligibility. */
function hasTransferListMetadataChange(before: TransferTask | undefined, after: TransferTask | undefined): boolean {
if (!before || !after) return before !== after;
return before.status !== after.status
|| before.parentTaskId !== after.parentTaskId
|| before.background !== after.background
|| before.startTime !== after.startTime
|| before.priority !== after.priority
|| before.ownerId !== after.ownerId
|| before.resumable !== after.resumable
|| before.reconnectRequired !== after.reconnectRequired
|| Boolean(before.conflict) !== Boolean(after.conflict)
|| (before.status === "failed" && ((before.checkpointBytes ?? 0) > 0) !== ((after.checkpointBytes ?? 0) > 0));
}
const TERMINAL_OWNER_STATUSES = new Set<TransferTask["status"]>([
"completed",
"cancelled",
@@ -420,6 +437,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
event: BackgroundTransferEvent;
}>>();
let snapshot = tasks.length > 0 ? buildSnapshot(tasks) : EMPTY_SNAPSHOT;
let lifecycleSnapshot = snapshot;
let badgeSnapshot = buildBadgeSnapshot(tasks);
let snapshotDirty = false;
const listeners = new Set<Listener>();
@@ -695,6 +713,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}
snapshotDirty = false;
snapshot = buildSnapshot(tasks);
lifecycleSnapshot = snapshot;
refreshBadgeSnapshot();
persist(shouldPersistImmediately);
notifyDedicatedResumeWaiters();
@@ -707,19 +726,24 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
/** Progress-only: mutate tasks, defer snapshot rebuild, wake progress UI only. */
const emitProgress = (persistImmediately = false) => {
const emitProgress = (persistImmediately = false, lifecycleChanged = false) => {
if (deferRestoreReplayEmits) {
snapshotDirty = true;
persistenceDirty = persistenceDirty || persistImmediately;
return;
}
snapshotDirty = true;
// Admission/late progress may change a row's lifecycle without the costly
// prune/persist path. Keep the list hierarchy current, coalescing its UI
// notification with progress instead of waking owner lifecycle listeners.
if (lifecycleChanged) lifecycleSnapshot = buildSnapshot(tasks);
// Never JSON.stringify/localStorage on the pure-progress path. Sync storage
// on large directory histories freezes CSS spinners every few seconds.
// Pause / complete / fingerprint / lifecycle still force persist below.
if (persistImmediately) {
snapshotDirty = false;
snapshot = buildSnapshot(tasks);
lifecycleSnapshot = snapshot;
refreshBadgeSnapshot();
persist(true);
notifyDedicatedResumeWaiters();
@@ -1401,6 +1425,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
return () => progressListeners.delete(listener);
},
getSnapshot: () => ensureSnapshot(),
getLifecycleSnapshot: () => lifecycleSnapshot,
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
const rootId = incoming.parentTaskId ?? incoming.id;
@@ -1454,7 +1479,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
tasks = next;
captureObservedTask(next[index]);
emitProgress(false);
emitProgress(false, hasTransferListMetadataChange(existing, next[index]));
return "ready";
},
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
@@ -1773,7 +1798,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (fingerprintChanged) emit(true);
else if (directoryCheckpointChanged) emit(true);
else if (lifecycleChanged) emit();
else emitProgress(false);
else emitProgress(false, hasTransferListMetadataChange(task, nextTask));
}
},
upsertTasks(incoming) {
@@ -2173,6 +2198,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
let existing = tasks[existingIndex];
const beforeEvent = existing;
let parentControlsChanged = false;
const eventParent = event.parentTaskId
? tasks.find((task) => task.id === event.parentTaskId)
: undefined;
@@ -2190,7 +2217,16 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
} : task);
existing = tasks[existingIndex];
captureObservedTask(existing);
// Structural recovery must survive restart even if an unchanged or
// already-terminal event returns before the normal persistence path.
persist();
}
// Hierarchy repair has already changed the live row. Even an otherwise
// ignored event must publish that change; ordinary progress still emits
// only once at the end, without a second directory-sized snapshot build.
const publishRepairedHierarchy = () => {
if (existing !== beforeEvent) emitProgress(false, true);
};
// A delayed child progress event can arrive after the renderer compacted
// that completed child into its parent checkpoint. Never resurrect it as
// a live row, especially after the parent itself has finished.
@@ -2216,9 +2252,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Allow a late explicit cancel to stick; ignore everything else.
if (event.type === "completed" || event.type === "failed") {
// Keep existing terminal state (prefer cancelled over late completed).
publishRepairedHierarchy();
return;
}
if (event.type === "queued" || event.type === "started" || event.type === "progress" || event.type === "pausing" || event.type === "resumed" || event.type === "paused") {
publishRepairedHierarchy();
return;
}
}
@@ -2263,6 +2301,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& sameOptional(event.resumable, existing.resumable)
&& sameOptional(event.pauseUnavailableReason, existing.pauseUnavailableReason)
) {
publishRepairedHierarchy();
return;
}
}
@@ -2452,6 +2491,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
});
if (event.resumable === false && existing.parentTaskId) {
const parent = tasks.find((task) => task.id === existing.parentTaskId);
parentControlsChanged = parent !== undefined && parent.resumable !== false;
tasks = tasks.map((task) => task.id === existing.parentTaskId ? {
...task,
resumable: false,
@@ -2508,8 +2549,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
// events still take the full emit path so UI never lags on pause/cancel.
const currentTask = tasks[existingIndex];
const hierarchyOrLifecycleChanged = parentControlsChanged
|| hasTransferListMetadataChange(beforeEvent, currentTask);
if (event.type === "progress" && !persistImmediately) {
emitProgress(false);
emitProgress(false, hierarchyOrLifecycleChanged);
} else {
emit(persistImmediately);
}
+121 -3
View File
@@ -21,6 +21,17 @@ async function fixture(t: test.TestContext) {
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const resizeObservers = new Map<ResizeObserver, Set<Element>>();
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
class LayoutResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
observe(target: Element) { resizeObservers.get(this)?.add(target); }
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
}
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
@@ -40,8 +51,11 @@ async function fixture(t: test.TestContext) {
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
const element = env.document.createElement('div');
env.document.body.append(element);
let bounds = new env.window.DOMRect(200, 100, 500, 600);
let reads = 0;
element.getBoundingClientRect = () => { reads += 1; return bounds; };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
@@ -62,11 +76,15 @@ async function fixture(t: test.TestContext) {
};
const tick = async () => {
await act(async () => {
await Promise.resolve();
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
observerCount: () => resizeObservers.size,
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
@@ -128,3 +146,103 @@ test('hiding or locking the pane clears pictures without taking another capture'
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
test('a stationary visible browser leaves no repeating layout work', async (t) => {
const f = await fixture(t);
for (let index = 0; index < 600; index += 1) await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
});
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
const f = await fixture(t);
const reads = f.reads();
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
for (let index = 0; index < 20; index += 1) {
f.resize();
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
}
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
f.env.document.dispatchEvent(new f.env.window.Event('load'));
f.style.colorScheme = 'light';
f.env.document.documentElement.classList.add('light');
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
await f.tick();
assert.equal(f.reads(), reads + 1);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
assert.equal(f.layouts.at(-1)?.theme, 'light');
assert.equal(f.pendingFrames(), 0);
f.env.document.body.setAttribute('inert', ''); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
f.env.document.body.removeAttribute('inert'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true);
});
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
const f = await fixture(t);
const popper = f.overlay(tooltip, 10, 10); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
f.resize(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
await f.hide();
assert.equal(f.observerCount(), 0);
const reads = f.reads();
f.overlay('<div role="dialog" aria-modal="true"></div>');
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
await f.tick();
assert.equal(f.pendingFrames(), 0);
assert.equal(f.reads(), reads);
});
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
const f = await fixture(t);
const animation = { pending: false, playState: 'running' };
f.element.getAnimations = () => [animation as unknown as Animation];
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
const reads = f.reads();
for (let index = 0; index < 5; index += 1) {
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
await f.tick();
}
assert.equal(f.reads(), reads + 5);
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
animation.playState = 'finished'; await f.tick();
assert.equal(f.pendingFrames(), 0);
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
spinner.getAnimations = () => [animation as unknown as Animation];
const settledReads = f.reads();
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
await f.tick();
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
});
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
const f = await fixture(t);
const popper = f.overlay(tooltip);
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
await f.tick();
assert.equal(f.pendingFrames(), 1);
popper.remove(); await f.tick();
assert.equal(f.pendingFrames(), 0);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
const f = await fixture(t);
const captures: ((result: Result) => void)[] = [];
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
const first = f.overlay(tooltip); await f.tick();
first.remove(); await f.tick();
f.overlay(tooltip); await f.tick();
assert.equal(captures.length, 2);
await act(async () => captures[0]({ layers }));
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
await act(async () => captures[1]({ layers }));
assert.deepEqual(f.panel().snapshot, layers);
});
+85 -7
View File
@@ -38,9 +38,33 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let frame: number | null = null;
let previous = '';
let disposed = false;
let layoutRevision = 0;
const element = elementRef.current;
const doc = element?.ownerDocument;
const view = doc?.defaultView;
const observed = new Set<Element>();
const animations = new Map<Animation, Element>();
const schedule = () => {
if (disposed || frame !== null) return;
frame = requestAnimationFrame(() => {
frame = null;
for (const [animation, target] of animations) {
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
}
update();
if (animations.size > 0) schedule();
});
};
const trackAnimations = (target: Element) => {
for (const animation of target.getAnimations?.() ?? []) {
if (!animation.pending && animation.playState !== 'running') continue;
animations.set(animation, target);
}
};
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
@@ -48,9 +72,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
const overlays = Array.from(document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
));
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
const targets = new Set<Element>(overlays);
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
for (const target of observed) {
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
}
for (const target of targets) {
if (observed.has(target)) continue;
observed.add(target);
resizeObserver?.observe(target);
trackAnimations(target);
}
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
@@ -63,17 +99,59 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const next = JSON.stringify(layout);
if (next !== previous) {
previous = next;
const revision = ++layoutRevision;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
if (disposed || layoutRevision !== revision) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
if (!isVisible) {
setSnapshot(null);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
return;
}
// Layout, theme and portal changes coalesce into a single frame. Keep
// following only running animations on the pane's ancestors or overlay
// containers; unrelated spinner/cursor animations must not restart polling.
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
if (doc) mutationObserver?.observe(doc.documentElement, {
subtree: true, childList: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
});
const onMotion = (event: Event) => {
const target = event.target;
if (!element || !view || !(target instanceof view.Element)) return;
if (!target.contains(element) && !target.matches(
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
)) return;
trackAnimations(target);
schedule();
};
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
doc?.addEventListener('scroll', schedule, true);
doc?.addEventListener('load', schedule, true);
view?.addEventListener('resize', schedule);
doc?.fonts?.addEventListener('loadingdone', schedule);
doc?.fonts?.addEventListener('loadingerror', schedule);
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
update();
if (animations.size > 0) schedule();
return () => {
disposed = true;
cancelAnimationFrame(frame);
if (frame !== null) cancelAnimationFrame(frame);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
doc?.removeEventListener('scroll', schedule, true);
doc?.removeEventListener('load', schedule, true);
view?.removeEventListener('resize', schedule);
doc?.fonts?.removeEventListener('loadingdone', schedule);
doc?.fonts?.removeEventListener('loadingerror', schedule);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
}, [browserId, isVisible]);
+84 -15
View File
@@ -1,24 +1,93 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { SessionHistorySearchTarget } from '../../domain/sessionHistorySearch';
import {
createSessionHistorySearch,
type SessionHistorySearch,
type SessionHistorySearchScan,
} 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 {
/** Search outside rendering, yielding between bounded slices for input/paint. */
export function useSessionHistorySearch<T extends SessionHistorySearchTarget>(
sessions: readonly T[],
query: string,
untitledLabel: string,
): { filteredSessions: readonly T[]; isSearching: boolean } {
const search = useMemo(() => createSessionHistorySearch(), []);
const latest = useRef({ sessions, query, untitledLabel });
const work = useRef<{
query: string;
untitledLabel: string;
scan: SessionHistorySearchScan<T>;
} | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [result, setResult] = useState<{
sessions: readonly T[];
query: string;
untitledLabel: string;
filteredSessions: T[];
} | null>(null);
useLayoutEffect(() => {
latest.current = { sessions, query, untitledLabel };
}, [sessions, query, untitledLabel]);
useEffect(() => {
const cancel = () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
work.current?.scan.dispose();
work.current = null;
};
if (!query.trim()) {
cancel();
setResult(null);
// Release deleted/replaced sessions without indexing any message bodies.
search.filterSessionHistory(sessions, '');
return;
}
if (work.current?.query !== query || work.current.untitledLabel !== untitledLabel) {
cancel();
work.current = { query, untitledLabel, scan: search.createScan<T>(query, { untitledLabel }) };
}
if (timer.current !== null) return;
const job = work.current;
const run = () => {
timer.current = null;
const current = latest.current;
if (work.current !== job || current.query !== job.query || current.untitledLabel !== job.untitledLabel) return;
const filteredSessions = job.scan.step(current.sessions);
if (filteredSessions === null) {
timer.current = setTimeout(run, 0);
} else {
setResult({ ...current, filteredSessions });
}
};
// Even the first slice runs after the input's render/commit has returned.
// Session updates reuse the in-flight query's match decisions, so one
// streaming session cannot restart and starve a large history search.
timer.current = setTimeout(run, 0);
}, [sessions, query, untitledLabel, search]);
useEffect(() => () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
work.current?.scan.dispose();
work.current = null;
search.clearSessionHistorySearchCache();
}, [search]);
return search;
const filteredSessions = useMemo(() => {
if (!query.trim()) return sessions;
if (result?.query !== query || result.untitledLabel !== untitledLabel) return [];
if (result.sessions === sessions) return result.filteredSessions;
// Keep only confirmed, unchanged objects in the current order while the
// scheduled scan checks new/replaced sessions. Do not read message bodies
// here, or make the whole list disappear on each streaming update.
const confirmed = new Set(result.filteredSessions);
return sessions.filter((session) => confirmed.has(session));
}, [sessions, query, untitledLabel, result]);
const sameQuery = result?.query === query && result.untitledLabel === untitledLabel;
return {
filteredSessions,
isSearching: Boolean(query.trim()) && (!sameQuery || result.sessions !== sessions),
};
}
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("history input yields before indexing long sessions and never publishes an obsolete query", async (t) => {
const env = 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(env.document);
t.after(async () => { await renderer.unmount(); env.cleanup(); });
let heartbeat = false;
let readsBeforeHeartbeat = 0;
let reads = 0;
const body = "representative retained output and ordinary content ".repeat(400);
let sessions: AISession[] = Array.from({ length: 180 }, (_, index) => ({
id: `session-${index}`,
title: `Session ${index}`,
agentId: "catty",
scope: { type: "terminal", targetId: "fixture" },
createdAt: 0,
updatedAt: 0,
messages: Array.from({ length: 4 }, (_, part) => ({
id: `message-${index}-${part}`,
role: "assistant" as const,
timestamp: 0,
get content() {
reads += 1;
if (!heartbeat) readsBeforeHeartbeat += 1;
return index === 179 && part === 3 ? "latest-match" : body;
},
})),
}));
const noop = () => {};
const render = () => renderer.render(
<I18nProvider locale="en"><TooltipProvider>
<SessionHistoryDrawer sessions={sessions} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />
</TooltipProvider></I18nProvider>,
);
const query = async (value: string) => {
const input = renderer.container.querySelector("input")!;
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, value);
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
assert.equal(input.value, value);
};
const waitFor = async (condition: () => boolean) => {
for (let attempt = 0; attempt < 300 && !condition(); attempt += 1) {
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
}
assert.ok(condition(), "search did not settle");
};
await render();
assert.equal(reads, 0, "blank query never indexes message bodies");
setTimeout(() => { heartbeat = true; }, 0);
await query("never-match");
assert.equal(readsBeforeHeartbeat, 0, "typing must return to the event loop before indexing messages");
await query("latest-match");
await waitFor(() => renderer.container.querySelectorAll('[role="button"]').length === 1);
assert.match(renderer.container.querySelector('[role="button"]')!.textContent!, /Session 179/);
// A streaming session changes while a large search is running. Keep progress
// on unchanged sessions, search the replacement, and respect the new order.
await query("new-live-match");
sessions = [{ ...sessions[10], title: "New live match", messages: [{ id: "replacement", role: "assistant", content: "new-live-match", timestamp: 1 }] }, ...sessions.filter((session) => session.id !== "session-10" && session.id !== "session-179")];
await render();
await waitFor(() => renderer.container.querySelector('[role="button"]')?.textContent?.includes("New live match") === true);
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 1);
await query("");
assert.equal(renderer.container.querySelectorAll('[role="button"]').length, 80);
});
test("pending history updates immediately remove stale matches and retain current ordering", async (t) => {
const env = 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(env.document);
t.after(async () => { await renderer.unmount(); env.cleanup(); });
const make = (id: string, matches: boolean): AISession => ({
id, title: id, agentId: "catty", scope: { type: "terminal", targetId: "fixture" },
createdAt: 0, updatedAt: 0,
messages: [{ id: `${id}-message`, role: "assistant", content: matches ? "needle" : "ordinary", timestamp: 0 }],
});
const a = make("Match A", true);
const b = make("Match B", true);
const c = make("Match C", true);
const rest = Array.from({ length: 100 }, (_, index) => make(`Other ${index}`, false));
let sessions = [a, b, c, ...rest];
let revision = 0;
const commits: string[][] = [];
const rowLabels = () => [...renderer.container.querySelectorAll('[role="button"]')]
.map((row) => row.querySelector("span")?.textContent ?? "");
const noop = () => {};
const Frame = ({ items, version }: { items: AISession[]; version: number }) => {
React.useLayoutEffect(() => {
if (version > 0) commits.push(rowLabels());
}, [items, version]);
return <SessionHistoryDrawer sessions={items} activeSessionId={null} onSelect={noop} onDelete={noop} onClose={noop} />;
};
const render = () => renderer.render(<I18nProvider locale="en"><TooltipProvider>
<Frame items={sessions} version={revision} />
</TooltipProvider></I18nProvider>);
const settle = async (expected: string[]) => {
for (let attempt = 0; attempt < 200 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
}
assert.deepEqual(rowLabels(), expected);
};
await render();
const input = renderer.container.querySelector("input")!;
Object.getOwnPropertyDescriptor(env.window.HTMLInputElement.prototype, "value")!.set!.call(input, "needle");
await dispatchDomEvent(input, new env.window.Event("input", { bubbles: true }));
await settle(["Match A", "Match B", "Match C"]);
revision += 1;
sessions = [c, a, b, ...rest];
await render();
assert.deepEqual(commits.at(-1), ["Match C", "Match A", "Match B"], "pending results follow the current list order in the very first commit");
await settle(["Match C", "Match A", "Match B"]);
revision += 1;
sessions = [c, make("Match A", false), ...rest];
await render();
assert.deepEqual(commits.at(-1), ["Match C"], "deleted and replaced objects cannot remain selectable; unchanged matches stay visible");
await settle(["Match C"]);
revision += 1;
sessions = [make("Match D", true), ...sessions];
await render();
assert.deepEqual(commits.at(-1), ["Match C"]);
await settle(["Match D", "Match C"]);
});
@@ -4,7 +4,7 @@ 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";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
const dom = installDomEnvironment();
@@ -51,6 +51,10 @@ test("history drawer filters, pages, clears and explains the bounded search in e
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!;
setValue.call(input, query);
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
for (let attempt = 0; attempt < 100 && renderer.container.querySelector('[aria-busy="true"]'); attempt += 1) {
await runWithAct(() => new Promise((resolve) => setTimeout(resolve, 5)));
}
assert.equal(renderer.container.querySelector('[aria-busy="true"]'), null, "search must settle");
};
const loadMore = () => [...renderer.container.querySelectorAll("button")]
.find((button) => button.textContent?.includes("Load more"));
+11 -11
View File
@@ -37,15 +37,10 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
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],
const { filteredSessions, isSearching } = useSessionHistorySearch(
sessions,
searchQuery,
t('ai.chat.untitled'),
);
// Reset the render batch when the list scope or query changes so matching
@@ -103,7 +98,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.searchSessionsHint')}
</p>
</div>
<ScrollArea className="flex-1">
<ScrollArea className="flex-1" aria-busy={isSearching}>
{isSearching && (
<p role="status" className="px-4 py-2 text-[12px] text-muted-foreground/60">
{t('ai.chat.searchingSessions')}
</p>
)}
<div className="px-3">
{sessions.length === 0 ? (
<div className="py-12 text-center">
@@ -111,7 +111,7 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
{t('ai.chat.noSessions')}
</p>
</div>
) : filteredSessions.length === 0 ? (
) : filteredSessions.length === 0 && !isSearching ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noMatchingSessions')}
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("expanded folder history bounds mounted files, isolates progress, and reaches the final child", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-child-list" ? 192 : 460; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
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 childCount = 1_000;
const base = {
fileName: "folder", sourcePath: "/source", targetPath: "/target",
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
totalBytes: 1_000, transferredBytes: 0, speed: 0, startTime: 1,
};
store.publishOwner("children-performance", [
{ ...base, id: "folder", status: "transferring", isDirectory: true, progressMode: "files" },
...Array.from({ length: childCount }, (_, index) => ({
...base, id: `child-${index}`, fileName: `child-${index}.bin`, sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
status: index === 0 ? "transferring" as const : "queued" as const,
startTime: index + 2, isDirectory: false, parentTaskId: "folder",
})),
]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const click = async (selector: string) => {
const element = env.document.querySelector(selector);
assert.ok(element, selector);
await dispatchDomEvent(element, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
};
await click("[data-section=global-sftp-transfer-toggle]");
await click('button[aria-label="Show files"]');
const childList = () => env.document.querySelector<HTMLElement>("[data-section=global-sftp-transfer-child-list]");
const childRows = () => childList()?.querySelectorAll('[data-transfer-status]') ?? [];
assert.ok(childRows().length > 0 && childRows().length < 40, `mounted ${childRows().length} child rows`);
assert.match(childList()?.textContent ?? "", /child-0.bin/);
let orderReads = 0;
for (const task of store.getSnapshot().tasks) {
if (!task.parentTaskId) continue;
const startTime = task.startTime;
Object.defineProperty(task, "startTime", { configurable: true, enumerable: true, get: () => { orderReads += 1; return startTime; } });
}
await runWithAct(async () => {
store.patchTask("child-0", { transferredBytes: 500, speed: 100 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.ok(orderReads < 20, `one byte update re-sorted the directory (${orderReads} reads)`);
assert.match(childList()?.textContent ?? "", /500 B/);
const pauseAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Pause all")!;
assert.equal(pauseAll().disabled, false);
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 600, resumable: false, pauseUnavailableReason: "fixture cannot pause" });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(store.getTask("folder")?.resumable, false, "a child's backend capability also limits its folder");
assert.equal(pauseAll().disabled, true, "batch controls must use the current backend capability");
await runWithAct(async () => {
store.patchTask("folder", { resumable: true });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, false, "metadata-only patches must refresh eligibility too");
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 650, resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, true, "a parent-only capability change is reflected even when the child already reported false");
await runWithAct(async () => {
store.patchTask("folder", { resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, true);
orderReads = 0;
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 700, resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.ok(orderReads < 20, "repeated capability metadata on byte ticks does not re-sort the directory");
await runWithAct(() => store.publishOwner("failed-control", [{
...base, id: "failed-control", status: "failed", isDirectory: false, endTime: Date.now(), resumable: true, checkpointBytes: 0,
}]));
const resumeAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Resume all")!;
assert.equal(resumeAll().disabled, true);
await runWithAct(async () => {
store.patchTask("failed-control", { checkpointBytes: 20 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(resumeAll().disabled, false, "a newly available failed-task checkpoint enables the existing resume action");
const list = childList()!;
list.scrollTop = 24 * childCount - 192;
await dispatchDomEvent(list, new env.window.Event("scroll"));
await flushEffects();
assert.ok(childRows().length > 0 && childRows().length < 40);
assert.match(childList()?.textContent ?? "", /child-999.bin/);
await runWithAct(() => store.patchTask("child-999", { status: "completed", transferredBytes: 1_000 }));
assert.equal(store.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1, "completed files remain counted in the existing compact folder checkpoint");
assert.equal(store.getTask("child-999"), undefined, "the existing completion compaction still owns history retention");
assert.equal(store.getTask("child-500")?.status, "queued");
await click('button[aria-label="Hide files"]');
assert.equal(childList(), null);
await click('button[aria-label="Show files"]');
assert.ok(childRows().length > 0 && childRows().length < 40);
assert.equal(store.getSnapshot().tasks.filter((task) => task.parentTaskId === "folder").length, childCount - 1, "virtualization never drops queued files");
await runWithAct(() => {
store.publishOwner("repair-folder", [{ ...base, id: "repair-folder", fileName: "repaired folder", status: "transferring", isDirectory: true }]);
store.publishOwner("repair-background", [{ ...base, id: "repair-child", fileName: "repaired-child.bin", status: "transferring", isDirectory: false, background: true, transferredBytes: 10 }]);
});
const repairedFolderRow = () => [...env.document.querySelectorAll<HTMLElement>('[data-section=global-sftp-transfer-row]')].find((row) => row.textContent?.includes("repaired folder"))!;
assert.equal(repairedFolderRow().querySelector('button[aria-label="Show files"]'), null);
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 10 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
const repairedExpand = repairedFolderRow().querySelector('button[aria-label="Show files"]');
assert.ok(repairedExpand, "same-byte hierarchy repair immediately exposes the folder's child");
await dispatchDomEvent(repairedExpand, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
const repairedHierarchy = store.getLifecycleSnapshot();
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 11 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(store.getLifecycleSnapshot(), repairedHierarchy);
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
await click("[data-section=global-sftp-transfer-toggle]");
});
+80 -76
View File
@@ -24,6 +24,7 @@ import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
useSftpTransferTask,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -47,28 +48,22 @@ const CLOSED_TRANSFER_CENTER_SNAPSHOT: SftpTransferCenterSnapshot = {
};
/**
* Full transfer-center list only while the popover is open. When closed, the
* getter always returns the same CLOSED snapshot so progress store notifies do
* not re-render this TopTabs child (badge uses a separate stable subscription).
* Read the folder hierarchy only while open and only on lifecycle changes.
* Mounted rows subscribe to their own bytes; progress must not regroup/sort
* every folder or re-render the center and its TopTabs ancestor.
*/
function useSftpTransferCenterWhenOpen(open: boolean): SftpTransferCenterSnapshot {
const openRef = useRef(open);
openRef.current = open;
return useSyncExternalStore(
(onStoreChange) => {
// Lifecycle + progress: when the popover is open the list must move with
// bytes; when closed getSnapshot returns a stable CLOSED constant so
// progress ticks are cheap no-ops for this subscriber.
const unsubLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
const unsubProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
return () => {
unsubLifecycle();
unsubProgress();
};
const unsubscribeLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
const unsubscribeProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
return () => { unsubscribeLifecycle(); unsubscribeProgress(); };
},
() => (
openRef.current
? sftpTransferCenterStore.getSnapshot()
? sftpTransferCenterStore.getLifecycleSnapshot()
: CLOSED_TRANSFER_CENTER_SNAPSHOT
),
() => CLOSED_TRANSFER_CENTER_SNAPSHOT,
@@ -340,8 +335,74 @@ function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetP
return `${source} → ${target}`;
}
const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTask, compact = false, frozen = false }: {
task: TransferTask;
compact?: boolean;
frozen?: boolean;
}) {
const liveTask = useSftpTransferTask(snapshotTask.id, snapshotTask);
const task = frozen ? snapshotTask : liveTask;
const { t } = useI18n();
const percent = getGlobalTransferProgressPercent(task);
const detail = formatChildByteProgress(task)
|| (task.totalBytes > 0 ? `${percent.toFixed(0)}%` : t(statusLabelKey(task.status)));
if (!compact) {
return (
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
}
const childFrozen = frozen || task.status === "pausing" || task.status === "paused";
return (
<div className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip text={task.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono">{detail}</span>
</div>
{task.totalBytes > 0 && (
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
<div className={cn("h-full rounded-full", task.status === "completed" ? "bg-emerald-500/80" : childFrozen ? "bg-amber-500/80" : "bg-primary/80")}
style={{ width: `${task.status === "completed" ? 100 : percent}%` }} />
</div>
)}
</div>
);
});
const TransferChildrenList = React.memo(function TransferChildrenList({ tasks }: { tasks: readonly TransferTask[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtual = tasks.length > 20;
const virtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 24,
getItemKey: (index) => tasks[index].id,
initialRect: { width: 0, height: 192 },
overscan: 4,
enabled: virtual,
});
return (
<div className="mt-2 border-t border-border/40 pt-2">
<div ref={scrollRef} className="max-h-48 overflow-y-auto" style={{ height: Math.min(tasks.length, 8) * 24 }} data-section="global-sftp-transfer-child-list">
{!virtual ? tasks.map((task) => <TransferChildRow key={task.id} task={task} />) : (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${row.start}px)` }}>
<TransferChildRow task={tasks[row.index]} />
</div>
))}
</div>
)}
</div>
</div>
);
});
function TransferRow({
task,
task: snapshotTask,
childTasks = [],
expanded,
onToggleExpanded,
@@ -353,6 +414,7 @@ function TransferRow({
onToggleExpanded: () => void;
isLast: boolean;
}) {
const task = useSftpTransferTask(snapshotTask.id, snapshotTask);
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
@@ -605,72 +667,14 @@ function TransferRow({
)}
data-section="global-sftp-transfer-active-children"
>
{displayChildren.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
const childFrozen = isPausing
|| task.status === "paused"
|| child.status === "pausing"
|| child.status === "paused";
return (
<div key={child.id} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip text={child.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono">
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
</div>
{child.totalBytes > 0 && (
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full",
child.status === "completed"
? "bg-emerald-500/80"
: childFrozen
? "bg-amber-500/80"
: "bg-primary/80",
)}
style={{ width: `${child.status === "completed" ? 100 : childPercent}%` }}
/>
</div>
)}
</div>
);
})}
{displayChildren.map((child) => (
<TransferChildRow key={child.id} task={child} compact frozen={isPausing || task.status === "paused"} />
))}
</div>
)}
{isDirParent && expanded && childTasks.length > 0 && (
<div
className="mt-2 max-h-48 space-y-1 overflow-y-auto border-t border-border/40 pt-2"
data-section="global-sftp-transfer-child-list"
>
{childTasks.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
return (
<div
key={child.id}
className="flex items-center gap-2 rounded px-1 py-1 text-[10px] hover:bg-muted/40"
data-transfer-status={child.status}
>
<TruncatedTextWithTooltip
text={child.fileName}
tooltip={child.targetPath || child.fileName}
className="flex-1 text-muted-foreground"
/>
<span className="shrink-0 font-mono text-muted-foreground">
{childDetail
|| (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
{child.status === "transferring" && (
<Loader2 size={10} className="shrink-0 animate-spin text-primary" />
)}
</div>
);
})}
</div>
<TransferChildrenList tasks={childTasks} />
)}
{task.status === "attention" && task.conflict && canControl && (() => {
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("large transfer histories mount only visible rows and can scroll to the last file", async (t) => {
const env = installDomEnvironment();
@@ -64,6 +64,33 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await runWithAct(() => {
store.patchTask("virtual-file-999", { isDirectory: true, progressMode: "files" });
store.upsertTasks(Array.from({ length: 100 }, (_, index) => ({
id: `nested-file-${index}`, fileName: `nested-${index}.bin`,
sourcePath: `/source/999/${index}`, targetPath: `/target/999/${index}`,
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
status: "queued" as const, totalBytes: 1_000, transferredBytes: 0, speed: 0,
startTime: index + 2_000, isDirectory: false, parentTaskId: "virtual-file-999",
})));
});
scroll.scrollTop = 0;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
const expand = env.document.querySelector('button[aria-label="Show files"]');
assert.ok(expand);
await dispatchDomEvent(expand, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
scroll.scrollTop = 112 * 1_000 - 460;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
scroll.scrollTop = 0;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
assert.ok(env.document.querySelector('button[aria-label="Hide files"]'), "folder expansion survives top-level row recycling");
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
+55 -17
View File
@@ -493,6 +493,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
);
const shellHistory = shellHistoryProp ?? (shellHistoryFromStore as ShellHistoryEntry[]);
const [rightPanelMode, setRightPanelMode] = useState<RightPanelMode>('none');
const [showNewMenu, setShowNewMenu] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>({
label: '',
command: '',
@@ -1664,23 +1665,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
<div className={snippetsHeaderActionsWrapClass}>
<div className={cn(snippetsHeaderActionsClass, hasSnippetsSidePanel && 'pr-1')}>
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3 shrink-0">
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
</Button>
<Button onClick={() => handleEdit(undefined, true)} size="sm" variant="secondary" className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}>
<Play size={14} className="mr-2" /> {t('snippets.action.newScript')}
</Button>
<Button
onClick={() => {
setNewPackageName('');
setIsPackageDialogOpen(true);
}}
size="sm"
variant="secondary"
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
>
<FolderPlus size={14} className="mr-1" /> {t('snippets.action.newPackage')}
</Button>
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
<DropdownTrigger asChild>
<Button
size="sm"
variant="secondary"
className={cn(vaultHeaderSecondaryButtonClass, 'shrink-0')}
aria-expanded={showNewMenu}
aria-haspopup="menu"
>
<Plus size={14} />
{t('snippets.action.new')}
<ChevronDown size={14} className={cn('transition-transform', showNewMenu && 'rotate-180')} />
</Button>
</DropdownTrigger>
<DropdownContent className="w-max flex flex-col" align="start" sideOffset={8} keyboardNavigation>
<Button
variant="ghost"
role="menuitem"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
onClick={() => {
setShowNewMenu(false);
handleEdit();
}}
>
<Zap size={16} className="text-sky-500" />
{t('snippets.action.newSnippet')}
</Button>
<Button
variant="ghost"
role="menuitem"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
onClick={() => {
setShowNewMenu(false);
handleEdit(undefined, true);
}}
>
<Play size={16} className="text-violet-500" />
{t('snippets.action.newScript')}
</Button>
<Button
variant="ghost"
role="menuitem"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap focus-visible:bg-accent"
onClick={() => {
setShowNewMenu(false);
setNewPackageName('');
setIsPackageDialogOpen(true);
}}
>
<FolderPlus size={16} className="text-primary" />
{t('snippets.action.newPackage')}
</Button>
</DropdownContent>
</Dropdown>
<Button
variant="secondary"
size="sm"
+19 -43
View File
@@ -11,34 +11,10 @@ import {
isConvergentConflictSecret,
} from '../../domain/convergentSync';
import { Button } from '../ui/button';
import { Toggle } from '../settings/settings-ui';
type Translate = (key: string, values?: Record<string, string | number>) => string;
const ConvergentToggle: React.FC<{
checked: boolean;
disabled: boolean;
label: string;
onChange: (checked: boolean) => void | Promise<void>;
}> = ({ checked, disabled, label, onChange }) => (
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 transition-colors ${
checked ? 'border-amber-500 bg-amber-500' : 'border-border bg-muted'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<span
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
);
function conflictLabel(conflict: ConvergentFieldConflict, t: Translate): string {
const { address } = conflict;
switch (address.kind) {
@@ -104,19 +80,19 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
onResolveConflict,
onDowngrade,
}) => (
<div className="space-y-4 overflow-hidden rounded-xl border border-amber-500/25 bg-gradient-to-br from-amber-500/[0.07] via-card to-card p-4 shadow-sm">
<div className="space-y-4 rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div
data-testid="convergent-sync-icon"
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400"
className="mt-0.5 flex h-9 w-9 shrink-0 self-start items-center justify-center rounded-lg bg-muted text-muted-foreground"
>
<FlaskConical size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{t('cloudSync.convergent.title')}</span>
<span className="inline-flex rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
<span className="text-sm font-medium">{t('cloudSync.convergent.title')}</span>
<span className="inline-flex rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('cloudSync.convergent.experimental')}
</span>
</div>
@@ -125,11 +101,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
</div>
</div>
</div>
<ConvergentToggle
<Toggle
checked={config.enabled}
onChange={onToggle}
disabled={busy}
label={t('cloudSync.convergent.title')}
ariaLabel={t('cloudSync.convergent.title')}
/>
</div>
@@ -143,20 +119,20 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
)}
{preview && (
<div className="space-y-3 rounded-lg border border-border/70 bg-background/65 p-3.5 shadow-sm">
<div className="space-y-3 rounded-md bg-muted/40 p-3">
<div className="text-sm font-semibold">{t('cloudSync.convergent.preview.title')}</div>
<div className="grid grid-cols-1 gap-2 text-center sm:grid-cols-3">
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="px-3 py-2">
<div className="text-lg font-semibold tabular-nums">
{Object.values(preview.entityCounts).reduce((sum, count) => sum + (count ?? 0), 0)}
</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.entities')}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="px-3 py-2">
<div className="text-lg font-semibold tabular-nums">{preview.providers.length}</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.providers')}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
<div className="px-3 py-2">
<div className="text-lg font-semibold tabular-nums">{preview.conflictCount}</div>
<div className="text-[10px] text-muted-foreground">{t('cloudSync.convergent.preview.conflicts')}</div>
</div>
@@ -165,7 +141,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
{t('cloudSync.convergent.preview.compatibility')}
</div>
{preview.providers.length > 0 && (
<div className="divide-y rounded border text-xs">
<div className="divide-y divide-border/40 text-xs">
{preview.providers.map((provider) => (
<div key={provider.provider} className="flex items-start justify-between gap-3 px-2 py-1.5">
<span className="font-medium">{provider.provider}</span>
@@ -178,11 +154,11 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
</div>
)}
{preview.blockedReasons.length > 0 && (
<div className="rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<div className="rounded-md bg-destructive/5 p-2 text-xs text-destructive">
{preview.blockedReasons.join(' · ')}
</div>
)}
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-3">
<div className="flex flex-wrap justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={onCancelMigration} disabled={busy}>
{t('common.cancel')}
</Button>
@@ -194,7 +170,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
)}
{error && (
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<div className="flex gap-2 rounded-md bg-destructive/5 p-2 text-xs text-destructive">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
@@ -209,13 +185,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
{conflicts.map((conflict) => {
const addressKey = convergentConflictAddressKey(conflict.address);
return (
<div key={addressKey} className="space-y-2 rounded-lg border border-border/70 bg-background/65 p-3 shadow-sm">
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
<div className="break-all font-mono text-xs">{conflictLabel(conflict, t)}</div>
<div className="space-y-1.5">
{conflict.candidates.map((candidate) => (
<div
key={dotKey(candidate.dot)}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/15 p-2.5"
className="flex flex-wrap items-center justify-between gap-3 rounded-md bg-background/60 p-2.5"
>
<div className="min-w-0">
<div className="truncate text-xs font-medium">
@@ -228,7 +204,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
</div>
<Button
size="sm"
variant={candidate.selected ? 'secondary' : 'outline'}
variant={candidate.selected ? 'secondary' : 'ghost'}
disabled={busy}
onClick={() => onResolveConflict(addressKey, dotKey(candidate.dot))}
>
@@ -245,7 +221,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
)}
{config.initialized && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<div className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">{t('cloudSync.convergent.downgrade.desc')}</div>
<Button variant="ghost" size="sm" className="text-destructive" onClick={onDowngrade} disabled={busy}>
<RotateCcw size={14} className="mr-1" />
+5 -1
View File
@@ -121,7 +121,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const update = () => setViewportHeight(container.clientHeight);
const update = () => {
const height = container.clientHeight;
setViewportHeight(height);
if (height > 0) setScrollTop(container.scrollTop);
};
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { useLayoutEffect } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneVirtualList } from './hooks/useSftpPaneVirtualList';
import { useSftpPaneTreeRows } from './useSftpPaneTreeRows';
import type { SftpFileEntry } from '../../types';
import { installDomEnvironment } from '../test-support/renderReactDom';
const files: SftpFileEntry[] = Array.from({ length: 5000 }, (_, i) => ({
name: `file-${i}.txt`, type: 'file', size: 1, lastModified: 0,
sizeFormatted: '1 B', lastModifiedFormatted: '--',
}));
test('file windows stay bounded on the first commit, hidden tabs and measured scrolling', async (t) => {
const dom = installDomEnvironment();
const previousObserver = globalThis.ResizeObserver;
let resize = () => {};
globalThis.ResizeObserver = class implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { resize = () => callback([], this); }
observe() {} unobserve() {} disconnect() {}
};
let nextFrame = 0;
const frames = new Map<number, FrameRequestCallback>();
dom.window.requestAnimationFrame = callback => { frames.set(++nextFrame, callback); return nextFrame; };
dom.window.cancelAnimationFrame = id => { frames.delete(id); };
const flushFrames = async () => {
const batch = [...frames.values()]; frames.clear();
await act(async () => { batch.forEach(callback => callback(0)); });
};
const node = { clientHeight: 600, scrollTop: 0, querySelector: () => ({ getBoundingClientRect: () => ({ height: measuredHeight }) }) };
let measuredHeight = 44;
let result!: ReturnType<typeof useSftpPaneVirtualList>;
const commits: number[] = [];
function Fixture({ active, entries = files, density = 'comfortable' }: { active: boolean; entries?: SftpFileEntry[]; density?: string }) {
result = useSftpPaneVirtualList({ isActive: active, sortedDisplayFiles: entries, layoutKey: density });
useLayoutEffect(() => { commits.push(result.visibleRows.length); });
return React.createElement('div', { ref: result.fileListRef });
}
let renderer!: ReactTestRenderer;
t.after(async () => {
await act(async () => { renderer?.unmount(); });
globalThis.ResizeObserver = previousObserver;
dom.cleanup();
});
await act(async () => { renderer = create(React.createElement(Fixture, { active: true }), { createNodeMock: () => node }); });
assert.ok(commits.every(count => count < 100), `initial commits must be bounded: ${commits}`);
await flushFrames();
node.scrollTop = 4999 * result.rowHeight;
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
await flushFrames();
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'the final file remains reachable');
await act(async () => { renderer.update(React.createElement(Fixture, { active: false })); });
assert.ok(result.shouldVirtualize && result.visibleRows.length < 100, 'hiding must retain the file window');
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1));
node.clientHeight = 0;
await act(async () => { resize(); });
await act(async () => { renderer.update(React.createElement(Fixture, { active: true })); });
assert.ok(result.visibleRows.length < 100, 'a zero-size viewport must not mount every file');
assert.equal(result.visibleRows.at(-1)?.entry, files.at(-1), 'restoring retains the last-file window');
const fewerFiles = files.slice(0, 100);
node.clientHeight = 600;
node.scrollTop = fewerFiles.length * result.rowHeight - node.clientHeight;
await act(async () => { renderer.update(React.createElement(Fixture, { active: false, entries: fewerFiles })); });
assert.ok(result.visibleRows.length > 0, 'an inactive listing shrink must not leave an empty window');
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: fewerFiles })); });
assert.equal(result.visibleRows.at(-1)?.entry, fewerFiles.at(-1));
assert.ok(result.visibleRows[0].top <= node.scrollTop, 'restoring reads the browser-clamped scroll position');
assert.ok(result.visibleRows.at(-1)!.top + result.rowHeight >= node.scrollTop + node.clientHeight);
measuredHeight = 24;
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, density: 'compact' })); resize(); });
await flushFrames();
assert.equal(result.rowHeight, 24, 'density is still measured from real rows');
node.scrollTop = 0;
await act(async () => { result.handleFileListScroll({ currentTarget: node } as unknown as React.UIEvent<HTMLDivElement>); });
await flushFrames();
assert.equal(result.visibleRows[0]?.entry, files[0]);
await act(async () => { renderer.update(React.createElement(Fixture, { active: true, entries: files.slice(0, 10) })); });
assert.equal(result.shouldVirtualize, false);
assert.equal(result.visibleRows.length, 10);
});
test('tree windows stay bounded before measurement and when the viewport becomes hidden', async (t) => {
const dom = installDomEnvironment();
let result!: ReturnType<typeof useSftpPaneTreeRows>;
const descriptors = files.map(entry => ({ type: 'node', entry, entryPath: `/fixture/${entry.name}`, depth: 0 }));
function Fixture({ height, top = 0, count = files.length }: { height: number; top?: number; count?: number }) {
result = useSftpPaneTreeRows({ nodeDescriptors: descriptors.slice(0, count), viewportHeight: height, scrollTop: top, selectedPaths: new Set(), tRef: { current: (key: string) => key } });
return null;
}
let renderer!: ReactTestRenderer;
t.after(async () => { await act(async () => { renderer?.unmount(); }); dom.cleanup(); });
await act(async () => { renderer = create(React.createElement(Fixture, { height: 0 })); });
assert.ok(result.visibleRange.virtualized && result.treeRows.length < 100, 'initial tree commit must be bounded');
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28 })); });
assert.equal(result.visibleRange.end, 4999);
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28 })); });
assert.ok(result.treeRows.length < 100, 'zero height must not mount every tree row');
assert.equal(result.visibleRange.end, 4999);
await act(async () => { renderer.update(React.createElement(Fixture, { height: 0, top: 4999 * 28, count: 100 })); });
assert.ok(result.treeRows.length > 0, 'a hidden tree refresh must retain rows after the listing shrinks');
assert.equal(result.visibleRange.end, 99);
await act(async () => { renderer.update(React.createElement(Fixture, { height: 600, top: 4999 * 28, count: 100 })); });
assert.ok(result.treeRows.length > 0);
assert.equal(result.visibleRange.end, 99);
});
@@ -24,7 +24,8 @@ export const useSftpPaneVirtualList = ({
layoutKey,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
// Bound the first commit too; the mounted rows refine this estimate below.
const [rowHeight, setRowHeight] = useState(44);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
@@ -32,7 +33,14 @@ export const useSftpPaneVirtualList = ({
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || !enabled) return;
const update = () => setViewportHeight(container.clientHeight);
const update = () => {
const height = container.clientHeight;
setViewportHeight(height);
// Scroll events are ignored while parked; the browser may still clamp
// its position when a refreshed directory becomes shorter. Hidden
// elements report zero geometry, so preserve their last visible offset.
if (height > 0) setScrollTop(container.scrollTop);
};
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
@@ -82,18 +90,23 @@ export const useSftpPaneVirtualList = ({
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
// Hidden/unmeasured panes still need a window, rather than all file rows.
const canVirtualize = enabled && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
: 0;
const windowTop = Math.max(0, Math.min(
scrollTop,
totalHeightLocal - viewportHeight,
));
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
? Math.max(0, Math.floor(windowTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedDisplayFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
Math.ceil((windowTop + viewportHeight) / rowHeight) + overscan,
)
: sortedDisplayFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
@@ -115,7 +128,7 @@ export const useSftpPaneVirtualList = ({
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
}, [enabled, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,
+5 -3
View File
@@ -15,15 +15,17 @@ export function useSftpPaneTreeRows(props: SftpPaneTreeRowsProps) {
const { totalHeight, visibleRange } = useMemo(() => {
const totalCount = nodeDescriptors.length;
const total = totalCount * TREE_ROW_HEIGHT;
const shouldVirtualize = viewportHeight > 0 && totalCount > 50;
// A zero viewport also occurs on first mount and while parked/hidden.
const shouldVirtualize = totalCount > 50;
if (!shouldVirtualize) {
return { totalHeight: 0, visibleRange: { start: 0, end: totalCount - 1, virtualized: false } };
}
const overscan = 6;
const start = Math.max(0, Math.floor(scrollTop / TREE_ROW_HEIGHT) - overscan);
const end = Math.min(totalCount - 1, Math.ceil((scrollTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
const windowTop = Math.max(0, Math.min(scrollTop, total - viewportHeight));
const start = Math.max(0, Math.floor(windowTop / TREE_ROW_HEIGHT) - overscan);
const end = Math.min(totalCount - 1, Math.ceil((windowTop + viewportHeight) / TREE_ROW_HEIGHT) + overscan);
return { totalHeight: total, visibleRange: { start, end, virtualized: true } };
}, [nodeDescriptors.length, scrollTop, viewportHeight]);
+55
View File
@@ -123,6 +123,8 @@ interface DropdownContentProps {
alignToParent?: boolean;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
/** Opt in for action menus whose options need keyboard focus and navigation. */
keyboardNavigation?: boolean;
}
const DropdownContent: React.FC<DropdownContentProps> = ({
@@ -134,9 +136,11 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
alignToParent = false,
onMouseEnter,
onMouseLeave,
keyboardNavigation = false,
}) => {
const { open, setOpen, triggerRef } = useDropdown();
const contentRef = useRef<HTMLDivElement>(null);
const didFocusOnOpen = useRef(false);
const [position, setPosition] = useState<{
top: number;
left: number;
@@ -248,6 +252,17 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
}
}, [open, calculatePosition]);
useLayoutEffect(() => {
if (!open) {
didFocusOnOpen.current = false;
return;
}
if (keyboardNavigation && position && !didFocusOnOpen.current) {
contentRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
didFocusOnOpen.current = true;
}
}, [open, position, keyboardNavigation]);
// Close on click outside
useEffect(() => {
if (!open) return;
@@ -293,6 +308,46 @@ const DropdownContent: React.FC<DropdownContentProps> = ({
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
role={keyboardNavigation ? 'menu' : undefined}
onClickCapture={keyboardNavigation ? (event) => {
if (event.target instanceof Element && event.target.closest('button:not(:disabled)')) {
triggerRef.current?.focus();
}
} : undefined}
onKeyDown={keyboardNavigation ? (event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
let nextIndex: number;
switch (event.key) {
case 'ArrowDown':
nextIndex = (index + 1) % items.length;
break;
case 'ArrowUp':
nextIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = items.length - 1;
break;
case 'Escape':
case 'Tab':
setOpen(false);
triggerRef.current?.focus();
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
return;
default:
return;
}
event.preventDefault();
items[nextIndex]?.focus();
} : undefined}
style={{
top: position?.top ?? -9999,
left: position?.left ?? -9999,
@@ -0,0 +1,63 @@
# Chat history search responsiveness
The search introduced in #3601 ran its entire bounded-content scan inside the
history drawer render. The index cache limits retained memory, but histories
larger than that cache still rebuilt older indexes on each keystroke. A changing
streamed conversation also refreshed the session list and repeated that scan.
Search now runs in scheduled slices of at most 16 sessions or approximately
4 ms. The existing per-session text limits, matching rules, index-memory bounds
and session order remain unchanged. A query change cancels the previous scan;
streaming updates keep match decisions for unchanged sessions and recheck the
replacement objects, so ongoing output cannot repeatedly restart the search.
The drawer shows a searching status and clears obsolete-query results until the
current query finishes. While the session list changes, only still-present,
unchanged matched objects stay visible, in the current list order; removed or
replaced objects cannot remain selectable until the next slice. Clearing the
query restores the unfiltered history
immediately and releases the search's previous results.
## Verification
A separate Electron window mounted the actual before/after history drawer with
500 synthetic conversations, each containing about 64,000 retained characters.
No user conversations, terminals or provider calls were used. The same input
and ten updates to a single conversation produced:
| Work | Before | After |
| --- | ---: | ---: |
| Input handler return | 352.7 ms | 4.0 ms |
| Single conversation update | 319.4–323.3 ms | 1.49–1.90 ms |
| Final results for the fixture query | 1 matching conversation | Same conversation |
The old input/update tasks each blocked for over 300 ms; the repaired search
produced no task of 50 ms or longer in this run. These are development-fixture
measurements on one machine, not a guarantee for every device or history size.
Native input, empty results and the matching conversation were also checked in
the rendered window.
The drawer regression drives the actual input event and verifies that a queued
event-loop heartbeat runs before any retained message is indexed. It fails on
the previous implementation (720 synchronous message reads) and passes after
the repair. Further tests cover obsolete queries, live replacements/removals,
continuous-stream progress, result order and the existing matching limits.
```sh
node --test --import tsx \
components/AIChatSessionHistoryDrawer.test.tsx \
components/AIChatSessionHistoryDrawer.responsiveness.test.tsx \
application/state/sessionHistorySearch.test.ts
```
## Pending-result freshness
A follow-up review found that the first repair could temporarily show results
from the previous session snapshot. A second Electron comparison used the
initial repair (`a8b291b7c`) and the reconciled version. Removing or replacing the
matching session left it selectable in the initial repair's first commit; the
reconciled version immediately removed it. Reversing the current list initially
kept session 0 first; it now immediately shows session 499 first. Unchanged
confirmed matches remain visible while changed objects are checked, so a stream
does not blank every result. This check compares object references and ordering
without rereading retained message text. Input remained about 4 ms for the same
500-conversation fixture.
@@ -0,0 +1,95 @@
# SFTP browser window performance
## Finding
The production file-list hook rendered every file until both row and viewport
measurements arrived. It also disabled its window when a pane became inactive.
The production tree rendered every descriptor whenever its viewport height was
zero, including the first commit and a hidden tree's ResizeObserver update.
Thus opening or leaving a large directory could mount thousands of file rows.
Both conditions are already present in v1.1.83; this is an existing bottleneck
found during the post-release performance audit, not a regression attributed to
the newer compact-list UI.
An isolated Electron renderer using the actual list/sorting/files hooks, list
component, tree component and application CSS reproduced:
| Files | Initial list mount | Hide list | Hidden list rows |
| --- | --- | --- | --- |
| 1,000 | 293 ms | 129 ms | 1,001 |
| 5,000 | 1,309 ms | 696 ms | 5,001 |
The 5,000-file tree initially took 604 ms and mounted 5,001 rows again when
hidden. The extra row is the normal parent-directory entry. These are controlled
macOS measurements, not the original Windows environment from #3155.
## Repair and regression gate
Large lists retain their window while inactive or unmeasured. A conservative
initial file-row height permits a bounded first commit; the existing real-row
measurement still corrects it for either density. Tree rows already have a fixed
height, so a zero viewport simply keeps the overscan window. Small directories
and the complete file/selection data remain unchanged.
`components/sftp/SftpPaneVirtualization.test.ts` failed on both original hooks
with 5,000 initial rows, then passed after the repair. It checks first commits,
inactive/zero-height panes, the last file, density remeasurement, small lists and
tree visibility. The separate native script exercises the actual rendered
components and records timings without machine-dependent time assertions.
## Durable native acceptance
Run `node_modules/.bin/electron scripts/sftp-browser-window-performance.live.test.cjs`.
For the red control, also set `NETCATTY_SFTP_WINDOW_BASELINE=1` and
`NETCATTY_SFTP_WINDOW_BASELINE_REF=b073162eafd39ce2a337c9acbf36da2eb7456f63`.
The control overrides only the two hooks, keeping the same inputs and current
components/CSS. It completes the interaction checks, then fails with
`SFTP_FILE_WINDOW_UNBOUNDED` (maximum 5,003 mounted rows). The repaired version
passes, retaining at most 39 rows across every recorded commit. Initially hidden
list/tree panes retain at most 22/32 rows; the small directory keeps all 22 rows.
In this Vite development-renderer fixture, initial list mount was 2,975 -> 61ms,
first hide 1,950 -> 11ms, restore 148 -> 12ms, and initial tree 715 -> 18ms.
These timings are distinct from the earlier bundled-renderer probe above.
The deterministic gate is bounded mounted rows, rather than a hardware-dependent
time threshold. Actual application CSS row heights are asserted at 44/24px.
The native script scrolls to the final file with Electron wheel events, selects
and opens files, expands a tree directory and opens its child, toggles density,
and hides/restores both views after selecting the final file. Selection and
position persist (one CSS-pixel boundary tolerance). It uses its own sandboxed
BrowserWindow/profile, dedicated port 5379 and private Vite cache, allows only
its local fixture requests, and closes its listener/profile afterward. Results
and window pixels are kept in Netcatty's dedicated temporary directory. This is
programmatic native-window input; it does not use the user's SSH or unlock the
macOS session, and is not a claim of manual CUA acceptance while macOS was locked.
## Review follow-up: shorter background listings
Review found that a retained scroll offset could outlive a shorter listing.
The browser clamps its real scroll position, but inactive-list scroll events
are ignored; the calculated start could exceed the final file and render an
empty slice. The same range issue affects tree descriptors. Clamp both windows
to the available content, and synchronize the measured scroll position when
positive viewport geometry returns. Preserve the last visible offset while
geometry is zero.
Both hook tests failed at the exact shrink scenario before this follow-up.
The native control loading all three original product files from `fa5e79c85`
also failed: list 5,000 -> 100 files and tree 100 -> 60 each recorded zero rows
during the hidden update and first restore commit. On this macOS run, later
browser scroll events automatically recovered; a permanent blank pane was not
observed. The repaired run retains 21 list rows and 6 -> 31 tree rows across
those commits, without another user scroll. It asserts nonempty bounded rows,
the last file's visibility and each rendered row's position against the actual
browser-clamped scroll offset. The previous complete native interactions also
remain passing. To replay this narrower red control, use the same baseline
environment variables with `NETCATTY_SFTP_WINDOW_BASELINE_REF=fa5e79c85`.
181 related SFTP checks, full lint (zero errors, eight existing warnings) and
production build pass locally. Full-project typechecking still reports existing
errors; there are no diagnostics in the changed product/test/fixture files.
The directory-window problem is independent of the expanded transfer-center
problem fixed in #3722. Related to #3155 only; neither observation establishes
the cause of the original Windows report, so this PR does not close it.
@@ -0,0 +1,96 @@
# Expanded SFTP folder-list responsiveness
The global transfer center already limited mounted top-level rows, but expanding
a folder rendered every retained child file. Each progress update also rebuilt
all folder groups and sorted their children. A folder with thousands of queued
files therefore stalled the renderer even when only one file's bytes changed.
The child list now mounts its visible range with a small overscan. Its existing
scroll area still reaches every retained file. The transfer center reads a stable
hierarchy snapshot between lifecycle changes; mounted parent/child rows subscribe
to their own live progress. Progress that introduces a row or changes its status
still refreshes the hierarchy. Completion retention and compact checkpoints are
unchanged.
## Controlled measurements
A separate Electron window mounted the actual before/after transfer center with
two synthetic directory transfers. No SSH connection, real file or user transfer
was involved. The same expand action, ten updates to one child and a scroll to
the final file were exercised in each version.
| Children | Work | Before | After |
| ---: | --- | ---: | ---: |
| 1,000 | Expand to next rendered frame | 298.8 ms | 10.94 ms |
| 1,000 | One child's progress to next frame | 112.6–125.6 ms | 1.18–18.18 ms |
| 5,000 | Expand to next rendered frame | 1,216.1 ms | 14.36 ms |
| 5,000 | One child's progress to next frame | 500.5–538.4 ms | 1.31–6.03 ms |
| 1,000 / 5,000 | Mounted child rows | 1,000 / 5,000 | 12 / 12 |
Both repaired lists reached their final child and retained all 1,000 / 5,000
queued records. Native scrolling, collapse/reopen and live byte display were
also checked. These are development-fixture measurements on one machine, not a
cross-device frame-rate guarantee.
The actual-component regression fails on the original implementation because
it mounts 1,000 children. It verifies bounded mounted rows, live progress without
re-sorting the folder, final-child access, completion accounting and collapse /
reopen. The existing top-level recycling test also expands a folder, scrolls it
out of view and returns to its retained expansion state.
## Issue relationship
Related to #3155. Its Windows report and subsequent comments cover multiple
possible large-transfer failures; they do not establish that this exact expanded
folder-list workload caused the reporter's freeze. This repair is independently
reproduced UI work and must not close that issue. Open #3694 and #3696 modify
file metadata / transfer connection acquisition, not this list behavior.
## Control availability follow-up
Independent review found that the initial stable snapshot did not refresh when
progress reported a transfer could no longer pause, including a child's change
propagated to its folder. Metadata-only patches and a newly available failed
transfer checkpoint could similarly leave batch buttons stale. The list now
refreshes its lightweight snapshot only when grouping/order/control eligibility
fields change; repeated identical capability metadata on byte ticks keeps the
hierarchy stable.
The actual-component regression first failed with Pause all incorrectly enabled
after the backend reported the folder could not pause. It now covers that child /
parent update, direct metadata changes, repeated unchanged metadata without a
full sort, and Resume all becoming available when a failed task gains a checkpoint.
An Electron comparison against initial repair 1b96e27b0 confirmed stale batch
buttons before and correct disabled/enabled states afterward. The final 1,000 /
5,000 file runs still mounted 12 rows and reached the final file: expand-to-frame
12.6 / 12.86 ms, child progress 1.32–15.42 / 1.06–17.90 ms.
## Hierarchy repair follow-up
A same-byte background event can repair a formerly top-level child before the
progress de-duplication guard returns. The repaired hierarchy must be published
even when the remaining event is ignored, including late events for terminal
rows. Those early exits now publish only when repair actually replaced the row;
normal progress still builds at most one hierarchy snapshot and ordinary byte
ticks keep its reference stable.
Both the store and actual-center regressions failed before this repair. The
Electron comparison against e894c9cb confirmed the old folder had no Show files
action after the same-byte event or its next byte tick. The repaired folder
immediately exposes its child, and the next byte tick keeps that grouping
without rebuilding it. A fresh 5,000-file run mounted 12 rows, reached the final
file and retained all 5,000 records: expansion 14.12 ms, progress 1.28-18.58 ms.
The repaired hierarchy also uses the existing coalesced persistence scheduler
at the structural mutation, so an ignored terminal event cannot leave the old
orphan on disk indefinitely. A restart regression first failed with zero writes
for the repair; completed, failed and cancelled cases now each save the change
once, restore under the folder (completed children through the existing compact
checkpoint), and schedule no further writes on unchanged late progress. Owner
lifecycle notifications stay at zero. An isolated actual Electron renderer
compared 744f0f8e against the repair and confirmed all three old orphan restores
and all three repaired restores. This run used synthetic in-memory persistence;
manual window input was unavailable because macOS was locked. Earlier native
folder scrolling and appearance evidence remains separate.
@@ -0,0 +1,31 @@
# Vault host search performance
Issue #3720 covers a large-library input stall already present in v1.1.83. It is separate from the snippet argument-completion repair in #3717. The recent IP sort is not the cause: A–Z sorting reproduces the same delay.
## Cause and scope
`getHostSearchMatch` evaluates matching/ranking against host label, group, tags and hostname. After literal and compact comparisons fail, human fields may also use pinyin. Printable ASCII fields cannot gain another match from that fallback, but repeated queries still performed its conversion/cache operations for thousands of hosts.
The repair only skips that redundant host-field fallback. Literal and compact matching remain ahead of it; source preference, phases, scores, Chinese/mixed-language matching, IP isolation, usernames/notes fallback, sorting and rendered fields are unchanged. The generic search and snippet matchers are not modified.
## Reproduction and verification
```sh
node_modules/.bin/electron scripts/vault-host-search-performance.live.test.cjs
```
The isolated Electron fixture renders production `useVaultHostCollections` and `VirtualizedHostCollection` with 5,000 synthetic hosts across 100 groups. It changes the actual controlled input through React `onChange`, repeats 63 input events over three rounds, measures render/input-task delay and compares result counts/order fingerprints. Only 15 host rows are mounted. It does not access user hosts, SSH servers or settings.
The real input regression was observed failing before the source change: warmed `Region 99` events delayed the next input turn by 100.2 and 89.6 ms. After the repair they took 24.2 and 23.2 ms. The standalone measurement uses a 50 ms warmed-input gate; slower test machines can set `NETCATTY_VAULT_SEARCH_MAX_INPUT_MS` explicitly. It is skipped under ordinary Node tests, where real renderer timings are unavailable.
| Query | Before render task | After render task | Matches |
| --- | --- | --- | --- |
| `production web` | 59.4–62.8 ms | 17.8–21.2 ms | 5,000 |
| `Region 99` | 89.1–99.5 ms | 20.2–23.6 ms | 95 |
| `no-match-query` | 36.4–90.4 ms | 13.8–17.1 ms | 0 |
All 63 before/after input events retained the same result count and complete-order fingerprint. `Region 99` returning 95 hosts is existing multi-segment matching behavior; this repair does not reinterpret it as a group-only filter.
Separate four-round diagnosis compared current source, v1.1.83 source and A–Z/IP sorting. Release source reproduced 56.4–59.2 ms for `production web` and 90.5–101.4 ms for `Region 99`, confirming an existing bottleneck rather than a newly introduced regression. These are representative local renderer measurements, not a guarantee for every host library or device.
Semantic tests cover ASCII separator compaction and punctuation, fullwidth normalization, direct IP matches and cross-field isolation, strict source preference, Chinese/mixed-language pinyin, and complete matching phase/method/score details. They also pass against the original implementation.
+47
View File
@@ -276,6 +276,53 @@ test("pinyin cache stays hard-bounded by retained characters, not just entries",
resetPinyinCacheForTests();
});
test("host ASCII fallback preserves compact matches and their complete ranking details", () => {
const separators = ["-", "_", " ", "/", "\\", "|", ".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "<", ">", '"', "'", "`", "~"];
for (const separator of separators) {
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
matched: true, phase: "loose", score: 100525,
details: [{ segment: "prefixsuffix", field: "label", method: "compact", phase: "loose" }],
}, JSON.stringify(separator));
}
for (const separator of ["+", "&", "@", "=", "%", "$", "#", "^"]) {
assert.deepEqual(getHostSearchMatch("prefixsuffix", { label: `prefix${separator}suffix` }), {
matched: false, phase: "none", score: 0, details: [],
}, JSON.stringify(separator));
}
assert.deepEqual(getHostSearchMatch("PRODAPI", { label: "PROD-API" }), {
matched: true, phase: "loose", score: 100525,
details: [{ segment: "prodapi", field: "label", method: "compact", phase: "loose" }],
});
});
test("host ASCII bypass preserves strict source selection, IP isolation, and pinyin scoring", () => {
const host = { label: "Production API-01", group: "Region 99", hostname: "10.6.1.88", tags: ["production", "web", "核心"] };
assert.deepEqual(getHostSearchMatch("production web", host), {
matched: true, phase: "strict", score: 1000820,
details: [
{ segment: "production", field: "label", method: "literal", phase: "strict" },
{ segment: "web", field: "tag", method: "literal", phase: "strict" },
],
});
assert.deepEqual(getHostSearchMatch("10.6.1.88", host), {
matched: true, phase: "strict", score: 1000430,
details: [{ segment: "10.6.1.88", field: "hostname", method: "literal", phase: "strict" }],
});
for (const query of ["61", "production 10.6"]) {
assert.deepEqual(getHostSearchMatch(query, host), { matched: false, phase: "none", score: 0, details: [] });
}
for (const [query, method, score] of [["hexin", "pinyinFull", 100232], ["hx", "pinyinInitials", 100228]] as const) {
assert.deepEqual(getHostSearchMatch(query, host), {
matched: true, phase: "loose", score,
details: [{ segment: query, field: "tag", method, phase: "loose" }],
});
}
assert.deepEqual(getHostSearchMatch("apibu", { label: "API部署" }), {
matched: true, phase: "loose", score: 100512,
details: [{ segment: "apibu", field: "label", method: "pinyinFull", phase: "loose" }],
});
});
test("word prefixes retain literal, normalized, and non-ASCII matching", () => {
assert.equal(matchesWordPrefixQuery("", "deploy"), true);
+9 -1
View File
@@ -167,10 +167,18 @@ module.exports = {
'!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
// sqlite3 rebuilds a native module for Electron; its upstream source
// tarball is build-time payload only.
'!node_modules/sqlite3/deps/**/*'
'!node_modules/sqlite3/deps/**/*',
// Native compilation helpers are needed during install/rebuild only.
// Match nested copies too; keep the actual native loaders and binaries.
'!node_modules/**/node-gyp/**/*',
'!node_modules/**/node-addon-api/**/*',
'!node_modules/**/nan/**/*'
],
asarUnpack: [
'node_modules/node-pty/**/*',
// afterPack removes prebuilds for other platforms/architectures. Keep
// these outside ASAR so removing them also removes their payload.
'node_modules/@serialport/bindings-cpp/prebuilds/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*',
'node_modules/@vscode/windows-process-tree/**/*',
+7 -10
View File
@@ -254,17 +254,14 @@ function startPtyJob(ptyStream, command, options) {
startupTimeoutMs: maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs,
});
// Deliver the interrupt as terminal data (ETX). This matches manual Ctrl+C
// in the terminal and terminalBridge.interruptSession: the remote line
// discipline turns \x03 into SIGINT. Never send an SSH "signal" channel
// request here — real OpenSSH rejects it ("session_signal_req: session
// signalling requires privilege separation"), which pollutes auth logs and
// makes other host-side tooling report brute-force noise (#3577).
function sendInterrupt(trigger = null) {
let signalSent = false;
let etxWritten = false;
try {
if (typeof ptyStream.signal === "function") {
ptyStream.signal("INT");
signalSent = true;
}
} catch {
// Ignore signal failures and fall back to ETX.
}
try {
if (typeof ptyStream.write === "function") {
ptyStream.write("\x03");
@@ -275,7 +272,7 @@ function startPtyJob(ptyStream, command, options) {
}
jobDiag.emit("send-interrupt", {
trigger,
signalSent,
signalSent: false,
etxWritten,
session: diagSnapshot(),
});
+31
View File
@@ -153,6 +153,37 @@ test("foreground PTY cancellation returns only a bounded tail", async () => {
assert.equal(result.outputTruncated, true);
});
test("PTY cancellation delivers Ctrl+C as terminal data without a channel signal request", async () => {
const calls = [];
class SignalSpyPty extends EventEmitter {
signal(name) {
calls.push(["signal", name]);
}
write(data) {
if (data === "\x03") calls.push(["write", data]);
const marker = markerFromWrite(data);
if (!marker || this.started) return;
this.started = true;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\n`));
});
}
}
const pty = new SignalSpyPty();
const job = startPtyJob(pty, "cancel-signal-request", {
shellKind: "posix",
timeoutMs: 1_000,
expectedPrompt: "$ ",
});
await new Promise((resolve) => setImmediate(resolve));
job.cancel();
pty.emit("data", Buffer.from("$ "));
const result = await job.resultPromise;
assert.equal(result.error, "Cancelled");
assert.deepEqual(calls, [["write", "\x03"]]);
});
test("background PTY jobs preserve output that has no trailing newline", async () => {
const job = startPtyJob(new ShellBackedPty(), "printf 'abc'", {
shellKind: "posix",
@@ -35,11 +35,24 @@ async function capture(file, selector) {
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
}
async function click(selector) {
// Electron requires native focus before sendInputEvent can deliver a click.
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus for click');
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
assert.ok(rect, `missing ${selector}`);
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(resolve))');
for (const type of ['mouseDown', 'mouseUp']) owner.webContents.sendInputEvent({ type, button: 'left', clickCount: 1, x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function pressEscape() {
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus for Escape');
await run('new Promise(resolve => requestAnimationFrame(resolve))');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
owner.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function input(selector, value) {
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
@@ -93,6 +106,65 @@ async function main() {
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
const page = root.children[0].webContents;
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
// A visible but stationary browser must not spend every frame reading the
// whole renderer. Settle startup/FontFaceSet work before observing idle time.
await wait(300);
const assertIdleLayout = async () => {
await run(`(() => {
const query=document.querySelector.bind(document),queryAll=document.querySelectorAll.bind(document),style=getComputedStyle;
window.layoutReads={queries:0,styles:0};
document.querySelector=(...args)=>{window.layoutReads.queries++;return query(...args)};
document.querySelectorAll=(...args)=>{window.layoutReads.queries++;return queryAll(...args)};
window.getComputedStyle=(...args)=>{window.layoutReads.styles++;return style(...args)};
window.restoreLayoutReads=()=>{document.querySelector=query;document.querySelectorAll=queryAll;window.getComputedStyle=style;};
})()`);
await wait(250);
const reads=await run('window.restoreLayoutReads();window.layoutReads');
assert.deepEqual(reads,{queries:0,styles:0},'idle browser leaves no repeated overlay/style scans');
};
await assertIdleLayout();
// Position-only CSS motion does not fire ResizeObserver on the viewport.
// Follow its real animation frames, then release the frame loop again.
await run(`(() => {const parent=document.querySelector('[data-section=host-browser-root]').parentElement;parent.id='moving-browser-fixture';parent.style.transition='transform 300ms linear';parent.style.transform='translateX(32px)';})()`);
const positions=[];
for(let index=0;index<15;index++){await wait(25);positions.push(root.getBounds().x);}
assert.ok(new Set(positions).size>=4,'the native browser follows intermediate transform positions');
const expectedX=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().x)');
assert.equal(root.getBounds().x,expectedX,'native browser reaches the final CSS position');
await assertIdleLayout();
await run('document.getElementById("moving-browser-fixture").style.transform="none"');await wait(350);
// Exercise continuous pointer-driven size updates through the same hook and
// native view. No application session or real SSH host is involved.
await run(`(() => {
const handle=document.createElement('div');handle.id='browser-drag-fixture';handle.style.cssText='position:fixed;left:2px;top:100px;width:8px;height:200px;z-index:99999';
const parent=document.getElementById('moving-browser-fixture');
handle.onmousedown=()=>{const move=event=>{parent.style.width=Math.max(300,event.clientX-parent.getBoundingClientRect().x)+'px'};const up=()=>{window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up)};window.addEventListener('mousemove',move);window.addEventListener('mouseup',up)};
document.body.append(handle);
})()`);
owner.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:6,y:150});
for(const x of [850,750,650,550]){owner.webContents.sendInputEvent({type:'mouseMove',x,y:150});await wait(30);}
owner.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:550,y:150});await wait(80);
const expectedWidth=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().width)');
assert.equal(root.getBounds().width,expectedWidth,'drag-driven viewport width reaches the native browser');
assert.ok(expectedWidth<600,'the pointer drag actually resized the fixture');
await run('document.getElementById("moving-browser-fixture").style.width="";document.getElementById("browser-drag-fixture").remove()');await wait(100);
await run('document.documentElement.style.colorScheme="light";document.documentElement.style.setProperty("--background","40 70% 90%")');
await until(()=>manager.getPanelState(event,browserId).theme,'light','theme reaches native browser without a frame poll');
await run('document.documentElement.style.colorScheme="dark";document.documentElement.style.setProperty("--background","210 25% 18%")');
await until(()=>manager.getPanelState(event,browserId).theme,'dark','theme restores');
await run(`document.body.insertAdjacentHTML('beforeend','<div id="modal-layout-fixture" role="dialog" aria-modal="true" style="position:fixed;left:0;top:0;width:1px;height:1px"></div>')`);
await until(()=>owner.contentView.children.includes(root),false,'modal beside the viewport still covers native content');
await run('document.getElementById("modal-layout-fixture").remove()');
await until(()=>owner.contentView.children.includes(root),true,'closing modal restores native content');
await run(`document.body.insertAdjacentHTML('beforeend','<div id="lock-layout-fixture" data-app-lock-overlay></div>')`);
await until(()=>owner.contentView.children.includes(root),false,'app lock hides native content');
await run('document.getElementById("lock-layout-fixture").remove()');
await until(()=>owner.contentView.children.includes(root),true,'unlock restores native content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"),'Unsaved browser input');
for (let index = 0; index < 2; index++) {
app.focus({ steal: true });
owner.focus(); owner.webContents.focus();
@@ -110,7 +182,7 @@ async function main() {
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
await click(`#choice-${index}-0`);
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
@@ -204,7 +276,7 @@ async function main() {
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
await click('[aria-label="下载"]');
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
const activePage = root.children[0].webContents;
await activePage.executeJavaScript("document.getElementById('download').click()");
@@ -218,15 +290,15 @@ async function main() {
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await click('[title="在文件夹中显示"]');
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
await click('[aria-label="打开下载文件夹"]');
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
activePage.downloadURL(`${url}slow.bin`);
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
@@ -237,7 +309,7 @@ async function main() {
await click('[aria-label="下载"]');
await click('[aria-label="取消下载"]');
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
@@ -246,7 +318,7 @@ async function main() {
await capture(pdfProgressScreenshot);
await click('[aria-label="取消下载"]');
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await pressEscape();
pendingPdf = undefined;
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
@@ -258,11 +330,19 @@ async function main() {
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
await capture(downloadScreenshot);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
// Close the list through its own trigger before testing independent
// navigation focus. A prior Escape may leave the baseline list open.
await click('[aria-label="下载"]');
await until(() => run('!!document.querySelector("[data-radix-popper-content-wrapper] > [role=dialog]")'), false, 'download list is closed before starting navigation');
await input('[aria-label="输入网址"]', `${url}slow-page`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
await click('[aria-label="新标签页"]');
assert.equal(activePage.isLoading(), true, 'the response remains unfinished while switching tabs');
// Exercise the native page shortcut while its response remains unfinished.
// The new-tab button is covered above with the same real mouse input helper.
activePage.focus();
activePage.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
activePage.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
await input('[aria-label="输入网址"]', 'keep my address draft');
pendingNavigation.end();
@@ -289,7 +369,7 @@ async function main() {
const screenshot = path.join(userData, 'unified-browser.png');
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
assert.deepEqual(errors, [], 'no renderer errors');
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
console.log(JSON.stringify({ ok: true, rendererChrome: true, idleLayout: true, animatedLayout: true, dragLayout: true, themeLayout: true, modalAndLockLayout: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
}
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
clearTimeout(watchdog);
+3
View File
@@ -454,6 +454,9 @@ function matchSegmentAgainstSource(
}
if (!source.allowPinyin || !/[a-z]/i.test(segment)) return null;
// Literal and compact checks already cover printable ASCII host fields.
// Keep their ranking above; skip only redundant transliteration/cache work.
if (/^[\x20-\x7E]+$/.test(source.text)) return null;
const { full, initials } = getPinyinVariants(source.text);
if (full && full.includes(compactSegment)) {
+455 -53
View File
File diff suppressed because it is too large. Load diff
+48 -48
View File
@@ -76,23 +76,41 @@
"test:xterm-keyword-highlight-throughput": "electron scripts/xterm-keyword-highlight-throughput.live.test.cjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.956.0",
"@mimo-ai/sdk": "^0.1.15",
"@modelcontextprotocol/sdk": "^1.29.0",
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@openai/codex-sdk": "0.144.3",
"@smithy/node-http-handler": "^4.4.9",
"@withfig/autocomplete": "^2.692.3",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"electron-updater": "^6.8.3",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"iconv-lite": "^0.6.3",
"node-pty": "1.1.0",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "4.0.8",
"@aws-sdk/client-s3": "^3.956.0",
"@eslint-community/regexpp": "4.12.2",
"@eslint/js": "^9.39.1",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/mona-sans": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@mdxeditor/editor": "^4.0.4",
"@mimo-ai/sdk": "^0.1.15",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@noble/hashes": "^2.2.0",
"@openai/codex-sdk": "0.144.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
@@ -102,11 +120,16 @@
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@smithy/node-http-handler": "^4.4.9",
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.1.0",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-virtual": "^3.14.8",
"@withfig/autocomplete": "^2.692.3",
"@types/node": "^25.0.0",
"@types/react-test-renderer": "^19.1.0",
"@types/turndown": "^5.0.6",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@xterm/addon-fit": "0.12.0-beta.292",
"@xterm/addon-image": "0.10.0-beta.292",
"@xterm/addon-ligatures": "0.11.0-beta.292",
@@ -117,46 +140,7 @@
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"ai": "^7.0.2",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"clsx": "2.1.1",
"electron-updater": "^6.8.3",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"iconv-lite": "^0.6.3",
"katex": "^0.18.4",
"lucide-react": "0.560.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm-strikethrough": "^2.1.0",
"monaco-editor": "^0.55.1",
"node-pty": "1.1.0",
"pinyin-pro": "^3.28.1",
"re2js": "2.8.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
"streamdown": "^2.4.0",
"tailwind-merge": "3.4.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-stick-to-bottom": "^1.1.3",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^25.0.0",
"@types/react-test-renderer": "^19.1.0",
"@types/turndown": "^5.0.6",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.11.12",
@@ -167,13 +151,29 @@
"eslint-plugin-unused-imports": "^4.3.0",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"katex": "^0.18.4",
"lucide-react": "0.560.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm-strikethrough": "^2.1.0",
"monaco-editor": "^0.55.1",
"patch-package": "^8.0.0",
"pinyin-pro": "^3.28.1",
"re2js": "2.8.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-test-renderer": "^19.2.4",
"sass": "^1.103.1",
"simple-icons": "^16.23.0",
"streamdown": "^2.4.0",
"tailwind-merge": "3.4.0",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"typescript": "~5.9.3",
"use-stick-to-bottom": "^1.1.3",
"vite": "^7.3.2",
"wait-on": "^9.0.3"
},
+40
View File
@@ -294,6 +294,40 @@ function pruneCursorSdkPlatformPackages(context) {
return removed;
}
function pruneNativePrebuilds(context) {
const platform = context.electronPlatformName;
const arch = typeof context.arch === "number" ? ELECTRON_BUILDER_ARCH_NAMES[context.arch] : context.arch;
if (!["darwin", "linux", "win32"].includes(platform)) return [];
const architectures = platform === "darwin" && arch === "universal"
? ["arm64", "x64"]
: [arch === "armv7l" ? "arm" : arch];
if (!architectures.every((value) => ["arm", "arm64", "ia32", "x64"].includes(value))) return [];
const resourcesDir = appResourcesDir(context);
const unpackedDir = path.join(resourcesDir, "app.asar.unpacked");
const removed = [];
for (const packageName of ["node-pty", "@serialport/bindings-cpp"]) {
const prebuilds = `node_modules/${packageName}/prebuilds`;
const directory = path.join(unpackedDir, prebuilds);
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const tuple = /^([a-z0-9]+)-([a-z0-9+]+)$/.exec(entry.name);
if (!tuple) continue;
const [, buildPlatform, buildArchitectures] = tuple;
if (buildPlatform === platform && architectures.some((value) => buildArchitectures.split("+").includes(value))) continue;
removed.push(`${prebuilds}/${entry.name}`);
}
}
if (removed.length === 0) return [];
pruneAsarHeaderEntries(path.join(resourcesDir, "app.asar"), removed);
for (const entry of removed) {
fs.rmSync(path.join(unpackedDir, entry), { recursive: true, force: true });
}
return removed;
}
/** @param {import('electron-builder').AfterPackContext} context */
async function afterPack(context) {
const removedCursorPackages = pruneCursorSdkPlatformPackages(context);
@@ -303,6 +337,11 @@ async function afterPack(context) {
);
}
const removedPrebuilds = pruneNativePrebuilds(context);
if (removedPrebuilds.length > 0) {
console.log(`[afterPack] Removed unused native prebuild(s): ${removedPrebuilds.join(", ")}`);
}
if (context.electronPlatformName === "win32") {
const projectDir = context.packager.appDir || context.packager.projectDir;
rebuildPatchedNodePty({ projectDir, platform: "win32", arch: context.arch });
@@ -365,3 +404,4 @@ module.exports.adHocSignAppBundle = adHocSignAppBundle;
module.exports.readAsarHeader = readAsarHeader;
module.exports.pruneAsarHeaderEntries = pruneAsarHeaderEntries;
module.exports.pruneCursorSdkPlatformPackages = pruneCursorSdkPlatformPackages;
module.exports.pruneNativePrebuilds = pruneNativePrebuilds;
+70
View File
@@ -7,6 +7,7 @@ const {
patchMachOBuffer,
pruneAsarHeaderEntries,
pruneCursorSdkPlatformPackages,
pruneNativePrebuilds,
readAsarHeader,
} = require("./afterPackMacUuid.cjs");
@@ -92,6 +93,75 @@ test("deriveUuid is deterministic and 16 bytes", () => {
assert.ok(a.equals(b));
});
for (const [platform, arch, keep] of [
["darwin", 3, ["darwin-arm64", "darwin-x64+arm64"]],
["darwin", 1, ["darwin-x64", "darwin-x64+arm64"]],
["darwin", 4, ["darwin-arm64", "darwin-x64", "darwin-x64+arm64"]],
["linux", 3, ["linux-arm64"]],
["linux", 1, ["linux-x64"]],
["linux", 2, ["linux-arm"]],
["win32", 1, ["win32-x64"]],
["win32", 3, ["win32-arm64"]],
["win32", 0, ["win32-ia32"]],
["darwin", "unknown-arch", null],
["darwin", 99, null],
["unknown-platform", 1, null],
]) {
test(`native prebuild pruning preserves the ${platform}/${arch} runtime and ASAR payload`, (t) => {
const fs = require("node:fs");
const path = require("node:path");
const { getTempFilePath } = require("../electron/bridges/tempDirBridge.cjs");
const tempDir = getTempFilePath("native-prebuild-pruning");
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const resources = platform === "darwin"
? path.join(tempDir, "Netcatty.app", "Contents", "Resources")
: path.join(tempDir, "resources");
const unpacked = path.join(resources, "app.asar.unpacked");
const payload = Buffer.from("preserved-packed-payload");
const header = { files: { "packed.txt": { size: payload.length, offset: "0" } } };
const tuples = [
"darwin-arm64", "darwin-x64", "darwin-x64+arm64", "linux-arm64", "linux-x64",
"linux-arm", "win32-x64", "win32-arm64", "win32-ia32", "android-arm64", "custom",
];
const paths = [];
for (const packageName of ["node-pty", "@serialport/bindings-cpp"]) {
for (const directory of [...tuples.map((tuple) => `prebuilds/${tuple}`), "build/Release"]) {
const relative = `node_modules/${packageName}/${directory}/runtime.node`;
const file = path.join(unpacked, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "native-runtime");
let node = header;
const segments = relative.split("/");
for (const segment of segments.slice(0, -1)) {
node.files ??= {};
node = node.files[segment] ??= { files: {} };
}
node.files[segments.at(-1)] = { size: 14, unpacked: true };
paths.push(relative);
}
}
const asarPath = path.join(resources, "app.asar");
const originalHeaderSize = writeFakeAsar(asarPath, header, payload);
const context = {
electronPlatformName: platform, arch, appOutDir: tempDir,
packager: { appInfo: { productFilename: "Netcatty" } },
};
const removed = pruneNativePrebuilds(context);
const updated = readAsarHeader(asarPath);
assert.equal(updated.headerSize, originalHeaderSize);
assert.deepEqual(fs.readFileSync(asarPath).subarray(8 + originalHeaderSize), payload);
for (const relative of paths) {
const tuple = relative.split("/prebuilds/")[1]?.split("/")[0];
const retained = keep === null || !tuple || tuple === "custom" || keep.includes(tuple);
assert.equal(fs.existsSync(path.join(unpacked, relative)), retained, relative);
const entry = relative.split("/").reduce((node, segment) => node?.files?.[segment], updated.header);
assert.equal(Boolean(entry), retained, `ASAR ${relative}`);
}
if (keep === null) assert.deepEqual(removed, []);
assert.deepEqual(pruneNativePrebuilds(context), []);
});
}
test("deriveUuid differs per appId and sets version/variant bits", () => {
const a = deriveUuid("com.netcatty.app");
const b = deriveUuid("com.example.other");
@@ -0,0 +1,64 @@
import React, { Profiler, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { SftpPaneFileList } from '../../components/sftp/SftpPaneFileList';
import { SftpPaneTreeView } from '../../components/sftp/SftpPaneTreeView';
import { useSftpPaneVirtualList } from '../../components/sftp/hooks/useSftpPaneVirtualList';
import { useSftpPaneFiles } from '../../components/sftp/hooks/useSftpPaneFiles';
import { useSftpPaneSorting } from '../../application/state/sftp/useSftpPaneSorting';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import { TooltipProvider } from '../../components/ui/tooltip';
import type { SftpFileEntry } from '../../types';
import type { SftpPane } from '../../application/state/sftp/types';
import type { SftpListDensity } from '../../domain/sftpListDensity';
const query = new URLSearchParams(location.search);
const count = Number(query.get('count') || 5000);
const initiallyHidden = query.has('hidden');
const initiallyTree = query.has('tree');
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1024, sizeFormatted: '1 KB', lastModified: 0, lastModifiedFormatted: '--' });
const files = [{ ...file('folder'), type: 'directory' as const }, ...Array.from({ length: count }, (_, index) => file(`file-${String(index).padStart(6, '0')}.txt`))];
const noop = () => {};
const pane: SftpPane = { id: 'fixture', connection: { id: 'remote', hostId: 'fixture', hostLabel: 'Fixture', isLocal: false, status: 'connected', currentPath: '/fixture' }, files, loading: false, reconnecting: false, error: null, connectionLogs: [], selectedFiles: new Set(), filter: '', filenameEncoding: 'auto', showHiddenFiles: true, transferMutationToken: 0 };
const result = { count, commits: [] as { phase: string; rows: number }[], timings: [] as { phase: string; ms: number }[], selected: '', opened: [] as string[], loads: [] as string[] };
let phase = `${initiallyTree ? 'tree' : 'list'}:first`;
const rows = () => document.querySelectorAll('[data-sftp-row], [data-section="terminal-sftp-tree-row"]').length;
const record = () => result.commits.push({ phase, rows: rows() });
const measure = (name: string, action: () => void) => { phase = name; const started = performance.now(); flushSync(action); result.timings.push({ phase, ms: performance.now() - started }); };
function List({ active, density, selected, select, entries }: { entries: SftpFileEntry[]; active: boolean; density: SftpListDensity; selected: Set<string>; select: (name: string) => void }) {
const sorting = useSftpPaneSorting();
const { sortedDisplayFiles } = useSftpPaneFiles({ files: entries, filter: '', connection: pane.connection, showHiddenFiles: true, enableListView: true, ...sorting });
const virtual = useSftpPaneVirtualList({ isActive: active, sortedDisplayFiles, layoutKey: density });
return <SftpPaneFileList t={key => key} pane={{ ...pane, files: entries, selectedFiles: selected }} side="left" isPaneFocused={active} sorting={sorting} sortedDisplayFiles={sortedDisplayFiles} listDensity={density} {...virtual}
isDragOverPane={false} draggedFiles={null} dragOverEntry={null} onRefresh={noop} onNavigateTo={noop} onClearSelection={() => select('')}
setShowNewFolderDialog={noop} setShowNewFileDialog={noop} getNextUntitledName={() => ''} setNewFileName={noop} setFileNameError={noop}
handleRowSelect={entry => select(entry.name)} handleRowOpen={entry => result.opened.push(entry.name)} handleFileDragStart={noop} onDragEnd={noop}
handleEntryDragOver={noop} handleRowDragLeave={noop} handleEntryDrop={noop} onCopyToOtherPane={noop} onMoveEntriesToPath={async () => {}} openRenameDialog={noop} openDeleteConfirm={noop} />;
}
function Tree({ entries }: { entries: SftpFileEntry[] }) {
const sorting = useSftpPaneSorting();
return <SftpPaneTreeView pane={{ ...pane, files: entries }} side="left" sorting={sorting} draggedFiles={null} reloadRequest={{ token: 0 }}
onPrepareSelection={noop} onLoadChildren={async path => { result.loads.push(path); return [file('nested-child.txt')]; }} onMoveEntriesToPath={async () => {}}
onNavigateUp={noop} onNavigateTo={noop} onRefresh={noop} onOpenEntry={entry => result.opened.push(entry.name)} onDragStart={noop} onDragEnd={noop}
openRenameDialog={noop} openDeleteConfirm={noop} onCopyToOtherPane={noop} onReceiveFromOtherPane={noop} openNewFolderDialog={noop} openNewFileDialog={noop} />;
}
function App() {
const [active, setActive] = useState(!initiallyHidden);
const [tree, setTree] = useState(initiallyTree);
const [density, setDensity] = useState<SftpListDensity>('comfortable');
const [entries, setEntries] = useState(files);
const [selected, setSelected] = useState(new Set<string>());
const select = (name: string) => { result.selected = name; setSelected(new Set(name ? [name] : [])); };
return <I18nProvider locale="en"><TooltipProvider><div className="flex h-full w-full flex-col bg-background text-foreground">
<div className="flex shrink-0 gap-4 p-2"><button id="visibility" onClick={() => measure(`${tree ? 'tree' : 'list'}:${active ? 'hide' : 'restore'}`, () => setActive(!active))}>Hide / restore</button>
<button id="view" onClick={() => measure('tree:first', () => { setTree(!tree); setActive(true); })}>List / tree</button>
<button id="density" onClick={() => measure('list:density', () => setDensity(density === 'comfortable' ? 'compact' : 'comfortable'))}>Density</button>
<button id="shrink" onClick={() => measure(`${tree ? 'tree' : 'list'}:shrink-hidden`, () => setEntries(files.slice(0, tree ? 61 : 101)))}>Refresh smaller listing</button></div>
<div id="pane" className="min-h-0 flex-1 flex-col" style={{ display: active ? 'flex' : 'none' }}><Profiler id="pane" onRender={record}>
{tree ? <Tree entries={entries} /> : <List entries={entries} active={active} density={density} selected={selected} select={select} />}
</Profiler></div>
</div></TooltipProvider></I18nProvider>;
}
const root = createRoot(document.getElementById('root')!);
measure(phase, () => root.render(<App />));
Object.assign(window, { sftpWindowAudit: result, sftpWindowRows: rows });
@@ -0,0 +1,90 @@
import React, { useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { useVaultHostCollections } from "../../components/vault/useVaultHostCollections";
import { VirtualizedHostCollection } from "../../components/vault/VirtualizedHostCollection";
import type { GroupConfig, Host, KnownHost } from "../../types";
const hosts: Host[] = Array.from({ length: 5000 }, (_, index) => ({
id: `host-${index}`,
label: `Production Host ${index}`,
hostname: `10.${(index >> 16) % 256}.${(index >> 8) % 256}.${index % 256}`,
username: "fixture",
notes: "Deployment host",
tags: ["production", "web"],
group: `Region ${index % 100}`,
port: 22,
os: "linux",
createdAt: 1,
}));
const groups = Array.from({ length: 100 }, (_, index) => `Region ${index}`);
const groupConfigs: GroupConfig[] = [];
const knownHosts: KnownHost[] = [];
const selectedTags: string[] = [];
const noop = () => {};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let current: ReturnType<typeof useVaultHostCollections>;
function VaultSearchFixture() {
const [search, setSearch] = useState("");
current = useVaultHostCollections({
hosts, customGroups: groups, groupConfigs, knownHosts,
onConvertKnownHost: noop, onUpdateHosts: noop, onUpdateKnownHosts: noop,
search, selectedGroupPath: null, selectedTags,
showOnlyUngroupedHostsInRoot: false, showRecentHosts: false,
sortMode: "ip", viewMode: "list",
});
const scrollRef = useRef<HTMLDivElement>(null);
return <>
<label>Search hosts <input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<output>{search}</output>
<div ref={scrollRef} style={{ height: 650, width: 1200, overflow: "auto" }}>
<VirtualizedHostCollection
items={current.displayedHosts}
itemKey={(host) => host.id}
renderItem={(host) => <button data-host-id={host.id}>{host.label}</button>}
scrollRef={scrollRef}
viewMode="list"
/>
</div>
</>;
}
export async function runVaultSearchPerformance() {
const rootNode = document.getElementById("root");
if (!rootNode) throw new Error("Missing fixture root");
const root = createRoot(rootNode);
flushSync(() => root.render(<VaultSearchFixture />));
await wait(100);
const input = rootNode.querySelector("input");
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!input || !setter) throw new Error("Missing search input");
const rows = [];
const queries = ["", "P", "Pr", "Pro", "Prod", "production", "production w", "production we", "production web", "", "R", "Re", "Reg", "Region", "Region 9", "Region 99", "", "no", "no-m", "no-match", "no-match-query"];
for (let round = 0; round < 3; round++) {
for (const query of queries) {
let resolveInput!: (ms: number) => void;
const started = performance.now();
const nextInputTurn = new Promise<number>((resolve) => { resolveInput = resolve; });
setTimeout(() => resolveInput(performance.now() - started), 0);
flushSync(() => {
setter.call(input, query);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const renderMs = performance.now() - started;
const inputDelayMs = await nextInputTurn;
const ids = current.displayedHosts.map((host) => host.id).join(",");
// A compact fingerprint lets before/after runs compare complete order,
// without recording thousands of host IDs for every input event.
let orderHash = 0;
for (const char of ids) orderHash = ((orderHash << 5) - orderHash + char.charCodeAt(0)) | 0;
rows.push({ round, query, renderMs, inputDelayMs, orderHash,
matches: current.displayedHosts.length,
actualInput: rootNode.querySelector("output")?.textContent,
mountedHosts: rootNode.querySelectorAll("[data-host-id]").length });
await wait(30);
}
}
root.unmount();
return rows;
}
@@ -0,0 +1,176 @@
"use strict";
/* global process, __dirname, console, setTimeout, clearTimeout */
// Run with Electron. NETCATTY_SFTP_WINDOW_BASELINE=1 loads the list/tree virtualization sources from Git
// with NETCATTY_SFTP_WINDOW_BASELINE_REF=<unfixed commit> for the red control.
// All fixture inputs stay identical; no implicit HEAD baseline changes after commit.
if (!process.versions.electron) {
require('node:test')('large SFTP browser windows stay bounded', { skip: 'run with Electron for native layout and input' }, () => {});
} else {
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { app, BrowserWindow } = require('electron');
const temp = require('../electron/bridges/tempDirBridge.cjs');
const root = path.resolve(__dirname, '..');
const profile = fs.mkdtempSync(`${temp.getTempFilePath('sftp-window-performance')}-`);
const artifacts = fs.mkdtempSync(`${temp.getTempFilePath('sftp-window-results')}-`);
app.setPath('userData', profile);
app.on('window-all-closed', () => {});
let window, vite;
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const run = code => window.webContents.executeJavaScript(code).catch(error => { throw new Error(`${error.message}\nWhile executing: ${code}`); });
async function until(code, label) {
for (let i = 0; i < 200; i++) { if (await run(code)) return; await wait(25); }
throw new Error(label);
}
async function click(selector, double = false) {
app.focus({ steal: true }); window.focus(); window.webContents.focus();
const point = await run(`(() => {const e=document.querySelector(${JSON.stringify(selector)});const r=e?.getBoundingClientRect();return r&&{x:Math.round(r.x+Math.min(150,r.width/2)),y:Math.round(r.y+r.height/2)}})()`);
assert.ok(point, `click target ${selector}`);
assert.ok(point.y > 0 && point.y < 800, 'row is inside the viewport');
window.webContents.sendInputEvent({ type: 'mouseMove', ...point });
await wait(20);
for (const clickCount of double ? [1, 2] : [1]) {
for (const type of ['mouseDown', 'mouseUp']) window.webContents.sendInputEvent({ type, button: 'left', clickCount, ...point });
}
await run('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))');
}
async function hideRestoreSelectedLast() {
await click('#visibility'); await wait(100);
await click('#visibility'); await wait(100);
await until(`(() => {const e=document.querySelector('[data-entry-name="file-004999.txt"]');const r=e?.getBoundingClientRect();return e?.dataset.selected==='true'&&r.height>0&&r.top>=-1&&r.bottom<=innerHeight+1})()`, 'last selected row remains visible after hide/restore');
}
async function scrollToEnd(selector) {
const point = await run(`(() => {const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`);
window.webContents.sendInputEvent({ type: 'mouseMove', ...point });
// Chromium caps each injected wheel to approximately one viewport. Repeat
// native wheel inputs instead of treating a huge delta as a jump-to-end.
for (let i = 0; i < 450; i++) {
const done = await run(`(() => {const e=document.querySelector(${JSON.stringify(selector)});return e.scrollTop+e.clientHeight>=e.scrollHeight-1})()`);
if (done) { await wait(50); return; }
window.webContents.sendInputEvent({ type: 'mouseWheel', deltaX: 0, deltaY: -1000000, canScroll: true, ...point });
await wait(5);
}
throw new Error('native wheel did not reach the end');
}
const cleanup = async code => { window?.destroy(); await vite?.close(); fs.rmSync(profile, { recursive: true, force: true }); app.exit(code); };
const watchdog = setTimeout(() => { console.error('SFTP_WINDOW_TIMEOUT'); void cleanup(1); }, 120000);
void app.whenReady().then(async () => {
const { createServer } = await import('vite');
const baseline = process.env.NETCATTY_SFTP_WINDOW_BASELINE === '1';
const baselineRef = process.env.NETCATTY_SFTP_WINDOW_BASELINE_REF;
if (baseline) assert.ok(baselineRef, 'baseline control requires NETCATTY_SFTP_WINDOW_BASELINE_REF=<unfixed commit>');
const paths = ['components/sftp/hooks/useSftpPaneVirtualList.ts', 'components/sftp/useSftpPaneTreeRows.tsx', 'components/sftp/SftpPaneTreeView.tsx'];
const originals = new Map(baseline ? paths.map(file => [path.join(root, file), execFileSync('git', ['show', `${baselineRef}:${file}`], { cwd: root, encoding: 'utf8' })]) : []);
const tailwind = (await import('@tailwindcss/vite')).default;
vite = await createServer({ configFile: false, root, cacheDir: path.join(profile, 'vite-cache'), resolve: { alias: { '@': root } }, optimizeDeps: { entries: ['scripts/fixtures/sftp-browser-window-performance.tsx'] }, logLevel: 'error', clearScreen: false, server: { host: '127.0.0.1', port: 5379, strictPort: true, hmr: false }, plugins: [tailwind(), { name: 'sftp-window-audit', enforce: 'pre',
load(id) { return originals.get(id.split('?')[0]); },
configureServer(server) { server.middlewares.use('/__sftp_window_fixture', async (_req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><html><head><meta charset="utf-8"><title>SFTP Window Performance Fixture</title><link rel="stylesheet" href="/index.css"><style>html,body,#root{width:100%;height:100%;margin:0;overflow:hidden}</style></head><body><div id="root"></div><script type="module" src="/scripts/fixtures/sftp-browser-window-performance.tsx"></script></body></html>');
}); },
}] });
await vite.listen();
const origin = `http://127.0.0.1:${vite.httpServer.address().port}`;
window = new BrowserWindow({ show: true, width: 1280, height: 800, title: 'SFTP Window Performance Fixture', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false } });
const errors = [];
window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message); });
window.webContents.session.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !details.url.startsWith(origin) && !details.url.startsWith('data:') }));
await window.loadURL(`${origin}/__sftp_window_fixture`);
await until('window.sftpWindowAudit && document.querySelector("[data-sftp-row]")', 'list mounts');
await wait(120);
assert.equal(await run('Math.round(document.querySelector("[data-sftp-row]").getBoundingClientRect().height)'), 44, 'real comfortable-row CSS loaded');
await click('#visibility'); await wait(60);
await click('#visibility'); await wait(60);
await scrollToEnd('[data-section=terminal-sftp-list]');
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'list scroll reaches final file');
await click('[data-entry-name="file-004999.txt"]');
await until(`document.querySelector('[data-entry-name="file-004999.txt"]')?.dataset.selected==="true"`, 'last file selection');
await click('[data-entry-name="file-004999.txt"]', true);
await until('sftpWindowAudit.opened.includes("file-004999.txt")', 'last file double-click opens');
await click('#density'); await wait(100);
assert.equal(await run('document.querySelector("[data-sftp-row]").dataset.sftpListDensity'), 'compact');
assert.equal(await run('Math.round(document.querySelector("[data-sftp-row]").getBoundingClientRect().height)'), 24, 'real compact-row CSS loaded');
await scrollToEnd('[data-section=terminal-sftp-list]');
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'compact list reaches final file');
await click('#density'); await wait(100);
await hideRestoreSelectedLast();
await click('#view'); await wait(100);
await click('[data-section="terminal-sftp-tree-row"][data-entry-name="folder"]', true);
await until(`sftpWindowAudit.loads.includes("/fixture/folder") && !!document.querySelector('[data-entry-name="nested-child.txt"]')`, 'directory expands using fixture child loader');
await click('[data-entry-name="nested-child.txt"]', true);
await until('sftpWindowAudit.opened.includes("nested-child.txt")', 'expanded tree child opens');
await click('#visibility'); await wait(100);
await click('#visibility'); await wait(100);
await run(`document.querySelector('[data-section="terminal-sftp-tree-row"]').closest('.overflow-y-auto').id='tree-scroll-fixture'`);
await scrollToEnd('#tree-scroll-fixture');
await until(`!!document.querySelector('[data-entry-name="file-004999.txt"]')`, 'tree scroll reaches final file');
await click('[data-entry-name="file-004999.txt"]');
await until(`document.querySelector('[data-entry-name="file-004999.txt"]')?.dataset.selected==="true"`, 'tree last file selection');
await hideRestoreSelectedLast();
const result = await run('sftpWindowAudit');
result.baseline = baseline;
result.baselineRef = baseline ? baselineRef : null;
result.maxRows = Math.max(...result.commits.map(commit => commit.rows));
const screenshot = await window.webContents.capturePage();
result.additional = [];
for (const scenario of [{ query: '?count=20', small: true }, { query: '?hidden=1' }, { query: '?hidden=1&tree=1' }]) {
await window.loadURL(`${origin}/__sftp_window_fixture${scenario.query}`);
await until('!!window.sftpWindowAudit', 'additional scenario mounts');
await wait(100);
await click('#visibility'); await wait(80);
await click('#visibility'); await wait(80);
const sample = await run('sftpWindowAudit');
result.additional.push({ query: scenario.query, ...sample });
if (scenario.small) assert.ok(sample.commits.every(commit => commit.rows === 22), 'small directory retains all rows');
else assert.ok(await run('document.querySelector("#pane").style.display==="none"'), 'initially hidden pane survives restore and rehide');
}
// Parked panes may receive a smaller refresh while their scroll handlers
// cannot publish the browser-clamped position. Keep every commit nonempty.
result.shrinkWhileHidden = [];
for (const scenario of [
{ view: 'list', query: '?shrink=1', before: 'file-004999.txt', after: 'file-000099.txt', height: 44 },
{ view: 'tree', query: '?count=100&tree=1&shrink=1', before: 'file-000099.txt', after: 'file-000059.txt', height: 28 },
]) {
await window.loadURL(`${origin}/__sftp_window_fixture${scenario.query}`);
await until('!!window.sftpWindowAudit', 'shrink scenario mounts');
await wait(100);
await scrollToEnd('#pane .overflow-y-auto');
await until(`!!document.querySelector('[data-entry-name="${scenario.before}"]')`, 'shrink scenario starts at the final page');
await click('#visibility'); await wait(100);
await click('#shrink'); await wait(100);
await click('#visibility'); await wait(100);
const shrink = await run(`(() => {
const container=document.querySelector('#pane .overflow-y-auto');
const viewport=container.getBoundingClientRect();
const rows=[...container.querySelectorAll('[data-sftp-row],[data-section="terminal-sftp-tree-row"]')].map(element=>({name:element.dataset.entryName,top:element.getBoundingClientRect().top,bottom:element.getBoundingClientRect().bottom,height:element.getBoundingClientRect().height}));
return {scrollTop:container.scrollTop,scrollHeight:container.scrollHeight,clientHeight:container.clientHeight,viewport:viewport.toJSON(),rows,audit:sftpWindowAudit};
})()`);
result.shrinkWhileHidden.push({ view: scenario.view, ...shrink });
console.log('SFTP_WINDOW_SHRINK_RESULT', JSON.stringify({ view: scenario.view, ...shrink }));
fs.writeFileSync(path.join(artifacts, `shrink-${scenario.view}.json`), JSON.stringify(shrink, null, 2));
fs.writeFileSync(path.join(artifacts, `shrink-${scenario.view}.png`), (await window.webContents.capturePage()).toPNG());
assert.ok(shrink.rows.length > 0 && shrink.rows.length <= 100, 'restored smaller listing has a bounded nonempty row window without another scroll');
const last = shrink.rows.find(row => row.name === scenario.after);
assert.ok(last && last.top >= shrink.viewport.top - 1 && last.bottom <= shrink.viewport.bottom + 1, 'the browser-clamped final page shows its last file');
const visible = shrink.rows.filter(row => row.bottom > shrink.viewport.top && row.top < shrink.viewport.bottom);
assert.ok(visible.length > 0, 'restored rows intersect the actual viewport');
for (const row of shrink.rows) {
const index = row.name === '..' ? 0 : row.name === 'folder' ? 1 : Number(row.name.slice(5, 11)) + 2;
assert.ok(Math.abs(row.top - (shrink.viewport.top + index * scenario.height - shrink.scrollTop)) <= 1, 'row positions match the real clamped scroll offset');
}
}
assert.ok(result.shrinkWhileHidden.every(sample => sample.audit.commits.filter(commit => commit.phase.endsWith(':shrink-hidden') || commit.phase.endsWith(':restore')).every(commit => commit.rows > 0 && commit.rows <= 100)), 'hidden refresh and first restore commit keep bounded nonempty list/tree row windows');
console.log('SFTP_WINDOW_PERFORMANCE_RESULT', JSON.stringify(result));
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2));
fs.writeFileSync(path.join(artifacts, 'window.png'), screenshot.toPNG());
console.log('SFTP_WINDOW_ARTIFACTS', artifacts);
assert.deepEqual(errors, [], 'no renderer errors');
for (const phase of ['list:first', 'list:hide', 'list:restore', 'tree:first', 'tree:hide', 'tree:restore']) assert.ok(result.commits.some(commit => commit.phase === phase), `recorded ${phase}`);
assert.ok([result, ...result.additional.filter(sample => !sample.query.includes('count=20'))].every(sample => sample.commits.every(commit => commit.rows <= 100)), `SFTP_FILE_WINDOW_UNBOUNDED: max ${result.maxRows} mounted rows`);
console.log('SFTP_WINDOW_PERFORMANCE_OK');
}).catch(async error => { process.exitCode = 1; console.error(error); if(window && !window.isDestroyed()) console.error('SFTP_WINDOW_FAILURE_STATE', await run(`(() => {const e=document.querySelector('[data-entry-name="file-004999.txt"]');const r=e?.getBoundingClientRect();const sc=document.querySelector('#tree-scroll-fixture');return {selected:e?.dataset.selected,rect:r?.toJSON(),innerHeight,scroll:sc&&{top:sc.scrollTop,height:sc.clientHeight,max:sc.scrollHeight},rows:document.querySelectorAll('[data-sftp-row],[data-section="terminal-sftp-tree-row"]').length}})()`)); if(window && !window.isDestroyed()) fs.writeFileSync(path.join(artifacts, 'failure.png'), (await window.webContents.capturePage()).toPNG()); console.error('SFTP_WINDOW_ARTIFACTS', artifacts); process.exitCode = 1; }).finally(async () => {
clearTimeout(watchdog); await cleanup(process.exitCode || 0);
});
}
@@ -0,0 +1,57 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("large Vault searches keep input responsive", {
skip: "run with Electron for actual React input and virtualized host rendering",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("vault-host-search-perf")}-`);
const maximumInputMs = Number(process.env.NETCATTY_VAULT_SEARCH_MAX_INPUT_MS || "50");
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let window;
const cleanup = (code) => {
window?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
void app.whenReady().then(async () => {
const bundle = esbuild.buildSync({
entryPoints: [path.join(__dirname, "fixtures/vault-search-performance.tsx")],
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}", "process.env.NODE_ENV": '"production"' },
}).outputFiles[0].text;
window = new BrowserWindow({
show: process.env.NETCATTY_VAULT_SEARCH_SHOW_WINDOW === "1",
width: 1280, height: 800,
webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false },
});
await window.loadURL("data:text/html,<body><div id='root'></div>");
const rows = await window.webContents.executeJavaScript(`(async () => {
const loaded = { exports: {} };
((module, exports) => { ${bundle} })(loaded, loaded.exports);
return loaded.exports.runVaultSearchPerformance();
})()`);
console.log("VAULT_HOST_SEARCH_MEASUREMENTS", JSON.stringify(rows));
assert.ok(rows.every(row => row.actualInput === row.query), "drive actual onChange updates");
assert.ok(rows.every(row => row.mountedHosts <= 20), "retain a bounded rendered list");
const expectedMatches = { "production web": 5000, "Region 99": 95, "no-match-query": 0 };
for (const [query, count] of Object.entries(expectedMatches)) {
const samples = rows.filter(row => row.query === query);
assert.ok(samples.every(row => row.matches === count), query);
assert.equal(new Set(samples.map(row => row.orderHash)).size, 1, "stable matching order");
}
const warmRegion = rows.filter(row => row.round > 0 && row.query === "Region 99");
assert.ok(warmRegion.every(row => row.inputDelayMs < maximumInputMs),
`Vault search input exceeded ${maximumInputMs}ms: ${JSON.stringify(warmRegion)}`);
console.log("VAULT_HOST_SEARCH_PERFORMANCE_OK");
cleanup(0);
}).catch(error => { console.error(error); cleanup(1); });
}