[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
799
components/sftp/hooks/useSftpViewFileOps.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { TransferStatus } from "../../../domain/models";
|
||||
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
runBoundedConcurrency,
|
||||
} from "../../../application/state/sftp/transferConcurrency";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
||||
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
|
||||
|
||||
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
|
||||
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||
/**
|
||||
* Multi-select roots each start their own interleaved folder walk / session
|
||||
* work. Bound them so many selected directories cannot stampede the scheduler.
|
||||
*/
|
||||
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
|
||||
export const useSftpViewFileOps = ({
|
||||
sftpRef,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
/** Host ID at the time the file was opened, to prevent saving to wrong host.
|
||||
* Uses hostId (not connectionId) because auto-reconnect after a transient
|
||||
* disconnect generates a fresh connectionId for the same endpoint. */
|
||||
hostId?: string;
|
||||
} | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
fullPath: string;
|
||||
} | null>(null);
|
||||
|
||||
// Refs for frequently-changing state used inside stable callbacks
|
||||
const fileOpenerTargetRef = useRef(fileOpenerTarget);
|
||||
fileOpenerTargetRef.current = fileOpenerTarget;
|
||||
const textEditorTargetRef = useRef(textEditorTarget);
|
||||
textEditorTargetRef.current = textEditorTarget;
|
||||
|
||||
const onEditPermissionsLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
if (!pane.connection) return;
|
||||
setPermissionsState({
|
||||
file,
|
||||
side: "left",
|
||||
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
|
||||
});
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
const onEditPermissionsRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
setPermissionsState({
|
||||
file,
|
||||
side: "right",
|
||||
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
|
||||
});
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleEditFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
assertSftpFileFitsBuiltinEditor(file.size);
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
|
||||
|
||||
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
|
||||
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
|
||||
setTextEditorTarget(null);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handleOpenFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const savedOpener = getOpenerForFileRef.current(file.name);
|
||||
|
||||
if (savedOpener && savedOpener.openerType) {
|
||||
if (savedOpener.openerType === "builtin-editor") {
|
||||
handleEditFileForSide(side, file, resolvedFullPath);
|
||||
return;
|
||||
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
side,
|
||||
resolvedFullPath,
|
||||
file.name,
|
||||
savedOpener.systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
|
||||
);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(
|
||||
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
|
||||
const target = fileOpenerTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(target.file.name);
|
||||
setOpenerForExtension(ext, openerType, systemApp);
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === "builtin-editor") {
|
||||
handleEditFileForSide(target.side, target.file, target.fullPath);
|
||||
} else if (openerType === "system-app" && systemApp) {
|
||||
try {
|
||||
await sftpRef.current.downloadToTempAndOpen(
|
||||
target.side,
|
||||
target.fullPath,
|
||||
target.file.name,
|
||||
systemApp.path,
|
||||
{ enableWatch: autoSyncRef.current },
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
|
||||
}
|
||||
}
|
||||
|
||||
setFileOpenerTarget(null);
|
||||
},
|
||||
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
|
||||
);
|
||||
|
||||
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
|
||||
const result = await sftpRef.current.selectApplication();
|
||||
if (result) {
|
||||
return { path: result.path, name: result.name };
|
||||
}
|
||||
return null;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleSaveTextFile = useCallback(
|
||||
async (content: string) => {
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
|
||||
// Verify the SFTP connection hasn't switched to a different host.
|
||||
// We check hostId (not connectionId) because auto-reconnect after a
|
||||
// transient disconnect generates a fresh connectionId for the same
|
||||
// endpoint. The auto-connect effect in SftpSidePanel blocks
|
||||
// host-switching while the editor is open, so a hostId mismatch here
|
||||
// reliably indicates a genuinely different endpoint.
|
||||
const currentPane = target.side === "left"
|
||||
? sftpRef.current.leftPane
|
||||
: sftpRef.current.rightPane;
|
||||
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
|
||||
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
|
||||
}
|
||||
|
||||
await sftpRef.current.writeTextFile(
|
||||
target.side,
|
||||
target.fullPath,
|
||||
content,
|
||||
);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
|
||||
const target = textEditorTargetRef.current;
|
||||
if (!target) return;
|
||||
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
const connection = pane.connection;
|
||||
if (!connection || !target.hostId) return;
|
||||
|
||||
const editorId = editorTabStore.promoteFromModal({
|
||||
sessionId: connection.id,
|
||||
sftpTabId: pane.id,
|
||||
hostId: target.hostId,
|
||||
remotePath: target.fullPath,
|
||||
fileName: target.file.name,
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
viewState: snapshot.viewState,
|
||||
});
|
||||
activeTabStore.setActiveTabId(toEditorTabId(editorId));
|
||||
// Close the modal
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, [sftpRef]);
|
||||
|
||||
const onEditFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onEditFileRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
|
||||
[handleEditFileForSide],
|
||||
);
|
||||
const onOpenFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
const onOpenFileRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
|
||||
[handleOpenFileForSide],
|
||||
);
|
||||
|
||||
const handleOpenFileWithSystemDefaultForSide = useCallback(
|
||||
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
void sftpRef.current.openWithSystemDefault(side, resolvedFullPath, file.name, { enableWatch: autoSyncRef.current });
|
||||
},
|
||||
[sftpRef, autoSyncRef],
|
||||
);
|
||||
|
||||
const onOpenFileWithSystemDefaultLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("left", file, fullPath),
|
||||
[handleOpenFileWithSystemDefaultForSide],
|
||||
);
|
||||
const onOpenFileWithSystemDefaultRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithSystemDefaultForSide("right", file, fullPath),
|
||||
[handleOpenFileWithSystemDefaultForSide],
|
||||
);
|
||||
|
||||
const handleOpenFileWithForSide = useCallback(
|
||||
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
},
|
||||
[sftpRef],
|
||||
);
|
||||
|
||||
const onOpenFileWithLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
const onOpenFileWithRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
|
||||
reportSftpUploadResults({ results, t, toast });
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload external files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
);
|
||||
|
||||
const onUploadExternalFilesLeft = useCallback(
|
||||
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFilesRight = useCallback(
|
||||
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFileListForSide = useCallback(
|
||||
async (side: "left" | "right", fileList: FileList, targetPath?: string) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFileList(side, fileList, targetPath);
|
||||
reportSftpUploadResults({ results, t, toast });
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload picked files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
);
|
||||
|
||||
const onUploadExternalFileListLeft = useCallback(
|
||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("left", fileList, targetPath),
|
||||
[handleUploadExternalFileListForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFileListRight = useCallback(
|
||||
(fileList: FileList, targetPath?: string) => handleUploadExternalFileListForSide("right", fileList, targetPath),
|
||||
[handleUploadExternalFileListForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFolderForSide = useCallback(
|
||||
async (side: "left" | "right", targetPath?: string) => {
|
||||
if (!selectDirectory) {
|
||||
toast.error(t("sftp.error.uploadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.uploadFolder"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFolderPath(side, selectedDirectory, targetPath);
|
||||
const folderName = selectedDirectory.split(/[/\\]/).filter(Boolean).pop() || selectedDirectory;
|
||||
reportSftpUploadResults({
|
||||
results,
|
||||
t,
|
||||
toast,
|
||||
successMessage: `${t("sftp.uploadFolder")}: ${folderName}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload picked folder:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectDirectory, sftpRef, t],
|
||||
);
|
||||
|
||||
const onUploadExternalFolderLeft = useCallback(
|
||||
(targetPath?: string) => handleUploadExternalFolderForSide("left", targetPath),
|
||||
[handleUploadExternalFolderForSide],
|
||||
);
|
||||
|
||||
const onUploadExternalFolderRight = useCallback(
|
||||
(targetPath?: string) => handleUploadExternalFolderForSide("right", targetPath),
|
||||
[handleUploadExternalFolderForSide],
|
||||
);
|
||||
|
||||
const handleDownloadFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
|
||||
try {
|
||||
// For local files, use blob download.
|
||||
if (pane.connection.isLocal) {
|
||||
if (isDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files/directories, use transfer-center downloads
|
||||
// (dedicated pool sessions via downloadToLocal).
|
||||
if (!showSaveDialog || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (!selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
|
||||
try {
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: true,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "attention") {
|
||||
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
|
||||
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
|
||||
toast.error(errorMessage, "SFTP");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
// Route through downloadToLocal so FileZilla-style transfer pool
|
||||
// sessions are used (browse session stays free for listing).
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory: false,
|
||||
totalBytes: fileSize,
|
||||
});
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "attention") {
|
||||
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
|
||||
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
|
||||
}
|
||||
},
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
],
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const onDownloadFileRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const handleExtractArchiveForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge?.extractLocalArchive) {
|
||||
throw new Error("Local extract unavailable");
|
||||
}
|
||||
await bridge.extractLocalArchive(resolvedPath);
|
||||
} else {
|
||||
const sftpId = getSftpIdForConnection?.(pane.connection.id);
|
||||
if (!sftpId || !bridge?.extractSftpArchive) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
|
||||
}
|
||||
await sftpRef.current.refresh(side);
|
||||
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to extract archive:", e);
|
||||
toast.error(
|
||||
t("sftp.extract.error", {
|
||||
fileName: file.name,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[getSftpIdForConnection, sftpRef, t],
|
||||
);
|
||||
|
||||
const onExtractArchiveLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
const onExtractArchiveRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
|
||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
// that would otherwise appear N times.
|
||||
const handleDownloadFilesForSide = useCallback(
|
||||
async (side: "left" | "right", files: SftpFileEntry[]) => {
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 1) {
|
||||
await handleDownloadFileForSide(side, files[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
// Sequential: each local download materializes a full ArrayBuffer.
|
||||
await runBoundedConcurrency(
|
||||
files,
|
||||
LOCAL_BLOB_DOWNLOAD_CONCURRENCY,
|
||||
async (file) => {
|
||||
await handleDownloadFileForSide(side, file);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectDirectory || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
// Bound root jobs: each directory root walks and transfers independently,
|
||||
// so unbounded Promise.allSettled would multiply session / LIST pressure.
|
||||
const results: Array<PromiseSettledResult<{ file: SftpFileEntry; status: TransferStatus }>> = [];
|
||||
await runBoundedConcurrency(
|
||||
files,
|
||||
MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY,
|
||||
async (file, index) => {
|
||||
try {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const isDirectory = isNavigableDirectory(file);
|
||||
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
sourceHostLabel: pane.connection.hostLabel,
|
||||
sourceEncoding: pane.filenameEncoding,
|
||||
isDirectory,
|
||||
totalBytes: isDirectory ? undefined : fileSize,
|
||||
});
|
||||
results[index] = { status: "fulfilled", value: { file, status } };
|
||||
} catch (reason) {
|
||||
results[index] = { status: "rejected", reason };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) continue;
|
||||
if (result.status === "fulfilled") {
|
||||
const { file, status } = result.value;
|
||||
if (status === "completed") {
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "attention") {
|
||||
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
|
||||
}
|
||||
} else {
|
||||
logger.error("[SftpView] Failed to download file:", result.reason);
|
||||
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
|
||||
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
sftpRef,
|
||||
t,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
handleDownloadFileForSide,
|
||||
],
|
||||
);
|
||||
|
||||
const onDownloadFilesLeft = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onDownloadFilesRight = useCallback(
|
||||
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
|
||||
[handleDownloadFilesForSide],
|
||||
);
|
||||
|
||||
const onOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.leftPane;
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
sftpRef.current.openEntry("left", entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
|
||||
const sourceConnectionId = pane.connection?.id;
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "left", "right", {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
});
|
||||
} else {
|
||||
onOpenFileLeft(entry, fullPath);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileLeft, behaviorRef],
|
||||
);
|
||||
|
||||
const onOpenEntryRight = useCallback(
|
||||
(entry: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = sftpRef.current.rightPane;
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
|
||||
if (entry.name === ".." || isDir) {
|
||||
sftpRef.current.openEntry("right", entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (behaviorRef.current === "transfer") {
|
||||
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
|
||||
const sourceConnectionId = pane.connection?.id;
|
||||
const fileData = [{
|
||||
name: entry.name,
|
||||
isDirectory: isDir,
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
}];
|
||||
sftpRef.current.startTransfer(fileData, "right", "left", {
|
||||
sourceConnectionId,
|
||||
sourcePath,
|
||||
});
|
||||
} else {
|
||||
onOpenFileRight(entry, fullPath);
|
||||
}
|
||||
},
|
||||
[sftpRef, onOpenFileRight, behaviorRef],
|
||||
);
|
||||
|
||||
return {
|
||||
permissionsState,
|
||||
setPermissionsState,
|
||||
showTextEditor,
|
||||
setShowTextEditor,
|
||||
textEditorTarget,
|
||||
setTextEditorTarget,
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab: handlePromoteToTab,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
onEditPermissionsLeft,
|
||||
onEditPermissionsRight,
|
||||
onOpenEntryLeft,
|
||||
onOpenEntryRight,
|
||||
onEditFileLeft,
|
||||
onEditFileRight,
|
||||
onOpenFileLeft,
|
||||
onOpenFileRight,
|
||||
onOpenFileWithSystemDefaultLeft,
|
||||
onOpenFileWithSystemDefaultRight,
|
||||
onOpenFileWithLeft,
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onExtractArchiveLeft,
|
||||
onExtractArchiveRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
onUploadExternalFilesRight,
|
||||
onUploadExternalFileListLeft,
|
||||
onUploadExternalFileListRight,
|
||||
onUploadExternalFolderLeft,
|
||||
onUploadExternalFolderRight,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user