mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 01:00:54 +00:00
* fix(sftp): instant folder-drop scan UI and drop full-tree pre-scan Restore interleaved folder transfers as the only non-compressed path, remove the folder pre-scan setting, and make local folder drops show a scanning task with live discovered counts via native listLocalTree (with cancel + progress). UI shows "done · found" instead of a fake grand-total percentage while discovery is still growing. * fix(sftp): make folder scan cancellation bridge-safe * fix(sftp): preserve path-only terminal drops * fix(terminal): accept native folder drop paths * fix(sftp): keep scan task cancellable during setup * fix(sftp): preserve scan failure state during setup errors * fix(sftp): keep folder scan task across retries * fix(sftp): cancel scan task immediately * fix(sftp): keep cancellation active through session setup * fix(sftp): settle failed drop upload retry * fix(sftp): preserve cancellation in final scan cleanup * fix(sftp): harden folder scan cancellation and conflicts * fix(sftp): finish Codex follow-ups on folder scan cancel UX - Abort sibling listLocalTree walks when one multi-root scan fails - Prefer ERR_*_CANCELLED over matching "cancel" in error text - Clear scanning phase on settle; only label live rows as Scanning - Omit directory metadata sizes from drop entries used for totals * fix(sftp): stream folder discovery into concurrent uploads Local path-backed folder drops now edge-scan and edge-transfer: listLocalTree streams entry batches while upload workers consume them, so the first files start before a 100k-file walk finishes. Cancel aborts the walk, clears the queue, and stops creating child tasks. Compressed uploads still materialize fully then pack. * fix(sftp): harden progressive folder UX, retry, and size-based uploads Align the SFTP panel with transfer-center progress, keep nested tree batches until tree-end, honor pause during progressive enqueue, add low-cost retry for external drag-drop files, and drop whole-file upload digests for industry size-based resume (download live-log prefix checks unchanged). * fix(sftp): address multi-agent review on progressive upload and retry Gate progressive drops to path-backed folders only, prefer pool sessions on drag-drop retry with in-flight and cancel guards, soft-check upload mtime without fail-open contentVerifiedSeparately, skip multi-root pause HOL, and fail closed when the local tree stream misses tree-end. * fix(sftp): finish multi-root pause HOL and parent rollup guards Race-wait on all latched parents so resuming a non-head folder can proceed, and only roll progressive parents to completed after terminal failed status. * fix(sftp): address Codex progressive conflict and hang findings Add root-level conflict preflight to progressive folder uploads, wake all backpressure waiters, preserve cancel listeners across uploadEntriesDirect, and surface empty-directory mkdir failures.
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { TransferTask } from "../domain/models";
|
|
import {
|
|
buildGlobalTransferProgressDisplay,
|
|
getGlobalTransferBadge,
|
|
getGlobalTransferBatchEligibility,
|
|
getGlobalTransferStatusOverride,
|
|
getGlobalTransferBucket,
|
|
getTasksForGlobalTransferBucket,
|
|
isDirectoryParentTask,
|
|
listChildTasksForParent,
|
|
pickActiveChildSummaries,
|
|
shouldShowCollapsedActiveChildren,
|
|
splitBackgroundTransfers,
|
|
} from "./GlobalSftpTransferCenter";
|
|
|
|
const task = (id: string, status: TransferTask["status"], background = false) => ({
|
|
id,
|
|
fileName: id,
|
|
sourcePath: "/a",
|
|
targetPath: "/b",
|
|
sourceConnectionId: "local",
|
|
targetConnectionId: "remote",
|
|
direction: "upload" as const,
|
|
status,
|
|
totalBytes: 10,
|
|
transferredBytes: 1,
|
|
speed: 1,
|
|
startTime: 1,
|
|
isDirectory: false,
|
|
background,
|
|
});
|
|
|
|
test("global transfer statuses keep paused work separate from items needing attention", () => {
|
|
assert.equal(getGlobalTransferBucket(task("a", "transferring")), "active");
|
|
assert.equal(getGlobalTransferBucket(task("a", "pausing")), "active");
|
|
assert.equal(getGlobalTransferBucket(task("a", "queued")), "queued");
|
|
assert.equal(getGlobalTransferBucket(task("a", "paused")), "paused");
|
|
assert.equal(getGlobalTransferBucket(task("a", "interrupted")), "attention");
|
|
assert.equal(getGlobalTransferBucket(task("a", "attention")), "attention");
|
|
assert.equal(getGlobalTransferBucket(task("a", "failed")), "attention");
|
|
assert.equal(getGlobalTransferBucket(task("a", "completed")), "completed");
|
|
assert.equal(getGlobalTransferBucket(task("a", "cancelled")), "completed");
|
|
});
|
|
|
|
test("batch actions are disabled when no task can accept them", () => {
|
|
assert.deepEqual(getGlobalTransferBatchEligibility([
|
|
task("done", "completed"),
|
|
task("cancelled", "cancelled"),
|
|
]), { pausableCount: 0, resumableCount: 0 });
|
|
|
|
assert.deepEqual(getGlobalTransferBatchEligibility([
|
|
task("running", "transferring"),
|
|
task("waiting", "queued"),
|
|
task("paused", "paused"),
|
|
{ ...task("failed", "failed"), checkpointBytes: 4 },
|
|
]), { pausableCount: 2, resumableCount: 2 });
|
|
});
|
|
|
|
test("paused transfer errors remain visible instead of looking safely paused", () => {
|
|
assert.equal(getGlobalTransferStatusOverride({
|
|
error: "Upload resume is unavailable",
|
|
pauseUnavailableReason: undefined,
|
|
}), "Upload resume is unavailable");
|
|
});
|
|
|
|
test("the all bucket includes every top-level task regardless of status", () => {
|
|
const tasks = [
|
|
task("a", "transferring"),
|
|
task("b", "queued"),
|
|
task("c", "failed"),
|
|
{ ...task("child", "transferring"), parentTaskId: "a" },
|
|
];
|
|
assert.deepEqual(getTasksForGlobalTransferBucket(tasks, "all").map((item) => item.id), ["a", "b", "c"]);
|
|
});
|
|
|
|
test("badge counts active and queued work while surfacing attention", () => {
|
|
assert.deepEqual(getGlobalTransferBadge([
|
|
task("a", "transferring"),
|
|
task("b", "queued"),
|
|
task("c", "failed"),
|
|
]), { count: 2, hasAttention: true });
|
|
});
|
|
|
|
test("badge does not double count child files in a folder transfer", () => {
|
|
assert.deepEqual(getGlobalTransferBadge([
|
|
task("parent", "transferring"),
|
|
{ ...task("child", "transferring"), parentTaskId: "parent" },
|
|
]), { count: 1, hasAttention: false });
|
|
});
|
|
|
|
test("successful background work is collapsed but failures stay visible", () => {
|
|
const split = splitBackgroundTransfers([
|
|
task("a", "completed", true),
|
|
task("b", "failed", true),
|
|
task("c", "completed", false),
|
|
]);
|
|
assert.deepEqual(split.visible.map((item) => item.id), ["b", "c"]);
|
|
assert.deepEqual(split.collapsed.map((item) => item.id), ["a"]);
|
|
});
|
|
|
|
test("directory parent detection matches side-queue file-count semantics", () => {
|
|
assert.equal(isDirectoryParentTask({
|
|
isDirectory: true,
|
|
parentTaskId: undefined,
|
|
progressMode: "files",
|
|
}), true);
|
|
assert.equal(isDirectoryParentTask({
|
|
isDirectory: true,
|
|
parentTaskId: undefined,
|
|
progressMode: undefined,
|
|
}), true);
|
|
assert.equal(isDirectoryParentTask({
|
|
isDirectory: true,
|
|
parentTaskId: "p",
|
|
progressMode: "files",
|
|
}), false);
|
|
assert.equal(isDirectoryParentTask({
|
|
isDirectory: false,
|
|
parentTaskId: undefined,
|
|
progressMode: "bytes",
|
|
}), false);
|
|
assert.equal(isDirectoryParentTask({
|
|
isDirectory: true,
|
|
parentTaskId: undefined,
|
|
progressMode: "bytes",
|
|
}), false);
|
|
});
|
|
|
|
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
|
|
const t = (key: string, params?: Record<string, string | number>) => {
|
|
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
|
return `${params?.completed} done · ${params?.discovered} found`;
|
|
}
|
|
if (key === "sftp.transfers.filesProgress") {
|
|
return `${params?.current}/${params?.total} files`;
|
|
}
|
|
if (key === "sftp.transfers.filesCount") {
|
|
return `${params?.count} files`;
|
|
}
|
|
return key;
|
|
};
|
|
const display = buildGlobalTransferProgressDisplay({
|
|
status: "transferring",
|
|
isDirectory: true,
|
|
progressMode: "files",
|
|
totalBytes: 12,
|
|
transferredBytes: 1,
|
|
speed: 0,
|
|
phase: "transferring",
|
|
}, t);
|
|
assert.equal(display.percent, (1 / 12) * 100);
|
|
assert.equal(display.detail, "1 done · 12 found");
|
|
assert.equal(display.indeterminate, false);
|
|
// Must not look like "1 Bytes / 12 Bytes"
|
|
assert.doesNotMatch(display.detail, /Bytes/i);
|
|
});
|
|
|
|
test("folder parent scanning stays indeterminate while nothing has completed", () => {
|
|
const t = (key: string, params?: Record<string, string | number>) => {
|
|
if (key === "sftp.transfers.filesDiscoveredProgress") {
|
|
return `${params?.completed} done · ${params?.discovered} found`;
|
|
}
|
|
return key;
|
|
};
|
|
const display = buildGlobalTransferProgressDisplay({
|
|
status: "transferring",
|
|
isDirectory: true,
|
|
progressMode: "files",
|
|
totalBytes: 40,
|
|
transferredBytes: 0,
|
|
speed: 0,
|
|
phase: "scanning",
|
|
}, t);
|
|
assert.equal(display.detail, "0 done · 40 found");
|
|
assert.equal(display.indeterminate, true);
|
|
});
|
|
|
|
test("single-file progress still shows byte totals", () => {
|
|
const t = (key: string) => key;
|
|
const display = buildGlobalTransferProgressDisplay({
|
|
status: "transferring",
|
|
isDirectory: false,
|
|
progressMode: "bytes",
|
|
totalBytes: 1024,
|
|
transferredBytes: 512,
|
|
speed: 0,
|
|
}, t);
|
|
assert.equal(display.percent, 50);
|
|
assert.match(display.detail, /512\s*Bytes/);
|
|
assert.match(display.detail, /1(\.00)?\s*KB/);
|
|
});
|
|
|
|
test("active child summary prefers transferring files over queued", () => {
|
|
const children = [
|
|
{ ...task("q1", "queued"), parentTaskId: "p", startTime: 1 },
|
|
{ ...task("t1", "transferring"), parentTaskId: "p", startTime: 2 },
|
|
{ ...task("t2", "transferring"), parentTaskId: "p", startTime: 3 },
|
|
{ ...task("done", "completed"), parentTaskId: "p", startTime: 0 },
|
|
];
|
|
assert.deepEqual(
|
|
pickActiveChildSummaries(children, 2).map((item) => item.id),
|
|
["t1", "t2"],
|
|
);
|
|
});
|
|
|
|
test("collapsed active children only show while transferring or soft-draining", () => {
|
|
assert.equal(shouldShowCollapsedActiveChildren("transferring"), true);
|
|
assert.equal(shouldShowCollapsedActiveChildren("pausing"), true);
|
|
assert.equal(shouldShowCollapsedActiveChildren("paused"), false);
|
|
assert.equal(shouldShowCollapsedActiveChildren("interrupted"), false);
|
|
assert.equal(shouldShowCollapsedActiveChildren("queued"), false);
|
|
});
|
|
|
|
test("listChildTasksForParent skips cancelled siblings", () => {
|
|
const tasks = [
|
|
task("p", "transferring"),
|
|
{ ...task("c1", "transferring"), parentTaskId: "p" },
|
|
{ ...task("c2", "cancelled"), parentTaskId: "p" },
|
|
{ ...task("other", "transferring"), parentTaskId: "x" },
|
|
];
|
|
assert.deepEqual(listChildTasksForParent(tasks, "p").map((item) => item.id), ["c1"]);
|
|
});
|