Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
130 lines
5.0 KiB
TypeScript
130 lines
5.0 KiB
TypeScript
import type { TransferTask } from "./models";
|
|
|
|
/** Statuses that still hold or may soon hold the destination path. */
|
|
export const SFTP_PATH_CONFLICT_ACTIVE_STATUSES: ReadonlySet<TransferTask["status"]> = new Set([
|
|
"pending",
|
|
"queued",
|
|
"transferring",
|
|
"pausing",
|
|
"paused",
|
|
]);
|
|
|
|
type DestinationRef = Pick<
|
|
TransferTask,
|
|
"targetPath" | "targetConnectionId" | "targetHostId" | "targetConnectionKey" | "targetHostLabel"
|
|
>;
|
|
|
|
/**
|
|
* Local filesystem destinations share one identity: Save As / downloadToLocal
|
|
* use the `"local"` sentinel, while dual-pane transfers store an ephemeral pane
|
|
* connection id with targetHostLabel "Local". Treat both as the same endpoint so
|
|
* concurrent writers to one absolute path still conflict.
|
|
*/
|
|
export function isLocalTransferDestination(ref: DestinationRef): boolean {
|
|
if (ref.targetConnectionId === "local" || ref.targetConnectionKey === "local") {
|
|
return true;
|
|
}
|
|
// Dual-pane local rows: labeled Local, no remote host/key, pane connection id.
|
|
return ref.targetHostLabel === "Local" && !ref.targetHostId && !ref.targetConnectionKey;
|
|
}
|
|
|
|
/**
|
|
* Stable destination endpoint identity. Prefer connection key / host id so two
|
|
* sessions to the same host still collide; fall back to connection id (covers
|
|
* the local sentinel and single-session remotes). Local FS destinations are
|
|
* normalized so pane id and `"local"` compare equal.
|
|
*/
|
|
export function sameTransferDestinationEndpoint(
|
|
a: DestinationRef,
|
|
b: DestinationRef,
|
|
): boolean {
|
|
if (isLocalTransferDestination(a) && isLocalTransferDestination(b)) {
|
|
return true;
|
|
}
|
|
if (isLocalTransferDestination(a) || isLocalTransferDestination(b)) {
|
|
return false;
|
|
}
|
|
if (a.targetConnectionKey && b.targetConnectionKey) {
|
|
return a.targetConnectionKey === b.targetConnectionKey;
|
|
}
|
|
if (a.targetHostId && b.targetHostId) {
|
|
return a.targetHostId === b.targetHostId;
|
|
}
|
|
return a.targetConnectionId === b.targetConnectionId;
|
|
}
|
|
|
|
/**
|
|
* Canonical destination path for conflict checks. Mirrors
|
|
* `normalizeSftpPathForCompare` so Windows local paths with different casing or
|
|
* separators still collide; POSIX paths stay case-sensitive aside from trailing
|
|
* slash stripping.
|
|
*/
|
|
export function normalizeTransferTargetPathForCompare(path: string): string {
|
|
if (/^[A-Za-z]:/.test(path)) {
|
|
const withBackslashes = path.replace(/\//g, "\\");
|
|
if (/^[A-Za-z]:\\?$/.test(withBackslashes)) {
|
|
return withBackslashes.toLowerCase();
|
|
}
|
|
return withBackslashes.replace(/[\\]+$/, "").toLowerCase();
|
|
}
|
|
if (path === "/") return "/";
|
|
return path.replace(/\/+$/, "");
|
|
}
|
|
|
|
function sameTransferTargetPath(a: string, b: string): boolean {
|
|
return normalizeTransferTargetPathForCompare(a) === normalizeTransferTargetPathForCompare(b);
|
|
}
|
|
|
|
function isTransferTargetPathDescendant(existingPath: string, candidatePath: string): boolean {
|
|
const existing = normalizeTransferTargetPathForCompare(existingPath);
|
|
const candidate = normalizeTransferTargetPathForCompare(candidatePath);
|
|
if (!existing || existing === candidate) return false;
|
|
|
|
const separator = /^[a-z]:/.test(existing) ? "\\" : "/";
|
|
const descendantPrefix = existing.endsWith(separator) ? existing : `${existing}${separator}`;
|
|
return candidate.startsWith(descendantPrefix);
|
|
}
|
|
|
|
/**
|
|
* Find another active transfer that reserves the candidate destination. File
|
|
* tasks reserve their exact path; directory tasks reserve their path and all
|
|
* descendants because recursive transfer children may not exist in the task
|
|
* list yet. Incoming directories also conflict with any active descendant already
|
|
* writing under that tree (the reverse of an existing directory reserving
|
|
* children). Concurrent writers (especially local .part + rename) race and
|
|
* corrupt output; endpoint identity avoids treating identical path strings on
|
|
* different hosts as the same destination.
|
|
*/
|
|
export function findActivePathConflict(
|
|
tasks: readonly TransferTask[],
|
|
candidate: Pick<TransferTask, "id"> & DestinationRef & Pick<Partial<TransferTask>, "isDirectory">,
|
|
): TransferTask | undefined {
|
|
const conflictsAtEndpoint = (task: TransferTask) => (
|
|
task.id !== candidate.id
|
|
&& sameTransferDestinationEndpoint(task, candidate)
|
|
&& SFTP_PATH_CONFLICT_ACTIVE_STATUSES.has(task.status)
|
|
);
|
|
|
|
return tasks.find((task) => (
|
|
conflictsAtEndpoint(task)
|
|
&& sameTransferTargetPath(task.targetPath, candidate.targetPath)
|
|
)) ?? tasks.find((task) => (
|
|
conflictsAtEndpoint(task)
|
|
&& task.isDirectory
|
|
&& isTransferTargetPathDescendant(task.targetPath, candidate.targetPath)
|
|
)) ?? (candidate.isDirectory
|
|
? tasks.find((task) => (
|
|
conflictsAtEndpoint(task)
|
|
&& isTransferTargetPathDescendant(candidate.targetPath, task.targetPath)
|
|
))
|
|
: undefined);
|
|
}
|
|
|
|
export function pathConflictMessage(existing: Pick<TransferTask, "fileName" | "status">): string {
|
|
const label = existing.fileName || "file";
|
|
if (existing.status === "paused") {
|
|
return `Another transfer for "${label}" is paused. Resume or cancel it first.`;
|
|
}
|
|
return `Another transfer for "${label}" is already in progress.`;
|
|
}
|