[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,397 @@
import { SftpFileEntry, TransferTask } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
};
/**
* Estimate remaining transfer time from bytes left and current speed.
* Returns null when inputs are too small to be meaningful (WinSCP-style:
* only show ETA after a stable speed sample).
*/
export function estimateTransferEtaSeconds(
remainingBytes: number,
speedBytesPerSec: number,
): number | null {
if (!Number.isFinite(remainingBytes) || remainingBytes <= 0) return null;
if (!Number.isFinite(speedBytesPerSec) || speedBytesPerSec < 1024) return null;
const seconds = Math.ceil(remainingBytes / speedBytesPerSec);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
// Cap absurd ETAs (e.g. speed briefly dips).
if (seconds > 48 * 3600) return null;
return seconds;
}
export function formatTransferEta(seconds: number | null | undefined): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null;
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
if (isNaN(date.getTime())) return "--";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
export const getFileExtension = (name: string): string => {
if (name === "..") return "folder";
const ext = name.split(".").pop()?.toLowerCase();
return ext || "file";
};
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
};
export type SftpWindowsPathOptions = {
/**
* When true, treat //host/share as a Windows UNC path.
* Default false so remote/POSIX panes keep // as a POSIX absolute path.
*/
acceptForwardSlashUnc?: boolean;
};
export const getWindowsUncRoot = (
path: string,
options?: SftpWindowsPathOptions,
): string | null => {
// Forward-slash //host/share is ambiguous: UNC on Windows panes,
// POSIX absolute on remote/POSIX panes. A backslash can be a POSIX filename
// character, so require an explicit opt-in for every // path.
if (
!options?.acceptForwardSlashUnc
&& /^\/\//.test(path)
) {
return null;
}
const normalized = path.replace(/\//g, "\\");
const match = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/);
return match ? `\\\\${match[1]}\\${match[2]}` : null;
};
// Check if path is Windows-style, including network-share (UNC) paths.
export const isWindowsPath = (
path: string,
options?: SftpWindowsPathOptions,
): boolean => (
/^[A-Za-z]:/.test(path) || getWindowsUncRoot(path, options) !== null
);
export type NormalizeSftpNavigationPathOptions = SftpWindowsPathOptions & {
/**
* Strip leading/trailing whitespace from the whole path string.
* Default true for path-bar typing/paste; set false for programmatic
* navigate/openEntry so directory names ending in whitespace stay intact.
*/
trimWhitespace?: boolean;
};
/**
* Normalize a path typed/pasted into the SFTP path bar before navigation.
* Preserves Windows drive letters and UNC roots (e.g. \\wsl.localhost\Ubuntu-22.04\...).
* Non-Windows paths keep or gain a leading slash.
* Pass acceptForwardSlashUnc on Windows-style panes so //host/share becomes UNC.
*/
export const normalizeSftpNavigationPath = (
rawPath: string,
options?: NormalizeSftpNavigationPathOptions,
): string => {
const prepared = options?.trimWhitespace === false ? rawPath : rawPath.trim();
const newPath = prepared || "/";
if (isWindowsPath(newPath, options)) {
if (/^[A-Za-z]:[\\/]?$/.test(newPath)) {
return `${newPath.charAt(0).toUpperCase()}:\\`;
}
return newPath.replace(/\//g, "\\");
}
return newPath.startsWith("/") ? newPath : `/${newPath}`;
};
/**
* Derive Windows UNC opt-in from pane context (current/home paths that are
* already unambiguously Windows: drive letter or backslash UNC).
*/
export const resolveSftpWindowsPathOptions = (
...hints: Array<string | null | undefined>
): SftpWindowsPathOptions => {
for (const hint of hints) {
if (hint && isWindowsPath(hint)) {
return { acceptForwardSlashUnc: true };
}
}
return {};
};
/**
* Normalize bookmark / initialPath / navigate ingress using pane Windows context
* so //host/share becomes UNC on Windows panes and stays POSIX elsewhere.
* Does not trim: openEntry / joinPath may produce paths with meaningful trailing
* whitespace in directory names.
*/
export const normalizeSftpPaneNavigationPath = (
rawPath: string,
...contextPaths: Array<string | null | undefined>
): string => normalizeSftpNavigationPath(
rawPath,
{
...resolveSftpWindowsPathOptions(...contextPaths),
trimWhitespace: false,
},
);
export type SftpBreadcrumbSegment = { label: string; path: string };
/**
* Split a current path into breadcrumb segments (label + navigable path).
* UNC roots are kept as a single first segment (\\server\share).
* Pure //host/share paths stay POSIX unless acceptForwardSlashUnc is set.
*/
export const getSftpBreadcrumbSegments = (
path: string,
options?: SftpWindowsPathOptions,
): { segments: SftpBreadcrumbSegment[]; isWindowsDrive: boolean } => {
if (!isWindowsPath(path, options)) {
const parts = path.split("/").filter(Boolean);
const prefix = /^\/\/(?!\/)/.test(path) ? "//" : "/";
return {
segments: parts.map((part, index) => ({
label: part,
path: `${prefix}${parts.slice(0, index + 1).join("/")}`,
})),
isWindowsDrive: false,
};
}
const normalized = path.replace(/\//g, "\\");
const uncRoot = getWindowsUncRoot(normalized, options);
if (uncRoot) {
const rest = normalized.slice(uncRoot.length).replace(/^[\\]+/, "");
const restParts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
return {
segments: [
{ label: uncRoot, path: uncRoot },
...restParts.map((part, index) => ({
label: part,
path: `${uncRoot}\\${restParts.slice(0, index + 1).join("\\")}`,
})),
],
isWindowsDrive: false,
};
}
const parts = normalized.split(/[\\/]/).filter(Boolean);
return {
segments: parts.map((part, index) => {
const built = parts.slice(0, index + 1).join("\\");
return {
label: part,
path: /^[A-Za-z]:$/.test(built) ? `${built}\\` : built,
};
}),
isWindowsDrive: true,
};
};
const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
/**
* Filesystem root for the given path, used by the breadcrumb "go to root" affordance:
* "/" on POSIX panes, the drive root (C:\) or UNC share root on Windows panes.
* Returns null when no root can be derived (e.g. a relative Windows path).
*/
export const getSftpPathRoot = (
path: string,
options?: SftpWindowsPathOptions,
): string | null => {
if (!isWindowsPath(path, options)) return "/";
const normalized = path.replace(/\//g, "\\");
const uncRoot = getWindowsUncRoot(normalized, options);
if (uncRoot) return uncRoot;
const drive = normalized.match(/^[A-Za-z]:/);
return drive ? `${drive[0]}\\` : null;
};
export const isWindowsRoot = (
path: string,
options?: SftpWindowsPathOptions,
): boolean => {
if (!isWindowsPath(path, options)) return false;
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\?$/.test(normalized)) return true;
const uncRoot = getWindowsUncRoot(normalized, options);
return uncRoot !== null && normalized.replace(/[\\]+$/, "") === uncRoot;
};
export const joinPath = (base: string, name: string): string => {
if (isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base.replace(/\/+$/, "")}/${name}`;
};
/**
* Join a discovered directory entry to a transfer target without allowing a
* server-controlled filename to escape the user-selected destination root.
* Forward slashes are the traversal's internal separators; a backslash is a
* literal POSIX filename character but becomes a separator on Windows targets.
*
* Navigation treats pure //host/share as POSIX unless acceptForwardSlashUnc is
* set, but transfer joins still apply Windows relative-path guards to
* UNC-shaped bases so //server/share download roots cannot be escaped.
*/
export function joinTransferTargetPath(
base: string,
relativePath: string,
options?: SftpWindowsPathOptions,
): string {
const unsafe = () => {
throw new Error(`Unsafe transfer path outside the selected destination: ${relativePath}`);
};
if (!relativePath || relativePath.includes("\0")) return unsafe();
if (
relativePath.startsWith("/")
|| relativePath.startsWith("\\")
|| /^[A-Za-z]:[\\/]/.test(relativePath)
) return unsafe();
const parts = relativePath.split("/");
if (parts.some((part) => !part || part === "." || part === "..")) return unsafe();
const windowsBase = isWindowsPath(base, options);
// Ambiguous //host/share stays POSIX for joining, but still needs Windows
// segment guards when it matches a UNC share shape.
const uncShapedBase = getWindowsUncRoot(base, { acceptForwardSlashUnc: true }) !== null;
if (windowsBase || uncShapedBase) {
if (parts.some((part) => (
part.includes("\\")
|| part.includes(":")
|| /[. ]$/.test(part)
))) return unsafe();
}
if (windowsBase) {
// joinPath only recognizes drive / \\UNC without opt-in; normalize
// forward-slash UNC when the caller explicitly opted into Windows UNC.
const joinBase = isWindowsPath(base) ? base : base.replace(/\//g, "\\");
return joinPath(joinBase, parts.join("\\"));
}
return joinPath(base, parts.join("/"));
}
export const getParentPath = (
path: string,
options?: SftpWindowsPathOptions,
): string => {
if (isWindowsPath(path, options)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const uncRoot = getWindowsUncRoot(normalized, options);
if (uncRoot) {
const rest = normalized.slice(uncRoot.length).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) return uncRoot;
parts.pop();
return `${uncRoot}\\${parts.join("\\")}`;
}
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
return result;
}
if (path === "/" || path === "//") {
return "/";
}
const parts = path.split("/").filter(Boolean);
parts.pop();
// Match breadcrumb / path-bar: pure //host/... stays POSIX with a double-slash prefix.
const prefix = /^\/\/(?!\/)/.test(path) ? "//" : "/";
return parts.length ? `${prefix}${parts.join("/")}` : "/";
};
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
const targetPath = task.targetPath.trim();
return targetPath.length > 0 && targetPath !== "(temp)";
};
export const getFileName = (path: string): string => {
const parts = path.split(isWindowsPath(path) ? /[\\/]/ : "/").filter(Boolean);
return parts[parts.length - 1] || "";
};
export const normalizeSftpPathForCompare = (path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (isWindowsPath(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
};
export const isSameSftpPath = (a: string, b: string): boolean => {
return normalizeSftpPathForCompare(a) === normalizeSftpPathForCompare(b);
};
export const isSftpDescendantPath = (candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizeSftpPathForCompare(candidate);
const normalizedParent = normalizeSftpPathForCompare(parent);
if (normalizedCandidate === normalizedParent) return false;
if (/^[a-z]:\\$/.test(normalizedParent)) {
return normalizedCandidate.startsWith(normalizedParent);
}
if (normalizedParent === "/") {
return normalizedCandidate.startsWith("/");
}
const separator = isWindowsPath(normalizedParent) ? "\\" : "/";
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
};
export const shouldClearSftpFilterForPathChange = (
currentPath: string,
nextPath: string,
): boolean => {
return !isSameSftpPath(currentPath, nextPath);
};
export const getSftpFilterAfterPathChange = (
currentPath: string,
nextPath: string,
currentFilter: string,
): string => {
return shouldClearSftpFilterForPathChange(currentPath, nextPath) ? "" : currentFilter;
};
export const getSftpFilterAfterPathChangeError = (
clearFilterForPathChange: boolean,
previousFilter: string,
currentFilter: string,
): string => {
return clearFilterForPathChange ? previousFilter : currentFilter;
};