[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
129
domain/sftpTransferConflicts.ts
Normal file
129
domain/sftpTransferConflicts.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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.`;
|
||||
}
|
||||
Reference in New Issue
Block a user