Compare commits

...
6 changed files with 516 additions and 81 deletions

No files matched your search

@@ -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);
}
@@ -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();
});
@@ -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.