Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6edc4213f4 | ||
|
|
4313977bd4 |
@@ -647,6 +647,10 @@ const en: Messages = {
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
'sftp.download.cancelled': 'Download cancelled',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
|
||||
@@ -913,6 +913,10 @@ const zhCN: Messages = {
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
'sftp.download.cancelled': '下载已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
|
||||
@@ -188,6 +188,15 @@ export const useSftpBackend = () => {
|
||||
return bridge.selectApplication();
|
||||
}, []);
|
||||
|
||||
const showSaveDialog = useCallback(async (
|
||||
defaultPath: string,
|
||||
filters?: Array<{ name: string; extensions: string[] }>
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.showSaveDialog) return null;
|
||||
return bridge.showSaveDialog(defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
@@ -268,6 +277,7 @@ export const useSftpBackend = () => {
|
||||
cancelSftpUpload,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
showSaveDialog,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ export const useSftpState = (
|
||||
// SFTP session refs
|
||||
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
|
||||
|
||||
// Getter for sftpId from connectionId (for stream transfers)
|
||||
const getSftpIdForConnection = useCallback((connectionId: string) => {
|
||||
return sftpSessionsRef.current.get(connectionId);
|
||||
}, []);
|
||||
|
||||
// Directory listing cache (connectionId + path)
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
@@ -274,11 +279,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -315,11 +323,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -360,11 +371,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
|
||||
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
|
||||
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
|
||||
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
|
||||
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
}), []); // Empty deps - these wrappers never change
|
||||
|
||||
// Return object with stable method references but reactive state
|
||||
|
||||
@@ -82,6 +82,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
|
||||
@@ -358,6 +359,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
@@ -376,6 +378,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
@@ -625,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onDismiss={dismissTask} />
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
|
||||
|
||||
<SftpModalFooter
|
||||
t={t}
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
@@ -67,6 +68,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -130,6 +134,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import React from "react";
|
||||
import { Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface UploadTask {
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: UploadTask[];
|
||||
tasks: TransferTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: UploadTask) => {
|
||||
const getDisplayName = (task: TransferTask) => {
|
||||
// Check for explicit phase marker format: "folderName|phase"
|
||||
// This is the format sent by uploadService.ts for compressed uploads
|
||||
if (task.fileName.includes('|')) {
|
||||
@@ -96,14 +98,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{task.status === "uploading" && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
<Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<Upload size={14} className="text-green-500" />
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-green-500" />
|
||||
: <Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
@@ -117,18 +123,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
<span className="text-xs font-medium truncate">
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{task.status === "uploading" && task.speed > 0 && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
</span>
|
||||
)}
|
||||
{task.status === "uploading" && remainingStr && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "pending") && (
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -140,30 +146,30 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading"
|
||||
task.status === "uploading" || task.status === "downloading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "uploading" && task.totalBytes > 0 && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t("sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t("sftp.upload.cancelled")}
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
@@ -178,12 +184,19 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "pending") && onCancel && (
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={onCancel}
|
||||
onClick={() => {
|
||||
// For download tasks or when onCancelTask is available, use task-specific cancel
|
||||
if (onCancelTask) {
|
||||
onCancelTask(task.id);
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
} from "../../../lib/uploadService";
|
||||
import { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UploadTask {
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
@@ -26,8 +26,12 @@ interface UploadTask {
|
||||
isDirectory?: boolean;
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
// Keep UploadTask as alias for backwards compatibility
|
||||
type UploadTask = TransferTask;
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -67,6 +71,7 @@ interface UseSftpModalTransfersParams {
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
cancelTransfer?: (transferId: string) => Promise<void>;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
useCompressedUpload?: boolean; // Enable compressed folder uploads
|
||||
@@ -85,6 +90,7 @@ interface UseSftpModalTransfersResult {
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
cancelUpload: () => Promise<void>;
|
||||
cancelTask: (taskId: string) => Promise<void>;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -104,6 +110,7 @@ export const useSftpModalTransfers = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
@@ -214,6 +221,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
@@ -231,6 +239,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -376,19 +385,159 @@ export const useSftpModalTransfers = ({
|
||||
async (file: RemoteFile) => {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
setLoading(true);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath)
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// For local files, use blob download (file is already on local filesystem)
|
||||
if (isLocalSession) {
|
||||
setLoading(true);
|
||||
const content = await readLocalFile(fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled the save dialog
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === 'number' ? file.size : parseInt(file.size, 10) || 0;
|
||||
|
||||
// Create download task for progress display
|
||||
const downloadTask: TransferTask = {
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
direction: "download",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, downloadTask]);
|
||||
|
||||
// Track if this download was cancelled or error was handled
|
||||
let wasCancelled = false;
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
},
|
||||
// onProgress
|
||||
(transferred, total, speed) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
|
||||
speed,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
// onComplete
|
||||
() => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "completed" as const, progress: 100 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
// Check if this is a cancellation error
|
||||
if (error.includes('cancelled') || error.includes('canceled')) {
|
||||
wasCancelled = true;
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "failed" as const, error }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if bridge doesn't support streaming (returns undefined)
|
||||
if (result === undefined) {
|
||||
// Remove the pending task and show error
|
||||
setUploadTasks(prev => prev.filter(task => task.id !== transferId));
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle result - check for cancellation in result.error as well
|
||||
// (backend may set error without calling onError callback)
|
||||
if (result?.error) {
|
||||
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
|
||||
if (isCancelError) {
|
||||
// Mark as cancelled if not already done by onError
|
||||
if (!wasCancelled) {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
}
|
||||
// Don't show error for cancellation
|
||||
return;
|
||||
}
|
||||
// For non-cancel errors, only show toast if onError didn't already handle it
|
||||
if (!errorHandled) {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "failed" as const, error: result.error }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
@@ -398,7 +547,7 @@ export const useSftpModalTransfers = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
|
||||
);
|
||||
|
||||
|
||||
@@ -608,6 +757,56 @@ export const useSftpModalTransfers = ({
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
// Cancel a specific task (works for both uploads and downloads)
|
||||
const cancelTask = useCallback(async (taskId: string) => {
|
||||
// Find the task to determine its type
|
||||
const task = uploadTasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
if (task.direction === "download") {
|
||||
// For download tasks, cancel only this specific transfer
|
||||
if (cancelTransfer) {
|
||||
try {
|
||||
await cancelTransfer(taskId);
|
||||
} catch (e) {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
}
|
||||
// Mark task as cancelled
|
||||
setUploadTasks(prev =>
|
||||
prev.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, status: "cancelled" as const, speed: 0 }
|
||||
: t
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For upload tasks, cancel the entire upload batch
|
||||
// because controller.cancel() cancels all active uploads
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
// Mark all active transfer IDs as cancelled before calling cancel
|
||||
const activeIds = controller.getActiveTransferIds();
|
||||
for (const id of activeIds) {
|
||||
cancelledTransferIdsRef.current.add(id);
|
||||
}
|
||||
await controller.cancel();
|
||||
}
|
||||
|
||||
// Mark ALL uploading/pending tasks as cancelled (not just the clicked one)
|
||||
setUploadTasks(prev =>
|
||||
prev.map(t =>
|
||||
t.status === "uploading" || t.status === "pending"
|
||||
? { ...t, status: "cancelled" as const, speed: 0 }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
// Reset uploading state
|
||||
setUploading(false);
|
||||
}
|
||||
}, [uploadTasks, cancelTransfer]);
|
||||
|
||||
const dismissTask = useCallback((taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, []);
|
||||
@@ -625,6 +824,7 @@ export const useSftpModalTransfers = ({
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,23 @@ interface UseSftpViewFileOpsParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface UseSftpViewFileOpsResult {
|
||||
@@ -88,6 +105,9 @@ export const useSftpViewFileOps = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
@@ -328,19 +348,130 @@ export const useSftpViewFileOps = ({
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
// For local files, use blob download
|
||||
if (pane.connection.isLocal) {
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
// Add download task to transfer queue for progress display
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: 'local',
|
||||
direction: 'download',
|
||||
status: 'transferring',
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
// Track if error was already handled by callback
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
},
|
||||
(transferred, total, speed) => {
|
||||
// Update transfer progress in the queue
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// Mark as completed
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: 'completed',
|
||||
transferredBytes: fileSize,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
// Check if this is a cancellation - don't show error toast for cancellations
|
||||
const isCancelError = error.includes('cancelled') || error.includes('canceled');
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? 'cancelled' : 'failed',
|
||||
error: isCancelError ? undefined : error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if bridge doesn't support streaming (returns undefined)
|
||||
if (result === undefined) {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: 'failed',
|
||||
error: t("sftp.error.downloadFailed"),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle error from result only if onError callback wasn't called
|
||||
if (result?.error && !errorHandled) {
|
||||
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? 'cancelled' : 'failed',
|
||||
error: isCancelError ? undefined : result.error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
@@ -349,7 +480,7 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
|
||||
@@ -19,6 +19,23 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneCallbacks = ({
|
||||
@@ -28,6 +45,9 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
const paneActions = useSftpViewPaneActions({ sftpRef });
|
||||
const fileOps = useSftpViewFileOps({
|
||||
@@ -37,6 +57,9 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
|
||||
|
||||
@@ -559,6 +559,22 @@ const registerBridges = (win) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show save file dialog and return selected path
|
||||
ipcMain.handle("netcatty:showSaveDialog", async (_event, { defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
|
||||
@@ -705,7 +705,11 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
||||
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId, encoding }),
|
||||
|
||||
3
global.d.ts
vendored
3
global.d.ts
vendored
@@ -536,6 +536,9 @@ declare global {
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
|
||||
Reference in New Issue
Block a user