Files
Netcatty/components/SFTPModal.tsx
T
陈大猫 4313977bd4 feat: stream-based SFTP download for large files (#151)
* feat: stream-based SFTP download for large files

- Add showSaveDialog API for native file save dialog
- Modify handleDownload to use streaming transfer for remote files
- Show save dialog first, then stream directly to disk
- Avoid loading entire file into memory
- Fallback to memory-based download for local files or when streaming unavailable

This fixes the issue where downloading large files would cause high memory
usage as the entire file was loaded into memory before saving.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* feat: stream-based download for SftpView

- Add getSftpIdForConnection to useSftpState for accessing SFTP session IDs
- Modify handleDownloadFileForSide in useSftpViewFileOps to use streaming
- Pass showSaveDialog, startStreamTransfer to SftpView hook chain
- For remote SFTP files: show save dialog then stream directly to disk
- For local files: fallback to memory-based download

This extends the stream download optimization to SftpView (dual-pane browser),
not just SFTPModal.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: improve download task handling and cancellation

- Add per-task cancellation for downloads via onCancelTask prop
- Add i18n translation keys for download status messages
- Prevent duplicate error toasts with errorHandled flag
- Add bridge capability check (result === undefined)
- Make direction field required in TransferTask interface

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

---------

Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-31 02:03:18 +08:00

693 lines
22 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useSettingsState } from "../application/state/useSettingsState";
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DropEntry } from "../lib/sftpFileUtils";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
import { SftpModalDialogs } from "./sftp-modal/SftpModalDialogs";
import { SftpModalFooter } from "./sftp-modal/SftpModalFooter";
import { SftpModalHeader } from "./sftp-modal/SftpModalHeader";
import { SftpModalUploadTasks } from "./sftp-modal/SftpModalUploadTasks";
import { formatBytes, formatDate } from "./sftp-modal/utils";
import { useSftpModalSorting } from "./sftp-modal/hooks/useSftpModalSorting";
import { useSftpModalVirtualList } from "./sftp-modal/hooks/useSftpModalVirtualList";
import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
import { Dialog, DialogContent } from "./ui/dialog";
interface SFTPModalProps {
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
}
const SFTPModal: React.FC<SFTPModalProps> = ({
host,
credentials,
open,
onClose,
initialPath,
initialEntriesToUpload,
}) => {
const {
openSftp,
closeSftp: closeSftpBackend,
listSftp,
readSftp,
writeSftpBinaryWithProgress,
writeSftpBinary,
writeSftp,
deleteSftp,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
listLocalDir,
readLocalFile,
writeLocalFile,
deleteLocalFile,
mkdirLocal,
getHomeDir,
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const navigatingRef = useRef(false);
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
// Update filenameEncoding when host changes
useEffect(() => {
setFilenameEncoding(host.sftpEncoding ?? "auto");
}, [host.id, host.sftpEncoding]);
const listSftpWithEncoding = useCallback(
(sftpId: string, path: string) => listSftp(sftpId, path, filenameEncoding),
[listSftp, filenameEncoding],
);
const readSftpWithEncoding = useCallback(
(sftpId: string, path: string) => readSftp(sftpId, path, filenameEncoding),
[readSftp, filenameEncoding],
);
const writeSftpWithEncoding = useCallback(
(sftpId: string, path: string, data: string) =>
writeSftp(sftpId, path, data, filenameEncoding),
[writeSftp, filenameEncoding],
);
const writeSftpBinaryWithEncoding = useCallback(
(sftpId: string, path: string, data: ArrayBuffer) =>
writeSftpBinary(sftpId, path, data, filenameEncoding),
[writeSftpBinary, filenameEncoding],
);
const writeSftpBinaryWithProgressWithEncoding = useCallback(
(
sftpId: string,
path: string,
data: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
) =>
writeSftpBinaryWithProgress(
sftpId,
path,
data,
transferId,
filenameEncoding,
onProgress,
onComplete,
onError,
),
[writeSftpBinaryWithProgress, filenameEncoding],
);
const deleteSftpWithEncoding = useCallback(
(sftpId: string, path: string) => deleteSftp(sftpId, path, filenameEncoding),
[deleteSftp, filenameEncoding],
);
const mkdirSftpWithEncoding = useCallback(
(sftpId: string, path: string) => mkdirSftp(sftpId, path, filenameEncoding),
[mkdirSftp, filenameEncoding],
);
const renameSftpWithEncoding = useCallback(
(sftpId: string, oldPath: string, newPath: string) =>
renameSftp(sftpId, oldPath, newPath, filenameEncoding),
[renameSftp, filenameEncoding],
);
const chmodSftpWithEncoding = useCallback(
(sftpId: string, path: string, mode: string) =>
chmodSftp(sftpId, path, mode, filenameEncoding),
[chmodSftp, filenameEncoding],
);
const statSftpWithEncoding = useCallback(
(sftpId: string, path: string) => statSftp(sftpId, path, filenameEncoding),
[statSftp, filenameEncoding],
);
const downloadSftpToTempAndOpenWithEncoding = useCallback(
(
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean },
) =>
downloadSftpToTempAndOpen(sftpId, remotePath, fileName, appPath, {
...options,
encoding: filenameEncoding,
}),
[downloadSftpToTempAndOpen, filenameEncoding],
);
const {
currentPath,
setCurrentPath,
files,
loading,
setLoading,
reconnecting,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
} = useSftpModalSession({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp: closeSftpBackend,
listSftp: listSftpWithEncoding,
listLocalDir,
getHomeDir,
onClearSelection: clearSelection,
});
// Track previous encoding to detect changes
const prevEncodingRef = useRef(filenameEncoding);
// Force reload only when filenameEncoding changes (not on every path change)
useEffect(() => {
if (!open || isLocalSession) return;
// Only force reload if encoding actually changed
if (prevEncodingRef.current !== filenameEncoding) {
prevEncodingRef.current = filenameEncoding;
loadFiles(currentPath, { force: true });
}
}, [currentPath, filenameEncoding, isLocalSession, loadFiles, open]);
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } =
useSftpModalSorting();
const joinPathForSession = useCallback(
(base: string, name: string) => joinPath(base, name, isLocalSession),
[isLocalSession],
);
const isRootPathForSession = useCallback(
(path: string) => isRootPath(path, isLocalSession),
[isLocalSession],
);
const getParentPathForSession = useCallback(
(path: string) => getParentPath(path, isLocalSession),
[isLocalSession],
);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
if (navigatingRef.current) return;
navigatingRef.current = true;
setCurrentPath(path);
// Reset lock after a short delay
setTimeout(() => {
navigatingRef.current = false;
}, 300);
}, [navigatingRef, setCurrentPath]);
const handleUp = () => {
if (isRootPathForSession(currentPath)) return;
setCurrentPath(getParentPathForSession(currentPath));
};
const {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
} = useSftpModalPath({
currentPath,
isLocalSession,
localHomePath: localHomeRef.current,
onNavigate: handleNavigate,
});
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
} = useSftpModalFileActions({
currentPath,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftp: writeSftpWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
deleteLocalFile,
deleteSftp: deleteSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
renameSftp: renameSftpWithEncoding,
chmodSftp: chmodSftpWithEncoding,
statSftp: statSftpWithEncoding,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen: downloadSftpToTempAndOpenWithEncoding,
selectApplication,
});
const {
uploading,
uploadTasks,
dragActive,
handleDownload,
handleUploadEntries,
handleFileSelect,
handleFolderSelect,
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
} = useSftpModalTransfers({
currentPath,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftpBinaryWithProgress: writeSftpBinaryWithProgressWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
writeSftp: writeSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
});
const handleClose = async () => {
await closeSftpSession();
onClose();
};
// Handle initial entries to upload (from drag-and-drop to terminal)
const initialUploadTriggeredRef = useRef(false);
const prevLoadingRef = useRef(loading);
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
useEffect(() => {
// Detect when loading transitions from true to false (initial load complete)
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = loading;
const justFinishedLoading = wasLoading && !loading;
// Reset the flag when initialEntriesToUpload is cleared
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = undefined;
return;
}
// Reset the flag when new entries arrive (different reference = new drop)
if (initialEntriesToUpload !== prevEntriesRef.current) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = initialEntriesToUpload;
}
// Prevent duplicate uploads
if (initialUploadTriggeredRef.current) return;
// Wait for SFTP connection to be established
// Trigger when: modal is open AND loading just finished (works for empty directories too)
if (!open || loading) return;
if (!justFinishedLoading) return;
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
handleUploadEntries(initialEntriesToUpload);
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPathForSession(currentPath);
if (atRoot) return visibleFiles;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
name: "..",
type: "directory",
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
// Sorted files
const sortedFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
// Keep ".." at the top, sort the rest
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
// Directories and symlinks pointing to directories come first
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size": {
const sizeA =
typeof a.size === "number"
? a.size
: parseInt(String(a.size), 10) || 0;
const sizeB =
typeof b.size === "number"
? b.size
: parseInt(String(b.size), 10) || 0;
cmp = sizeA - sizeB;
break;
}
case "modified": {
const dateA = new Date(a.lastModified || 0).getTime();
const dateB = new Date(b.lastModified || 0).getTime();
cmp = dateA - dateB;
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const {
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpModalVirtualList({ open, sortedFiles });
const { handleFileClick, handleFileDoubleClick } = useSftpModalSelection({
files,
setSelectedFiles,
currentPath,
joinPath: joinPathForSession,
onNavigate: handleNavigate,
onOpenFile: handleOpenFile,
onNavigateUp: handleUp,
});
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
const fileNames = Array.from(selectedFiles);
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
};
const handleDownloadSelected = async () => {
if (selectedFiles.size === 0) return;
for (const fileName of selectedFiles) {
const file = files.find((f) => f.name === fileName);
if (file && file.type === "file") {
await handleDownload(file);
}
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<SftpModalHeader
t={t}
host={host}
credentials={credentials}
showEncoding={!isLocalSession}
filenameEncoding={filenameEncoding}
onFilenameEncodingChange={setFilenameEncoding}
currentPath={currentPath}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
handlePathSubmit={handlePathSubmit}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
isAtRoot={isRootPathForSession(currentPath)}
rootLabel={rootLabel}
isRefreshing={loading || reconnecting}
onUp={handleUp}
onHome={() =>
setCurrentPath((isLocalSession && localHomeRef.current) || rootPath)
}
onRefresh={() => loadFiles(currentPath, { force: true })}
visibleBreadcrumbs={visibleBreadcrumbs}
hiddenBreadcrumbs={hiddenBreadcrumbs}
needsBreadcrumbTruncation={needsBreadcrumbTruncation}
breadcrumbs={breadcrumbs}
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
onRootSelect={() => setCurrentPath(rootPath)}
inputRef={inputRef}
folderInputRef={folderInputRef}
pathInputRef={pathInputRef}
uploading={uploading}
onTriggerUpload={() => inputRef.current?.click()}
onTriggerFolderUpload={() => folderInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
/>
<SftpModalFileList
t={t}
currentPath={currentPath}
isLocalSession={isLocalSession}
files={files}
selectedFiles={selectedFiles}
dragActive={dragActive}
loading={loading}
loadingTextContent={loadingTextContent}
reconnecting={reconnecting}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
visibleRows={visibleRows}
fileListRef={fileListRef}
inputRef={inputRef}
folderInputRef={folderInputRef}
handleSort={handleSort}
handleResizeStart={handleResizeStart}
handleFileListScroll={handleFileListScroll}
handleDrag={handleDrag}
handleDrop={handleDrop}
handleFileClick={handleFileClick}
handleFileDoubleClick={handleFileDoubleClick}
handleDownload={handleDownload}
handleDelete={handleDelete}
handleOpenFile={handleOpenFile}
openFileOpenerDialog={openFileOpenerDialog}
handleEditFile={handleEditFile}
openRenameDialog={openRenameDialog}
openPermissionsDialog={openPermissionsDialog}
handleNavigate={handleNavigate}
handleCreateFolder={handleCreateFolder}
handleCreateFile={handleCreateFile}
handleDownloadSelected={handleDownloadSelected}
handleDeleteSelected={handleDeleteSelected}
loadFiles={loadFiles}
formatBytes={formatBytes}
formatDate={formatDate}
/>
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
<SftpModalFooter
t={t}
files={files}
selectedFiles={selectedFiles}
loading={loading}
uploading={uploading}
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</DialogContent>
<SftpModalDialogs
t={t}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameTarget={renameTarget}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showPermissionsDialog={showPermissionsDialog}
setShowPermissionsDialog={setShowPermissionsDialog}
permissionsTarget={permissionsTarget}
permissions={permissions}
togglePermission={togglePermission}
getOctalPermissions={getOctalPermissions}
getSymbolicPermissions={getSymbolicPermissions}
handleSavePermissions={handleSavePermissions}
isChangingPermissions={isChangingPermissions}
/>
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
{/* Text Editor Modal */}
<TextEditorModal
open={showTextEditor}
onClose={() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}}
fileName={textEditorTarget?.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
/>
</Dialog>
);
};
export default SFTPModal;