Files
Netcatty/application/state/useSftpState.ts
T
LAPTOP-O016UC3M\Qi Chen 13f8e8cd59 Adds reusable SSH identities and integrates identity auth
Introduces reusable SSH identities for authentication, allowing hosts to reference centralized identity records containing usernames, passwords, and key/certificate associations. Updates connection, SFTP, terminal, and sync logic to resolve credentials via identities, improving credential management and reuse.

Enhances UI for identity selection, editing, and deletion. Updates localization for related identity and credential strings. Ensures identities are included in sync and vault persistence.

Streamlines SSH connection attempts by preferring key-based auth with fallback to password, both for host and identity-based authentication.

Facilitates safer, more flexible credential management across the application.
2025-12-18 11:45:20 +08:00

2175 lines
66 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
FileConflict,
Host,
Identity,
SftpConnection,
SftpFileEntry,
SSHKey,
TransferDirection,
TransferStatus,
TransferTask,
} from "../../domain/models";
import { logger } from "../../lib/logger";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { resolveHostAuth } from "../../domain/sshAuth";
// Helper functions
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]}`;
};
const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
const getFileExtension = (name: string): string => {
if (name === "..") return "folder";
const ext = name.split(".").pop()?.toLowerCase();
return ext || "file";
};
// Check if path is Windows-style
const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
const joinPath = (base: string, name: string): string => {
if (isWindowsPath(base)) {
// Windows path
const normalizedBase = base.replace(/[\\/]+$/, ""); // Remove trailing slashes
return `${normalizedBase}\\${name}`;
}
// Unix path
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
const getParentPath = (path: string): string => {
if (isWindowsPath(path)) {
// Windows path
const parts = path.split(/[\\/]/).filter(Boolean);
if (parts.length <= 1) return parts[0] || "C:"; // Return drive root
parts.pop();
return parts.join("\\");
}
// Unix path
if (path === "/") return "/";
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join("/")}` : "/";
};
const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";
};
export interface SftpPane {
connection: SftpConnection | null;
files: SftpFileEntry[];
loading: boolean;
reconnecting: boolean;
error: string | null;
selectedFiles: Set<string>;
filter: string;
}
export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity[]) => {
// Connections
const [leftPane, setLeftPane] = useState<SftpPane>({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
});
const [rightPane, setRightPane] = useState<SftpPane>({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
});
// Transfer management
const [transfers, setTransfers] = useState<TransferTask[]>([]);
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
// SFTP session refs
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
// Directory listing cache (connectionId + path)
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
Map<string, { files: SftpFileEntry[]; timestamp: number }>
>(new Map());
// Navigation sequence per pane, used to ignore stale async results
const navSeqRef = useRef<{ left: number; right: number }>({
left: 0,
right: 0,
});
const makeCacheKey = useCallback(
(connectionId: string, path: string) => `${connectionId}::${path}`,
[],
);
const clearCacheForConnection = useCallback((connectionId: string) => {
for (const key of dirCacheRef.current.keys()) {
if (key.startsWith(`${connectionId}::`)) {
dirCacheRef.current.delete(key);
}
}
}, []);
// Progress simulation refs
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Simulate progress for a transfer (used when real progress callbacks aren't available)
const startProgressSimulation = useCallback(
(taskId: string, estimatedBytes: number) => {
// Clear any existing interval for this task
const existing = progressIntervalsRef.current.get(taskId);
if (existing) clearInterval(existing);
// Estimate transfer speed based on file size (simulate realistic speeds)
// Smaller files: faster perceived progress, larger files: slower but steady
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10)); // 50KB/s to 500KB/s base
const variability = 0.3; // 30% speed variation
let transferred = 0;
const interval = setInterval(() => {
// Add some randomness to simulate real network conditions
const speedFactor = 1 + (Math.random() - 0.5) * variability;
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1); // Update every 100ms
transferred = Math.min(transferred + chunkSize, estimatedBytes);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId || t.status !== "transferring") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: estimatedBytes,
speed: chunkSize * 10, // Convert to per-second
};
}),
);
// If we've reached the estimated size, slow down to show we're finishing
if (transferred >= estimatedBytes * 0.95) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, 100);
progressIntervalsRef.current.set(taskId, interval);
},
[],
);
const stopProgressSimulation = useCallback((taskId: string) => {
const interval = progressIntervalsRef.current.get(taskId);
if (interval) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, []);
// Check if an error indicates a stale/lost SFTP session
const isSessionError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
right: false,
});
// Store last connected host info for reconnection
const lastConnectedHostRef = useRef<{
left: Host | "local" | null;
right: Host | "local" | null;
}>({
left: null,
right: null,
});
// Handle session error - will trigger auto-reconnect if host info is available
const handleSessionError = useCallback(
(side: "left" | "right", _error: Error) => {
const pane = side === "left" ? leftPane : rightPane;
const setPane = side === "left" ? setLeftPane : setRightPane;
if (pane.connection) {
// Clean up stale session reference
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
}
// Invalidate pending navigation/results for this side
navSeqRef.current[side] += 1;
// Check if we have host info to attempt reconnection
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
// We have files displayed, try to auto-reconnect
reconnectingRef.current[side] = true;
setPane((prev) => ({
...prev,
reconnecting: true,
error: "Connection lost. Reconnecting...",
}));
} else {
// No host info or empty files, just clear the connection
setPane({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
});
}
},
[leftPane, rightPane, clearCacheForConnection],
);
// Cleanup on unmount
useEffect(() => {
// Capture refs at effect creation time to use in cleanup
const sessionsRef = sftpSessionsRef.current;
const intervalsRef = progressIntervalsRef.current;
return () => {
// Clear all SFTP sessions
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
// Clear all progress simulation intervals
intervalsRef.forEach((interval) => {
clearInterval(interval);
});
intervalsRef.clear();
};
}, []);
// Track if initial auto-connect has been done
const initialConnectDoneRef = useRef(false);
// Get host credentials
const getHostCredentials = useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
};
},
[identities, keys],
);
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
// Fallback mock for development
return getMockLocalFiles(path);
}
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path);
if (!rawFiles) return [];
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
},
[],
);
// Connect to a host
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
const currentPane = side === "left" ? leftPane : rightPane;
const setPane = side === "left" ? setLeftPane : setRightPane;
const connectionId = `${side}-${Date.now()}`;
// Invalidate any pending navigation for this side
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
// Save host info for potential reconnection
lastConnectedHostRef.current[side] = host;
// First, disconnect any existing connection
if (currentPane.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(
currentPane.connection.id,
);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
if (host === "local") {
// Local filesystem connection
// Try to get home directory from backend, fallback to platform-specific default
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) {
// Detect platform and use appropriate default
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
homeDir,
};
setPane((prev) => ({
...prev,
connection,
loading: true,
reconnecting: false,
error: null,
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir), {
files,
timestamp: Date.now(),
});
// Clear reconnecting flag on success
reconnectingRef.current[side] = false;
setPane((prev) => ({
...prev,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
setPane((prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
}
} else {
// Remote SFTP connection
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
};
setPane((prev) => ({
...prev,
connection,
loading: true,
reconnecting: prev.reconnecting, // Preserve reconnecting state during connection
error: null,
files: prev.reconnecting ? prev.files : [], // Keep files if reconnecting
}));
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("authentication") ||
msg.includes("auth") ||
msg.includes("password") ||
msg.includes("permission denied")
);
};
const hasKey = !!credentials.privateKey;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
if (hasKey) {
try {
// Prefer trying key/cert first when both are present.
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
password: undefined,
});
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
// Try to get home directory, default to "/"
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
}
candidates.push("/root");
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate);
if (stat?.type === "directory") {
startPath = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(
sftpId,
"/root",
);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available, use default
}
}
}
const files = await listRemoteFiles(sftpId, startPath);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath), {
files,
timestamp: Date.now(),
});
// Clear reconnecting flag on success
reconnectingRef.current[side] = false;
setPane((prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
}
: null,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
setPane((prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error:
err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
loading: false,
reconnecting: false,
}));
}
}
},
[
getHostCredentials,
leftPane,
rightPane,
clearCacheForConnection,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
],
);
// Auto-connect left pane to local filesystem on first mount
useEffect(() => {
if (!initialConnectDoneRef.current && !leftPane.connection) {
initialConnectDoneRef.current = true;
// Use setTimeout to ensure connect is defined before calling
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftPane.connection]);
// Auto-reconnect when reconnecting flag is set
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && reconnectingRef.current[side]) {
// Small delay before reconnecting
await new Promise((resolve) => setTimeout(resolve, 1000));
if (reconnectingRef.current[side]) {
connect(side, lastHost);
}
}
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
}
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
// Disconnect
const disconnect = useCallback(
async (side: "left" | "right") => {
const pane = side === "left" ? leftPane : rightPane;
const setPane = side === "left" ? setLeftPane : setRightPane;
// Invalidate any pending navigation for this side
navSeqRef.current[side] += 1;
if (pane.connection) {
clearCacheForConnection(pane.connection.id);
}
// Clear reconnection state
reconnectingRef.current[side] = false;
lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
setPane({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
});
},
[leftPane, rightPane, clearCacheForConnection],
);
// Mock local file data for development (when backend is not available)
function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
const mockData: Record<string, SftpFileEntry[]> = {
// Unix-style paths
"/": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Applications",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "System",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Shared",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
// Windows-style paths (normalized to forward slashes for matching)
"C:": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Program Files",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Windows",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Public",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Default",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
],
"C:/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
"C:/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"C:/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
};
return mockData[normPath] || [];
}
// Navigate to path
const navigateTo = useCallback(
async (
side: "left" | "right",
path: string,
options?: { force?: boolean },
) => {
const pane = side === "left" ? leftPane : rightPane;
const setPane = side === "left" ? setLeftPane : setRightPane;
if (!pane.connection) return;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
if (
cached &&
Date.now() - cached.timestamp < DIR_CACHE_TTL_MS &&
cached.files
) {
setPane((prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
}));
return;
}
setPane((prev) => ({ ...prev, loading: true, error: null }));
try {
let files: SftpFileEntry[];
if (pane.connection.isLocal) {
files = await listLocalFiles(path);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
// Session lost - clear connection state
clearCacheForConnection(pane.connection.id);
setPane({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
});
return;
}
try {
files = await listRemoteFiles(sftpId, path);
} catch (err) {
if (isSessionError(err)) {
// Clean up stale session reference
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
setPane({
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session expired. Please reconnect.",
selectedFiles: new Set(),
filter: "",
});
return;
}
throw err as Error;
}
}
if (navSeqRef.current[side] !== requestId) return;
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
setPane((prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files,
loading: false,
selectedFiles: new Set(),
}));
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
setPane((prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
}
},
[
leftPane,
rightPane,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
listRemoteFiles,
],
);
// Refresh current directory
const refresh = useCallback(
async (side: "left" | "right") => {
const pane = side === "left" ? leftPane : rightPane;
if (pane.connection) {
await navigateTo(side, pane.connection.currentPath, { force: true });
}
},
[leftPane, rightPane, navigateTo],
);
// Navigate up
const navigateUp = useCallback(
async (side: "left" | "right") => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
const currentPath = pane.connection.currentPath;
// Check if we're at root (Unix "/" or Windows "C:")
const isAtRoot =
currentPath === "/" || /^[A-Za-z]:[\\/]?$/.test(currentPath);
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
await navigateTo(side, parentPath);
}
},
[leftPane, rightPane, navigateTo],
);
// Open file/directory
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
if (entry.name === "..") {
await navigateUp(side);
return;
}
if (entry.type === "directory") {
const newPath = joinPath(pane.connection.currentPath, entry.name);
await navigateTo(side, newPath);
}
// TODO: Handle file open/preview
},
[leftPane, rightPane, navigateTo, navigateUp],
);
// Selection management
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
const setPane = side === "left" ? setLeftPane : setRightPane;
setPane((prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
return { ...prev, selectedFiles: newSelection };
});
},
[],
);
// Range selection for shift-click
// Now accepts the actual file names to select directly from the UI
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const setPane = side === "left" ? setLeftPane : setRightPane;
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
newSelection.add(name);
}
}
setPane((prev) => ({ ...prev, selectedFiles: newSelection }));
},
[],
);
const clearSelection = useCallback((side: "left" | "right") => {
const setPane = side === "left" ? setLeftPane : setRightPane;
setPane((prev) => ({ ...prev, selectedFiles: new Set() }));
}, []);
const selectAll = useCallback(
(side: "left" | "right") => {
const pane = side === "left" ? leftPane : rightPane;
const setPane = side === "left" ? setLeftPane : setRightPane;
setPane((prev) => ({
...prev,
selectedFiles: new Set(
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
),
}));
},
[leftPane, rightPane],
);
// Filter
const setFilter = useCallback((side: "left" | "right", filter: string) => {
const setPane = side === "left" ? setLeftPane : setRightPane;
setPane((prev) => ({ ...prev, filter }));
}, []);
// Create directory
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[leftPane, rightPane, refresh, handleSessionError],
);
// Delete files
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) {
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath);
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[leftPane, rightPane, refresh, handleSessionError],
);
// Rename file
const renameFile = useCallback(
async (side: "left" | "right", oldName: string, newName: string) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[leftPane, rightPane, refresh, handleSessionError],
);
// Transfer files
const startTransfer = useCallback(
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
) => {
const sourcePane = sourceSide === "left" ? leftPane : rightPane;
const targetPane = targetSide === "left" ? leftPane : rightPane;
if (!sourcePane.connection || !targetPane.connection) return;
const sourcePath = sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
// Get SFTP session ID if remote
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection.id);
// Create transfer tasks with actual file sizes
const newTasks: TransferTask[] = [];
for (const file of sourceFiles) {
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
? "upload"
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
? "download"
: "remote-to-remote";
// Get actual file size from source
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
);
if (stat) fileSize = stat.size;
}
} catch {
// If stat fails, we'll use estimate later
}
}
newTasks.push({
id: crypto.randomUUID(),
fileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
targetPath: joinPath(targetPath, file.name),
sourceConnectionId: sourcePane.connection!.id,
targetConnectionId: targetPane.connection!.id,
direction,
status: "pending" as TransferStatus,
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: file.isDirectory,
});
}
setTransfers((prev) => [...prev, ...newTasks]);
// Process transfers
for (const task of newTasks) {
await processTransfer(task, sourcePane, targetPane);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline, not a dependency
[leftPane, rightPane],
);
// Process a single transfer
const processTransfer = async (
task: TransferTask,
sourcePane: SftpPane,
targetPane: SftpPane,
) => {
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
);
};
// Get actual file size if not already known
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
);
if (stat) actualFileSize = stat.size;
}
} catch {
// Ignore stat errors, use estimate
}
}
// Estimate file size for progress simulation (use a reasonable default if unknown)
const estimatedSize =
actualFileSize > 0
? actualFileSize
: task.isDirectory
? 1024 * 1024 // 1MB estimate for directories
: 256 * 1024; // 256KB default for files
// Check if streaming transfer is available (will provide real progress)
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
// Only use simulated progress for directories or when streaming is not available
const useSimulatedProgress = task.isDirectory || !hasStreamingTransfer;
if (useSimulatedProgress) {
startProgressSimulation(task.id, estimatedSize);
}
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
const targetSftpId = targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
// Check if file already exists at target (conflict detection)
// Skip if user already resolved conflict with replace/duplicate
if (!task.isDirectory && !task.skipConflictCheck) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
let sourceStat: { size: number; mtime: number } | null = null;
// Get source file stat for accurate size and mtime
try {
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId && netcattyBridge.get()?.statSftp) {
const stat = await netcattyBridge.get()!.statSftp!(
sourceSftpId,
task.sourcePath,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// Use estimated size if stat fails
}
// Get target file stat to check for conflict
try {
if (targetPane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (targetSftpId && netcattyBridge.get()?.statSftp) {
const stat = await netcattyBridge.get()!.statSftp!(
targetSftpId,
task.targetPath,
);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// File doesn't exist, no conflict
}
if (targetExists && existingStat) {
// Stop progress simulation while waiting for user decision
stopProgressSimulation(task.id);
// Add conflict for user to resolve
const newConflict: FileConflict = {
transferId: task.id,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
existingSize: existingStat.size,
newSize: sourceStat?.size || estimatedSize, // Use actual source size
existingModified: existingStat.mtime,
newModified: sourceStat?.mtime || Date.now(), // Use actual source mtime
};
setConflicts((prev) => [...prev, newConflict]);
updateTask({
status: "pending",
totalBytes: sourceStat?.size || estimatedSize,
}); // Wait for user decision
return;
}
}
if (task.isDirectory) {
// Handle directory transfer recursively
await transferDirectory(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
);
} else {
// Handle file transfer
await transferFile(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
);
}
// Stop progress simulation (only if it was started)
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
// Get the current state of the task to use accurate totalBytes
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
return {
...t,
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: t.totalBytes, // Use actual totalBytes from state
speed: 0,
};
}),
);
// Refresh target pane
const targetSide = targetPane === leftPane ? "left" : "right";
await refresh(targetSide as "left" | "right");
} catch (err) {
// Stop progress simulation on failure (only if it was started)
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
updateTask({
status: "failed",
error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(),
speed: 0,
});
}
};
// Transfer a single file using streaming with real progress
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
): Promise<void> => {
// Try to use streaming transfer if available
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
};
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
// Check if cancelled
if (t.status === "cancelled") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: total || t.totalBytes,
speed,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
// Fallback to legacy transfer (read all then write all)
let content: ArrayBuffer | string;
// Read from source
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath)) || "";
}
} else {
throw new Error("No source connection");
}
// Write to target
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text);
}
} else {
throw new Error("No target connection");
}
};
// Transfer a directory
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
) => {
// Create target directory
if (targetIsLocal) {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath);
}
// List source directory
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath);
} else {
throw new Error("No source connection");
}
// Transfer each item
for (const file of files) {
if (file.name === "..") continue;
const childTask: TransferTask = {
...task,
id: crypto.randomUUID(),
fileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: file.type === "directory",
parentTaskId: task.id,
};
if (file.type === "directory") {
await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
);
} else {
await transferFile(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
);
}
}
};
// Cancel transfer
// This will stop the streaming transfer at the backend level if supported
const cancelTransfer = useCallback(
async (transferId: string) => {
// Stop progress simulation (for directory transfers or fallback mode)
stopProgressSimulation(transferId);
// Mark as cancelled
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? {
...t,
status: "cancelled" as TransferStatus,
endTime: Date.now(),
}
: t,
),
);
// Remove from conflicts if present
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
// Cancel at backend level if streaming transfer is in progress
if (netcattyBridge.get()?.cancelTransfer) {
try {
await netcattyBridge.get()!.cancelTransfer!(transferId);
} catch (err) {
logger.warn("Failed to cancel transfer at backend:", err);
}
}
},
[stopProgressSimulation],
);
// Retry failed transfer
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
const sourcePane = task.sourceConnectionId.startsWith("left")
? leftPane
: rightPane;
const targetPane = task.targetConnectionId.startsWith("left")
? leftPane
: rightPane;
if (sourcePane.connection && targetPane.connection) {
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
: t,
),
);
await processTransfer(task, sourcePane, targetPane);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline, not a dependency
[transfers, leftPane, rightPane],
);
// Clear completed transfers
const clearCompletedTransfers = useCallback(() => {
setTransfers((prev) =>
prev.filter((t) => t.status !== "completed" && t.status !== "cancelled"),
);
}, []);
// Dismiss transfer
const dismissTransfer = useCallback((transferId: string) => {
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
// Handle file conflict
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
const conflict = conflicts.find((c) => c.transferId === conflictId);
if (!conflict) return;
// Remove from conflicts list
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
// Find the task
const task = transfers.find((t) => t.id === conflictId);
if (!task) return;
if (action === "skip") {
// Mark as cancelled
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
: t,
),
);
return;
}
// For replace or duplicate, we need to update the task and re-process
let updatedTask = { ...task };
if (action === "duplicate") {
// Generate new name and update task
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = task.targetPath.replace(task.fileName, newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true, // Skip check for new name
};
} else if (action === "replace") {
// For replace, we just need to skip the conflict check
updatedTask = {
...task,
skipConflictCheck: true, // User explicitly chose to replace
};
}
// Update task status and re-process
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
// Find source and target panes and re-process transfer
const sourcePane = updatedTask.sourceConnectionId.startsWith("left")
? leftPane
: rightPane;
const targetPane = updatedTask.targetConnectionId.startsWith("left")
? leftPane
: rightPane;
if (sourcePane.connection && targetPane.connection) {
// Small delay to ensure state is updated
setTimeout(async () => {
await processTransfer(updatedTask, sourcePane, targetPane);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline, not a dependency
[conflicts, transfers, leftPane, rightPane],
);
// Get filtered files
const getFilteredFiles = (pane: SftpPane): SftpFileEntry[] => {
const term = pane.filter.trim().toLowerCase();
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
};
// Get active transfers count
const activeTransfersCount = transfers.filter(
(t) => t.status === "pending" || t.status === "transferring",
).length;
// Change file permissions (SFTP only)
const changePermissions = useCallback(
async (
side: "left" | "right",
filePath: string,
mode: string, // octal string like "755"
) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection || pane.connection.isLocal) {
logger.warn("Cannot change permissions on local files");
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
try {
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode);
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
logger.error("Failed to change permissions:", err);
}
},
[leftPane, rightPane, refresh, handleSessionError],
);
return {
// Panes
leftPane,
rightPane,
getFilteredFiles,
// Connection
connect,
disconnect,
// Navigation
navigateTo,
navigateUp,
refresh,
openEntry,
// Selection
toggleSelection,
rangeSelect,
clearSelection,
selectAll,
setFilter,
// File operations
createDirectory,
deleteFiles,
renameFile,
changePermissions,
// Transfers
transfers,
activeTransfersCount,
startTransfer,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
// Conflicts
conflicts,
resolveConflict,
// Helpers
formatFileSize,
formatDate,
getFileExtension,
joinPath,
getParentPath,
getFileName,
};
};