mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
|
|
// Kept free of React/DOM so they can be unit tested directly.
|
|
|
|
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
|
|
|
/** Custom MIME type carried by drags that originate in the local pane. */
|
|
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
|
|
|
|
/**
|
|
* Custom MIME type the remote grid adds to its internal drags so other panes
|
|
* can recognise them during dragenter/dragover (when payloads are unreadable).
|
|
*/
|
|
export const REMOTE_FILES_DRAG_MIME = "application/x-termix-remote-files";
|
|
|
|
export interface LocalFilesDragPayload {
|
|
type: "local_files";
|
|
paths: string[];
|
|
}
|
|
|
|
export interface InternalFilesDragPayload {
|
|
type: "internal_files";
|
|
files: string[];
|
|
}
|
|
|
|
export function serializeLocalFilesDragPayload(paths: string[]): string {
|
|
const payload: LocalFilesDragPayload = { type: "local_files", paths };
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
export function parseLocalFilesDragPayload(
|
|
raw: string | null | undefined,
|
|
): string[] | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<LocalFilesDragPayload>;
|
|
if (parsed?.type !== "local_files" || !Array.isArray(parsed.paths)) {
|
|
return null;
|
|
}
|
|
const paths = parsed.paths.filter(
|
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
);
|
|
return paths.length > 0 ? paths : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Parses the payload the remote grid puts on `text/plain` for internal drags. */
|
|
export function parseInternalFilesDragPayload(
|
|
raw: string | null | undefined,
|
|
): string[] | null {
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<InternalFilesDragPayload>;
|
|
if (parsed?.type !== "internal_files" || !Array.isArray(parsed.files)) {
|
|
return null;
|
|
}
|
|
const files = parsed.files.filter(
|
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
);
|
|
return files.length > 0 ? files : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** True while a drag that started in the local pane is over the element. */
|
|
export function isLocalFilesDrag(
|
|
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
|
|
): boolean {
|
|
if (!dataTransfer) return false;
|
|
return Array.from(dataTransfer.types ?? []).includes(LOCAL_FILES_DRAG_MIME);
|
|
}
|
|
|
|
/**
|
|
* Marks a drag that starts on remote rows. Within the remote grid a drop is a
|
|
* move; onto the local pane it is a copy (a download). The source has to
|
|
* allow both, because Chromium refuses a drop whose dropEffect is not in
|
|
* effectAllowed without firing any event at all -- the drop just silently
|
|
* does nothing.
|
|
*/
|
|
export function beginRemoteFilesDrag(
|
|
dataTransfer: Pick<DataTransfer, "setData" | "effectAllowed">,
|
|
remotePaths: string[],
|
|
): void {
|
|
const payload: InternalFilesDragPayload = {
|
|
type: "internal_files",
|
|
files: remotePaths,
|
|
};
|
|
dataTransfer.setData("text/plain", JSON.stringify(payload));
|
|
// Lets sibling panes recognise this drag before the payload is readable.
|
|
dataTransfer.setData(REMOTE_FILES_DRAG_MIME, "1");
|
|
dataTransfer.effectAllowed = "copyMove";
|
|
}
|
|
|
|
/** True while a drag that started in the remote grid is over the element. */
|
|
export function isRemoteFilesDrag(
|
|
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
|
|
): boolean {
|
|
if (!dataTransfer) return false;
|
|
return Array.from(dataTransfer.types ?? []).includes(REMOTE_FILES_DRAG_MIME);
|
|
}
|
|
|
|
/** Joins a POSIX remote directory with one or more path segments. */
|
|
export function joinRemotePath(base: string, ...segments: string[]): string {
|
|
let out = base || "/";
|
|
for (const segment of segments) {
|
|
const clean = segment.replace(/^\/+|\/+$/g, "");
|
|
if (!clean) continue;
|
|
out = out.endsWith("/") ? `${out}${clean}` : `${out}/${clean}`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Joins a local directory and a file name using the platform separator. */
|
|
export function joinLocalPath(
|
|
base: string,
|
|
name: string,
|
|
separator: string,
|
|
): string {
|
|
const sep = separator || "/";
|
|
const trimmedBase = base.endsWith(sep) ? base.slice(0, -sep.length) : base;
|
|
// Root on POSIX is "/" which trims to ""; keep the separator in that case.
|
|
return `${trimmedBase}${sep}${name}`;
|
|
}
|
|
|
|
/** Thrown when a remote name cannot be used as a local path component. */
|
|
export class UnsafeLocalNameError extends Error {
|
|
readonly code = "EINVAL";
|
|
constructor(
|
|
readonly name: string,
|
|
readonly reason: string,
|
|
) {
|
|
super(`Unsafe file name "${name}": ${reason}`);
|
|
}
|
|
}
|
|
|
|
// Windows refuses these device names in any directory, with any extension.
|
|
const WINDOWS_RESERVED_NAMES =
|
|
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.[^.]*)?$/i;
|
|
|
|
/**
|
|
* Validates one remote path component for use as a single local path
|
|
* segment on the destination platform (`separator` "\\" means Windows).
|
|
*
|
|
* A POSIX file name may legally contain "\\", ":" or end in a dot, all of
|
|
* which Windows treats as separators, drive prefixes or strips — so a remote
|
|
* name like "..\\outside.txt" would otherwise be normalised out of the
|
|
* selected download folder. Anything that is not a plain name is rejected;
|
|
* traversal ("..") and separators are rejected on every platform.
|
|
*/
|
|
export function assertSafeLocalComponent(
|
|
name: string,
|
|
separator: string,
|
|
): string {
|
|
if (name === "" || name === "." || name === "..") {
|
|
throw new UnsafeLocalNameError(name, "empty or traversal component");
|
|
}
|
|
if (name.includes("/") || name.includes("\0")) {
|
|
throw new UnsafeLocalNameError(name, "contains a path separator or NUL");
|
|
}
|
|
if (separator === "\\") {
|
|
if (/[\\:*?"<>|]/.test(name)) {
|
|
throw new UnsafeLocalNameError(
|
|
name,
|
|
"contains a character Windows does not allow in file names",
|
|
);
|
|
}
|
|
if (/[\u0001-\u001f]/.test(name)) {
|
|
throw new UnsafeLocalNameError(name, "contains control characters");
|
|
}
|
|
if (/[. ]$/.test(name)) {
|
|
throw new UnsafeLocalNameError(
|
|
name,
|
|
"Windows strips trailing dots and spaces",
|
|
);
|
|
}
|
|
if (WINDOWS_RESERVED_NAMES.test(name)) {
|
|
throw new UnsafeLocalNameError(name, "reserved device name on Windows");
|
|
}
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function samePathPrefix(a: string, b: string, separator: string): boolean {
|
|
return separator === "\\" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
}
|
|
|
|
/**
|
|
* Builds the local destination for a "/"-separated relative remote path
|
|
* under `localDir`, validating every component for the destination platform
|
|
* and asserting the result stays strictly inside `localDir`.
|
|
*/
|
|
export function buildLocalDestination(
|
|
localDir: string,
|
|
relativePath: string,
|
|
separator: string,
|
|
): string {
|
|
const sep = separator || "/";
|
|
const parts = relativePath.split("/").filter((p) => p !== "");
|
|
if (parts.length === 0) {
|
|
throw new UnsafeLocalNameError(relativePath, "empty path");
|
|
}
|
|
const dest = parts.reduce(
|
|
(acc, part) => joinLocalPath(acc, assertSafeLocalComponent(part, sep), sep),
|
|
localDir,
|
|
);
|
|
const root = localDir.endsWith(sep) ? localDir : `${localDir}${sep}`;
|
|
if (
|
|
!samePathPrefix(dest.slice(0, root.length), root, sep) ||
|
|
dest.length <= root.length
|
|
) {
|
|
throw new UnsafeLocalNameError(
|
|
relativePath,
|
|
"destination escapes the selected folder",
|
|
);
|
|
}
|
|
return dest;
|
|
}
|
|
|
|
export function remoteBaseName(remotePath: string): string {
|
|
const trimmed = remotePath.replace(/\/+$/, "");
|
|
return trimmed.split("/").pop() || trimmed || "/";
|
|
}
|
|
|
|
/**
|
|
* Given the "/"-separated relative paths of files being uploaded (each
|
|
* including its top-level root name) plus any empty directories, returns the
|
|
* set of directories that must exist on the remote, shallowest first, so each
|
|
* parent is created before its children.
|
|
*/
|
|
export function planRemoteDirectories(
|
|
fileRelativePaths: string[],
|
|
emptyDirs: string[] = [],
|
|
): string[] {
|
|
const dirs = new Set<string>();
|
|
|
|
for (const relativePath of fileRelativePaths) {
|
|
const parts = relativePath.split("/").filter(Boolean);
|
|
for (let i = 1; i < parts.length; i++) {
|
|
dirs.add(parts.slice(0, i).join("/"));
|
|
}
|
|
}
|
|
for (const dir of emptyDirs) {
|
|
const parts = dir.split("/").filter(Boolean);
|
|
for (let i = 1; i <= parts.length; i++) {
|
|
dirs.add(parts.slice(0, i).join("/"));
|
|
}
|
|
}
|
|
|
|
return Array.from(dirs).sort(
|
|
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b),
|
|
);
|
|
}
|
|
|
|
/** Remote directory a file with the given relative path should land in. */
|
|
export function remoteDirForRelativePath(
|
|
base: string,
|
|
relativePath: string,
|
|
): string {
|
|
const idx = relativePath.lastIndexOf("/");
|
|
if (idx <= 0) return base;
|
|
return joinRemotePath(base, relativePath.slice(0, idx));
|
|
}
|
|
|
|
/** Short "Kind" column label, in the spirit of Finder / Termius. */
|
|
export function describeLocalKind(
|
|
entry: Pick<LocalFileEntry, "name" | "type">,
|
|
): string {
|
|
if (entry.type === "directory") return "folder";
|
|
if (entry.type === "link") return "link";
|
|
const dot = entry.name.lastIndexOf(".");
|
|
if (dot > 0 && dot < entry.name.length - 1) {
|
|
return entry.name.slice(dot + 1).toLowerCase();
|
|
}
|
|
return "file";
|
|
}
|
|
|
|
export type LocalSortField = "name" | "modified" | "size" | "kind";
|
|
|
|
export function sortLocalEntries(
|
|
entries: LocalFileEntry[],
|
|
field: LocalSortField,
|
|
order: "asc" | "desc",
|
|
): LocalFileEntry[] {
|
|
const dir = order === "asc" ? 1 : -1;
|
|
const collator = new Intl.Collator(undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
return [...entries].sort((a, b) => {
|
|
// Folders always group first, matching the remote grid.
|
|
const aDir = a.type === "directory" ? 0 : 1;
|
|
const bDir = b.type === "directory" ? 0 : 1;
|
|
if (aDir !== bDir) return aDir - bDir;
|
|
|
|
let cmp = 0;
|
|
switch (field) {
|
|
case "modified":
|
|
cmp = (a.modifiedTimestamp ?? 0) - (b.modifiedTimestamp ?? 0);
|
|
break;
|
|
case "size":
|
|
cmp = a.size - b.size;
|
|
break;
|
|
case "kind":
|
|
cmp = collator.compare(describeLocalKind(a), describeLocalKind(b));
|
|
break;
|
|
default:
|
|
cmp = 0;
|
|
}
|
|
if (cmp === 0) cmp = collator.compare(a.name, b.name);
|
|
return cmp * dir;
|
|
});
|
|
}
|
|
|
|
const MONTH_ABBREVIATIONS = [
|
|
"Jan",
|
|
"Feb",
|
|
"Mar",
|
|
"Apr",
|
|
"May",
|
|
"Jun",
|
|
"Jul",
|
|
"Aug",
|
|
"Sep",
|
|
"Oct",
|
|
"Nov",
|
|
"Dec",
|
|
];
|
|
|
|
/**
|
|
* Formats a local mtime the same way the backend formats remote entries
|
|
* (`formatMtime` in src/backend/hosts/file-manager/utils.ts, i.e. `ls -l`
|
|
* style): `Sep 11 16:25` for the last six months, `Sep 11 2025` before
|
|
* that — so both panes read alike.
|
|
*/
|
|
export function formatLocalModified(
|
|
timestamp?: number,
|
|
now: Date = new Date(),
|
|
): string {
|
|
if (!timestamp) return "--";
|
|
const date = new Date(timestamp);
|
|
if (Number.isNaN(date.getTime())) return "--";
|
|
const month = MONTH_ABBREVIATIONS[date.getMonth()];
|
|
const day = date.getDate().toString().padStart(2, " ");
|
|
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
|
if (date > sixMonthsAgo) {
|
|
const hours = date.getHours().toString().padStart(2, "0");
|
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
return `${month} ${day} ${hours}:${minutes}`;
|
|
}
|
|
return `${month} ${day} ${date.getFullYear()}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parallel transfers
|
|
|
|
export const TRANSFER_CONCURRENCY_STORAGE_KEY =
|
|
"termix:file-manager:transfer-concurrency";
|
|
export const DEFAULT_TRANSFER_CONCURRENCY = 4;
|
|
export const MAX_TRANSFER_CONCURRENCY = 8;
|
|
|
|
export function clampTransferConcurrency(value: unknown): number {
|
|
const n = Math.floor(Number(value));
|
|
if (!Number.isFinite(n)) return DEFAULT_TRANSFER_CONCURRENCY;
|
|
return Math.min(MAX_TRANSFER_CONCURRENCY, Math.max(1, n));
|
|
}
|
|
|
|
/** How many files are transferred at the same time (1 = one after another). */
|
|
export function getTransferConcurrency(): number {
|
|
try {
|
|
const raw = localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
|
|
return raw === null
|
|
? DEFAULT_TRANSFER_CONCURRENCY
|
|
: clampTransferConcurrency(raw);
|
|
} catch {
|
|
return DEFAULT_TRANSFER_CONCURRENCY;
|
|
}
|
|
}
|
|
|
|
export function setTransferConcurrency(value: number): number {
|
|
const clamped = clampTransferConcurrency(value);
|
|
try {
|
|
localStorage.setItem(TRANSFER_CONCURRENCY_STORAGE_KEY, String(clamped));
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
/**
|
|
* Runs `worker` over `items` with at most `limit` in flight, preserving the
|
|
* original order of dispatch. Stops dispatching new items once `shouldStop()`
|
|
* returns true; items already in flight run to completion (the caller
|
|
* cancels those through their own transfer ids). Never rejects because of a
|
|
* single item: worker errors are the worker's business.
|
|
*/
|
|
export async function runWithConcurrency<T>(
|
|
items: readonly T[],
|
|
limit: number,
|
|
worker: (item: T, index: number) => Promise<void>,
|
|
shouldStop: () => boolean = () => false,
|
|
): Promise<void> {
|
|
const size = Math.max(1, Math.min(limit, items.length));
|
|
let next = 0;
|
|
const lanes = Array.from({ length: size }, async () => {
|
|
while (next < items.length && !shouldStop()) {
|
|
const index = next++;
|
|
await worker(items[index], index);
|
|
}
|
|
});
|
|
await Promise.all(lanes);
|
|
}
|