Files
NetMesh/domain/sftpTransferConflicts.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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.`;
}