Implement ZMODEM drag-and-drop file upload support in terminal
Adds SFTP fallback when rz is unavailable and cleans up drag-drop upload edge cases.
This commit is contained in:
@@ -89,7 +89,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No files to upload',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
|
||||
@@ -110,7 +110,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
|
||||
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
|
||||
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
|
||||
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
|
||||
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
|
||||
@@ -282,7 +282,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEM(PTY)上传',
|
||||
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.noFiles': '没有可上传的文件',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
|
||||
@@ -223,6 +223,36 @@ export const useTerminalBackend = () => {
|
||||
return bridge.selectDirectory(title, defaultPath);
|
||||
}, []);
|
||||
|
||||
const startZmodemDragDropUpload = useCallback(async (
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
path?: string;
|
||||
name: string;
|
||||
remoteName: string;
|
||||
data?: ArrayBuffer;
|
||||
}>,
|
||||
uploadCommand?: string,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startZmodemDragDropUpload) {
|
||||
return { success: false, error: "startZmodemDragDropUpload unavailable" };
|
||||
}
|
||||
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
}, []);
|
||||
|
||||
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId, options);
|
||||
}, []);
|
||||
|
||||
const onZmodemEvent = useCallback((
|
||||
sessionId: string,
|
||||
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
@@ -285,6 +315,9 @@ export const useTerminalBackend = () => {
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -330,6 +363,9 @@ export const useTerminalBackend = () => {
|
||||
receiveSerialYmodem,
|
||||
selectFile,
|
||||
selectDirectory,
|
||||
startZmodemDragDropUpload,
|
||||
cancelZmodem,
|
||||
onZmodemEvent,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type TerminalHostUpdate,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
@@ -470,6 +471,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
@@ -1162,6 +1164,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -1257,7 +1260,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
||||
|
||||
@@ -88,7 +88,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const ymodemActionEnabled = shouldEnableYmodemAction({
|
||||
isSerialConnection,
|
||||
status,
|
||||
@@ -159,7 +159,9 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
: remoteDragDropUsesZmodem
|
||||
? t("terminal.dragDrop.remoteZmodemMessage")
|
||||
: t("terminal.dragDrop.remoteSftpMessage")
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,15 @@ import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { logger } from "../../../lib/logger";
|
||||
import {
|
||||
buildZmodemDragDropFiles,
|
||||
buildZmodemDragDropUploadCommand,
|
||||
containsZmodemRzMissingMarker,
|
||||
createZmodemRzMissingToken,
|
||||
supportsZmodemDragDropSftpFallback,
|
||||
supportsZmodemTerminalDragDrop,
|
||||
type ZmodemDragDropFile,
|
||||
} from "../../../lib/zmodemDragDrop";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -14,6 +23,7 @@ import {
|
||||
interface UseTerminalDragDropOptions {
|
||||
host: Host;
|
||||
isLocalConnection: boolean;
|
||||
isNetworkDevice?: boolean;
|
||||
onOpenSftp?: TerminalProps["onOpenSftp"];
|
||||
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
@@ -23,37 +33,112 @@ interface UseTerminalDragDropOptions {
|
||||
t: (key: string) => string;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
|
||||
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
|
||||
onZmodemEvent?: (
|
||||
sessionId: string,
|
||||
cb: (event: { type: string; transferType?: string }) => void,
|
||||
) => () => void;
|
||||
startZmodemDragDropUpload?: (
|
||||
sessionId: string,
|
||||
files: ZmodemDragDropFile[],
|
||||
uploadCommand?: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
rzMissingFallbackTimeoutMs?: number;
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
}
|
||||
|
||||
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
|
||||
|
||||
export async function resolveTerminalDropUploadInitialPath(
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
|
||||
): Promise<string | undefined> {
|
||||
return resolveSftpInitialPath({ preferFreshBackend: true });
|
||||
}
|
||||
|
||||
function createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token,
|
||||
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
}: {
|
||||
sessionId: string;
|
||||
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
|
||||
token: string;
|
||||
timeoutMs?: number;
|
||||
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let buffer = "";
|
||||
let unsubscribeData: (() => void) | undefined;
|
||||
let unsubscribeZmodem: (() => void) | undefined;
|
||||
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
unsubscribeData?.();
|
||||
unsubscribeData = undefined;
|
||||
unsubscribeZmodem?.();
|
||||
unsubscribeZmodem = undefined;
|
||||
};
|
||||
|
||||
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
|
||||
settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
|
||||
buffer = `${buffer}${chunk}`.slice(-512);
|
||||
if (containsZmodemRzMissingMarker(buffer, token)) {
|
||||
settle("missing");
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
|
||||
if (event.type === "detect" && event.transferType === "upload") {
|
||||
settle("detected");
|
||||
}
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => settle("timeout"), timeoutMs);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
stop: () => settle("detected"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: Pick<
|
||||
UseTerminalDragDropOptions,
|
||||
| "host"
|
||||
| "isLocalConnection"
|
||||
| "isNetworkDevice"
|
||||
| "onOpenSftp"
|
||||
| "resolveSftpInitialPath"
|
||||
| "scrollToBottomAfterProgrammaticInput"
|
||||
| "sessionId"
|
||||
| "sessionRef"
|
||||
| "terminalBackend"
|
||||
| "rzMissingFallbackTimeoutMs"
|
||||
| "termRef"
|
||||
> & {
|
||||
dropEntries: DropEntry[];
|
||||
@@ -74,7 +159,67 @@ export async function handleTerminalDropEntries({
|
||||
return;
|
||||
}
|
||||
|
||||
if (onOpenSftp) {
|
||||
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
|
||||
entry.isDirectory || /[\\/]/.test(entry.relativePath)
|
||||
));
|
||||
|
||||
if (
|
||||
requiresSftpForDirectoryDrop
|
||||
&& onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
|
||||
const files = await buildZmodemDragDropFiles(dropEntries);
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files to upload");
|
||||
}
|
||||
|
||||
if (!terminalBackend.startZmodemDragDropUpload) {
|
||||
throw new Error("ZMODEM drag-drop upload is unavailable");
|
||||
}
|
||||
|
||||
const shouldFallbackToSftpWhenRzMissing = Boolean(
|
||||
onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
&& terminalBackend.onSessionData
|
||||
&& terminalBackend.cancelZmodem,
|
||||
);
|
||||
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
|
||||
? createZmodemRzMissingToken()
|
||||
: undefined;
|
||||
const rzMissingWatcher = rzMissingToken
|
||||
? createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token: rzMissingToken,
|
||||
timeoutMs: rzMissingFallbackTimeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const uploadCommand = rzMissingToken
|
||||
? buildZmodemDragDropUploadCommand(rzMissingToken)
|
||||
: undefined;
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
try {
|
||||
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
} catch (error) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw new Error(result.error || "ZMODEM upload failed");
|
||||
}
|
||||
|
||||
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
|
||||
if (fallbackResult === "missing" || fallbackResult === "timeout") {
|
||||
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
} else if (onOpenSftp) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
@@ -83,6 +228,7 @@ export async function handleTerminalDropEntries({
|
||||
export function useTerminalDragDrop({
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -91,6 +237,7 @@ export function useTerminalDragDrop({
|
||||
status,
|
||||
t,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: UseTerminalDragDropOptions) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
@@ -143,17 +290,22 @@ export function useTerminalDragDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
|
||||
const message = error instanceof Error && error.message === "No files to upload"
|
||||
? t("terminal.dragDrop.noFiles")
|
||||
: t("terminal.dragDrop.errorMessage");
|
||||
toast.error(message, t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,166 @@ const dropEntries: DropEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async () => {
|
||||
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
|
||||
let uploadedFiles: unknown;
|
||||
let uploadedSessionId: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
startZmodemDragDropUpload: async (sessionId, files) => {
|
||||
uploadedSessionId = sessionId;
|
||||
uploadedFiles = files;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadedSessionId, "session-1");
|
||||
assert.equal(Array.isArray(uploadedFiles), true);
|
||||
const files = uploadedFiles as Array<{ name: string; remoteName: string; data?: ArrayBuffer }>;
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, "report.txt");
|
||||
assert.equal(files[0].remoteName, "report.txt");
|
||||
assert.ok(files[0].data);
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
|
||||
let openedSftp = false;
|
||||
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {
|
||||
openedSftp = true;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
onZmodemEvent: (_sessionId, cb) => {
|
||||
zmodemCallback = cb;
|
||||
return () => {
|
||||
zmodemCallback = undefined;
|
||||
};
|
||||
},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
|
||||
zmodemCallback?.({ type: "detect", transferType: "upload" });
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
|
||||
let uploadCommandSeen: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host: { ...host, protocol: "serial" } as Host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
uploadCommandSeen = uploadCommand;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadCommandSeen, undefined);
|
||||
});
|
||||
|
||||
test("telnet terminal drop does not wrap rz with an SSH shell fallback", async () => {
|
||||
let uploadCommandSeen: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host: { ...host, protocol: "telnet" } as Host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
uploadCommandSeen = uploadCommand;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(uploadCommandSeen, undefined);
|
||||
});
|
||||
|
||||
test("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
@@ -33,6 +192,7 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
isNetworkDevice: true,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
@@ -57,6 +217,163 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
|
||||
assert.equal(openedSessionId, "session-1");
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let openedSessionId: string | undefined;
|
||||
let dataCallback: ((chunk: string) => void) | undefined;
|
||||
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
openedSessionId = sourceSessionId;
|
||||
},
|
||||
resolveSftpInitialPath: async (options) => {
|
||||
receivedOptions = options;
|
||||
return "/srv/app/current";
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
onSessionData: (_sessionId: string, cb: (chunk: string) => void) => {
|
||||
dataCallback = cb;
|
||||
return () => {
|
||||
dataCallback = undefined;
|
||||
};
|
||||
},
|
||||
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
cancelled = { sessionId, interrupt: options?.interrupt };
|
||||
},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
|
||||
assert.equal((uploadCommand ?? "").includes("\u001b]1337;NetcattyRzMissing="), false);
|
||||
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
|
||||
assert.ok(token);
|
||||
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.equal(openedEntries?.[0].relativePath, "report.txt");
|
||||
assert.equal(openedSessionId, "session-1");
|
||||
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: false });
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop falls back to SFTP when rz never starts", async () => {
|
||||
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
|
||||
let openedPath: string | undefined;
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, initialPath, pendingUploadEntries) => {
|
||||
openedPath = initialPath;
|
||||
openedEntries = pendingUploadEntries;
|
||||
},
|
||||
resolveSftpInitialPath: async (options) => {
|
||||
receivedOptions = options;
|
||||
return "/srv/app/current";
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
onSessionData: () => () => {},
|
||||
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
|
||||
cancelled = { sessionId, interrupt: options?.interrupt };
|
||||
},
|
||||
startZmodemDragDropUpload: async () => ({ success: true }),
|
||||
},
|
||||
rzMissingFallbackTimeoutMs: 1,
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
|
||||
});
|
||||
|
||||
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let zmodemStarted = false;
|
||||
|
||||
const folderEntries: DropEntry[] = [
|
||||
{
|
||||
file: null,
|
||||
relativePath: "docs",
|
||||
isDirectory: true,
|
||||
},
|
||||
{
|
||||
file: {
|
||||
name: "guide.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "docs/guide.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
];
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: folderEntries,
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, _initialPath, pendingUploadEntries) => {
|
||||
openedEntries = pendingUploadEntries;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
startZmodemDragDropUpload: async () => {
|
||||
zmodemStarted = true;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(zmodemStarted, false);
|
||||
assert.equal(openedEntries, folderEntries);
|
||||
});
|
||||
|
||||
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/srv/app/current",
|
||||
|
||||
@@ -14,6 +14,7 @@ const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
||||
|
||||
// Cached temp directory path
|
||||
let cachedTempDir = null;
|
||||
let tempFileCounter = 0;
|
||||
|
||||
/**
|
||||
* Get the Netcatty temp directory path
|
||||
@@ -143,8 +144,9 @@ async function clearTempDir() {
|
||||
function getTempFilePath(fileName) {
|
||||
const tempDir = getTempDir();
|
||||
const timestamp = Date.now();
|
||||
tempFileCounter = (tempFileCounter + 1) % 1000000;
|
||||
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
return path.join(tempDir, `${timestamp}_${safeFileName}`);
|
||||
return path.join(tempDir, `${timestamp}_${tempFileCounter}_${safeFileName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
electron/bridges/tempDirBridge.test.cjs
Normal file
19
electron/bridges/tempDirBridge.test.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
test("getTempFilePath is unique for duplicate names in the same millisecond", () => {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => 1234567890;
|
||||
try {
|
||||
const first = tempDirBridge.getTempFilePath("upload.txt");
|
||||
const second = tempDirBridge.getTempFilePath("upload.txt");
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(path.basename(first).endsWith("_upload.txt"), true);
|
||||
assert.equal(path.basename(second).endsWith("_upload.txt"), true);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
@@ -112,9 +112,15 @@ function createZmodemSentry(opts) {
|
||||
// After aborting, suppress incoming data briefly so residual ZMODEM
|
||||
// protocol bytes from the remote don't flood the terminal as garbage.
|
||||
let cooldownUntil = 0;
|
||||
/** Drag-drop upload queued before auto-triggering rz on the PTY. */
|
||||
let dragDropUpload = null;
|
||||
let dragDropStartTimer = null;
|
||||
const COOLDOWN_MS = 2000;
|
||||
const ECHO_TTL_MS = 1500;
|
||||
const ECHO_MAX_BYTES = 256;
|
||||
const dragDropStartTimeoutMs = Number.isFinite(opts.dragDropStartTimeoutMs)
|
||||
? Math.max(0, opts.dragDropStartTimeoutMs)
|
||||
: 15000;
|
||||
|
||||
function prunePendingEchoes(now = Date.now()) {
|
||||
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
|
||||
@@ -257,6 +263,39 @@ function createZmodemSentry(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupDragDropTempFiles(upload) {
|
||||
if (!upload?.tempPaths?.length) return;
|
||||
for (const tempPath of upload.tempPaths) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearDragDropUpload() {
|
||||
clearDragDropStartTimer();
|
||||
if (dragDropUpload) {
|
||||
cleanupDragDropTempFiles(dragDropUpload);
|
||||
dragDropUpload = null;
|
||||
}
|
||||
}
|
||||
|
||||
function takeDragDropUpload() {
|
||||
clearDragDropStartTimer();
|
||||
const upload = dragDropUpload;
|
||||
dragDropUpload = null;
|
||||
return upload;
|
||||
}
|
||||
|
||||
function clearDragDropStartTimer() {
|
||||
if (dragDropStartTimer) {
|
||||
clearTimeout(dragDropStartTimer);
|
||||
dragDropStartTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRemoteInterruptAfterCancel(transferRole) {
|
||||
if (cancelInterruptTimer) {
|
||||
clearTimeout(cancelInterruptTimer);
|
||||
@@ -279,6 +318,38 @@ function createZmodemSentry(opts) {
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function interruptPendingDragDropCommand() {
|
||||
ignoreDetectionUntil = Date.now() + 1000;
|
||||
sendExtraAbortBytes();
|
||||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||||
|
||||
if (cancelInterruptTimer) {
|
||||
clearTimeout(cancelInterruptTimer);
|
||||
cancelInterruptTimer = null;
|
||||
}
|
||||
cancelInterruptTimer = setTimeout(() => {
|
||||
cancelInterruptTimer = null;
|
||||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function scheduleDragDropStartTimeout() {
|
||||
clearDragDropStartTimer();
|
||||
if (!dragDropStartTimeoutMs) return;
|
||||
dragDropStartTimer = setTimeout(() => {
|
||||
dragDropStartTimer = null;
|
||||
if (!dragDropUpload || active) return;
|
||||
console.warn(`[ZMODEM][${label}] Drag-drop upload did not start before timeout; cancelling pending upload`);
|
||||
interruptPendingDragDropCommand();
|
||||
clearDragDropUpload();
|
||||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: "ZMODEM drag-drop upload did not start",
|
||||
});
|
||||
}, dragDropStartTimeoutMs);
|
||||
}
|
||||
|
||||
function isIgnorableSendKeepaliveError(errMsg) {
|
||||
return Boolean(
|
||||
active &&
|
||||
@@ -351,6 +422,9 @@ function createZmodemSentry(opts) {
|
||||
// underlying transport's write buffer is full.
|
||||
const transferOpts = {
|
||||
...opts,
|
||||
getDragDropUpload: () => dragDropUpload,
|
||||
takeDragDropUpload,
|
||||
clearDragDropUpload,
|
||||
waitForDrain: () => {
|
||||
if (!_needsDrain) return Promise.resolve();
|
||||
_needsDrain = false;
|
||||
@@ -490,7 +564,7 @@ function createZmodemSentry(opts) {
|
||||
},
|
||||
|
||||
/** Cancel the current ZMODEM transfer. */
|
||||
cancel() {
|
||||
cancel(options = {}) {
|
||||
if (currentZSession) {
|
||||
const transferRole = currentZSession.type;
|
||||
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
|
||||
@@ -504,6 +578,48 @@ function createZmodemSentry(opts) {
|
||||
sessionId,
|
||||
error: "Transfer cancelled",
|
||||
});
|
||||
} else if (dragDropUpload && options.interrupt !== false) {
|
||||
interruptPendingDragDropCommand();
|
||||
}
|
||||
clearDragDropUpload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue files from a terminal drag-drop and auto-trigger rz on the PTY.
|
||||
* @param {{ filePaths: string[], remoteNames?: string[], uploadCommand?: string, tempPaths?: string[] }} payload
|
||||
*/
|
||||
queueDragDropUpload(payload) {
|
||||
if (active) {
|
||||
throw new Error("ZMODEM transfer already in progress");
|
||||
}
|
||||
const filePaths = payload?.filePaths;
|
||||
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
||||
throw new Error("No files to upload");
|
||||
}
|
||||
if (dragDropUpload) {
|
||||
throw new Error("ZMODEM drag-drop upload already pending");
|
||||
}
|
||||
|
||||
const uploadCommand = payload.uploadCommand || "rz\r";
|
||||
dragDropUpload = {
|
||||
filePaths,
|
||||
remoteNames: payload.remoteNames,
|
||||
uploadCommand,
|
||||
tempPaths: payload.tempPaths || [],
|
||||
};
|
||||
|
||||
const cmdBuf = Buffer.from(uploadCommand, "utf8");
|
||||
const pendingEchoCount = pendingEchoes.length;
|
||||
try {
|
||||
rememberOutgoingEcho(cmdBuf);
|
||||
pendingTerminalSuppression = Buffer.from(uploadCommand.replace(/\r$/, ""));
|
||||
writeToRemote(cmdBuf);
|
||||
scheduleDragDropStartTimeout();
|
||||
} catch (err) {
|
||||
pendingEchoes.length = pendingEchoCount;
|
||||
pendingTerminalSuppression = null;
|
||||
clearDragDropUpload();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -561,6 +677,18 @@ async function handleUpload(zsession, opts) {
|
||||
const { BrowserWindow, dialog } = getElectron();
|
||||
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const dragDrop = opts.takeDragDropUpload?.() ?? opts.getDragDropUpload?.();
|
||||
let filePaths;
|
||||
let allNames;
|
||||
let dragDropTempPaths = [];
|
||||
|
||||
if (dragDrop?.filePaths?.length) {
|
||||
filePaths = dragDrop.filePaths;
|
||||
allNames = Array.isArray(dragDrop.remoteNames) && dragDrop.remoteNames.length === filePaths.length
|
||||
? dragDrop.remoteNames
|
||||
: filePaths.map((fp) => path.basename(fp));
|
||||
dragDropTempPaths = dragDrop.tempPaths || [];
|
||||
} else {
|
||||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||||
const result = await dialog.showOpenDialog(win || undefined, {
|
||||
properties: ["openFile", "multiSelections"],
|
||||
@@ -573,10 +701,12 @@ async function handleUpload(zsession, opts) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
filePaths = result.filePaths;
|
||||
allNames = filePaths.map((fp) => path.basename(fp));
|
||||
}
|
||||
|
||||
const allNames = filePaths.map((fp) => path.basename(fp));
|
||||
try {
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
@@ -709,6 +839,18 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (dragDropTempPaths.length) {
|
||||
for (const tempPath of dragDropTempPaths) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createZmodemSentry, buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
|
||||
|
||||
const never = () => { throw new Error("resolver should not be called"); };
|
||||
|
||||
@@ -72,3 +75,153 @@ test("buildModeRestores strips trailing slashes and dedupes duplicate basenames"
|
||||
[{ path: "/srv/x", mode: "600" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("queued drag-drop upload keeps temp files until cancel", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const tempPath = path.join(tempDir, "upload.txt");
|
||||
fs.writeFileSync(tempPath, "payload");
|
||||
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "session-1",
|
||||
onData: () => {},
|
||||
writeToRemote: () => true,
|
||||
getWebContents: () => null,
|
||||
});
|
||||
|
||||
sentry.queueDragDropUpload({
|
||||
filePaths: [tempPath],
|
||||
remoteNames: ["upload.txt"],
|
||||
tempPaths: [tempPath],
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(tempPath), true);
|
||||
sentry.cancel();
|
||||
assert.equal(fs.existsSync(tempPath), false);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("queued drag-drop upload interrupts the remote command when cancelled before detect", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const tempPath = path.join(tempDir, "upload.txt");
|
||||
fs.writeFileSync(tempPath, "payload");
|
||||
const writes = [];
|
||||
let interrupted = false;
|
||||
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "session-1",
|
||||
onData: () => {},
|
||||
writeToRemote: (buf) => {
|
||||
writes.push(Buffer.from(buf));
|
||||
return true;
|
||||
},
|
||||
interruptRemote: () => {
|
||||
interrupted = true;
|
||||
},
|
||||
getWebContents: () => null,
|
||||
dragDropStartTimeoutMs: 0,
|
||||
});
|
||||
|
||||
sentry.queueDragDropUpload({
|
||||
filePaths: [tempPath],
|
||||
remoteNames: ["upload.txt"],
|
||||
tempPaths: [tempPath],
|
||||
});
|
||||
sentry.cancel();
|
||||
|
||||
assert.equal(fs.existsSync(tempPath), false);
|
||||
assert.equal(interrupted, true);
|
||||
assert.equal(writes[0].toString("utf8"), "rz\r");
|
||||
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("queued drag-drop upload cleans temp files when rz never starts", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const tempPath = path.join(tempDir, "upload.txt");
|
||||
fs.writeFileSync(tempPath, "payload");
|
||||
const writes = [];
|
||||
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "session-1",
|
||||
onData: () => {},
|
||||
writeToRemote: (buf) => {
|
||||
writes.push(Buffer.from(buf));
|
||||
return true;
|
||||
},
|
||||
getWebContents: () => null,
|
||||
dragDropStartTimeoutMs: 1,
|
||||
});
|
||||
|
||||
sentry.queueDragDropUpload({
|
||||
filePaths: [tempPath],
|
||||
remoteNames: ["upload.txt"],
|
||||
tempPaths: [tempPath],
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(fs.existsSync(tempPath), false);
|
||||
assert.equal(writes[0].toString("utf8"), "rz\r");
|
||||
assert.deepEqual([...writes[1]], [0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("queued drag-drop upload rejects a second pending upload", () => {
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "session-1",
|
||||
onData: () => {},
|
||||
writeToRemote: () => true,
|
||||
getWebContents: () => null,
|
||||
});
|
||||
|
||||
sentry.queueDragDropUpload({
|
||||
filePaths: ["/tmp/first.txt"],
|
||||
remoteNames: ["first.txt"],
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => sentry.queueDragDropUpload({
|
||||
filePaths: ["/tmp/second.txt"],
|
||||
remoteNames: ["second.txt"],
|
||||
}),
|
||||
/already pending/,
|
||||
);
|
||||
sentry.cancel({ interrupt: false });
|
||||
});
|
||||
|
||||
test("queued drag-drop upload cleans temp files when command write fails", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const firstTempPath = path.join(tempDir, "first.txt");
|
||||
const secondTempPath = path.join(tempDir, "second.txt");
|
||||
fs.writeFileSync(firstTempPath, "first");
|
||||
fs.writeFileSync(secondTempPath, "second");
|
||||
|
||||
const sentry = createZmodemSentry({
|
||||
sessionId: "session-1",
|
||||
onData: () => {},
|
||||
writeToRemote: () => {
|
||||
throw new Error("socket closed");
|
||||
},
|
||||
getWebContents: () => null,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => sentry.queueDragDropUpload({
|
||||
filePaths: [firstTempPath],
|
||||
remoteNames: ["first.txt"],
|
||||
tempPaths: [firstTempPath],
|
||||
}),
|
||||
/socket closed/,
|
||||
);
|
||||
assert.equal(fs.existsSync(firstTempPath), false);
|
||||
|
||||
assert.throws(
|
||||
() => sentry.queueDragDropUpload({
|
||||
filePaths: [secondTempPath],
|
||||
remoteNames: ["second.txt"],
|
||||
tempPaths: [secondTempPath],
|
||||
}),
|
||||
/socket closed/,
|
||||
);
|
||||
assert.equal(fs.existsSync(secondTempPath), false);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -199,7 +199,62 @@ function createBridgeRegistrar(context) {
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (session?.zmodemSentry) {
|
||||
session.zmodemSentry.cancel();
|
||||
session.zmodemSentry.cancel(payload.options);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:zmodem:drag-drop-upload", async (_event, payload) => {
|
||||
const { sessionId, files, uploadCommand } = payload || {};
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.zmodemSentry?.queueDragDropUpload) {
|
||||
return { success: false, error: "ZMODEM upload is not available for this session" };
|
||||
}
|
||||
if (session.zmodemSentry.isActive?.()) {
|
||||
return { success: false, error: "ZMODEM transfer already in progress" };
|
||||
}
|
||||
|
||||
const filePaths = [];
|
||||
const remoteNames = [];
|
||||
const tempPaths = [];
|
||||
|
||||
for (const file of files || []) {
|
||||
if (!file?.name) continue;
|
||||
let localPath = file.path;
|
||||
if (!localPath && file.data) {
|
||||
localPath = tempDirBridge.getTempFilePath(file.name);
|
||||
await fs.promises.writeFile(localPath, Buffer.from(file.data));
|
||||
tempPaths.push(localPath);
|
||||
}
|
||||
if (!localPath) continue;
|
||||
try {
|
||||
await fs.promises.access(localPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
filePaths.push(localPath);
|
||||
remoteNames.push(file.remoteName || path.basename(localPath));
|
||||
}
|
||||
|
||||
if (!filePaths.length) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: "No readable files to upload" };
|
||||
}
|
||||
|
||||
try {
|
||||
session.zmodemSentry.queueDragDropUpload({
|
||||
filePaths,
|
||||
remoteNames,
|
||||
uploadCommand: uploadCommand || "rz\r",
|
||||
tempPaths,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
for (const tempPath of tempPaths) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
|
||||
}
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -183,8 +183,15 @@ function createPreloadApi(ctx) {
|
||||
zmodemListeners.get(sessionId).add(cb);
|
||||
return () => zmodemListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
cancelZmodem: (sessionId, options) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId, options });
|
||||
},
|
||||
startZmodemDragDropUpload: (sessionId, files, uploadCommand) => {
|
||||
return ipcRenderer.invoke("netcatty:zmodem:drag-drop-upload", {
|
||||
sessionId,
|
||||
files,
|
||||
uploadCommand,
|
||||
});
|
||||
},
|
||||
onZmodemOverwriteRequest: (sessionId, cb) => {
|
||||
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());
|
||||
|
||||
86
lib/zmodemDragDrop.ts
Normal file
86
lib/zmodemDragDrop.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { DropEntry } from "./sftpFileUtils";
|
||||
import { getPathForFile } from "./sftpFileUtils";
|
||||
import type { Host } from "../types";
|
||||
|
||||
const ZMODEM_RZ_MISSING_MARKER_PREFIX = "\x1b]1337;NetcattyRzMissing=";
|
||||
const ZMODEM_RZ_MISSING_MARKER_SUFFIX = "\x07";
|
||||
|
||||
export type ZmodemDragDropFile = {
|
||||
path?: string;
|
||||
name: string;
|
||||
remoteName: string;
|
||||
data?: ArrayBuffer;
|
||||
};
|
||||
|
||||
export function supportsZmodemTerminalDragDrop(
|
||||
host: Host,
|
||||
isNetworkDevice = false,
|
||||
): boolean {
|
||||
if (host.protocol === "local" || isNetworkDevice) return false;
|
||||
if (host.moshEnabled || host.etEnabled) return true;
|
||||
return (
|
||||
host.protocol === "ssh" ||
|
||||
host.protocol === "telnet" ||
|
||||
host.protocol === "serial"
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsZmodemDragDropSftpFallback(host: Host): boolean {
|
||||
return host.protocol === "ssh" || Boolean(host.moshEnabled || host.etEnabled);
|
||||
}
|
||||
|
||||
export function getZmodemRemoteName(relativePath: string, fallbackName: string): string {
|
||||
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!normalized) return fallbackName;
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] || fallbackName;
|
||||
}
|
||||
|
||||
function quotePosixShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function createZmodemRzMissingToken(): string {
|
||||
return `rz-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function buildZmodemDragDropUploadCommand(rzMissingToken: string): string {
|
||||
const markerFormat = `\\033]1337;NetcattyRzMissing=${rzMissingToken}\\007`;
|
||||
const script = `if command -v rz >/dev/null 2>&1; then exec rz; else printf ${quotePosixShellArg(markerFormat)}; fi`;
|
||||
return `sh -lc ${quotePosixShellArg(script)}\r`;
|
||||
}
|
||||
|
||||
export function containsZmodemRzMissingMarker(chunk: string, rzMissingToken: string): boolean {
|
||||
return chunk.includes(`${ZMODEM_RZ_MISSING_MARKER_PREFIX}${rzMissingToken}${ZMODEM_RZ_MISSING_MARKER_SUFFIX}`);
|
||||
}
|
||||
|
||||
export async function buildZmodemDragDropFiles(
|
||||
dropEntries: DropEntry[],
|
||||
): Promise<ZmodemDragDropFile[]> {
|
||||
const files: ZmodemDragDropFile[] = [];
|
||||
|
||||
for (const entry of dropEntries) {
|
||||
if (entry.isDirectory || !entry.file) continue;
|
||||
|
||||
const remoteName = getZmodemRemoteName(entry.relativePath, entry.file.name);
|
||||
const localPath = getPathForFile(entry.file);
|
||||
|
||||
if (localPath) {
|
||||
files.push({
|
||||
path: localPath,
|
||||
name: entry.file.name,
|
||||
remoteName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await entry.file.arrayBuffer();
|
||||
files.push({
|
||||
name: entry.file.name,
|
||||
remoteName,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
12
types/global/netcatty-bridge-session.d.ts
vendored
12
types/global/netcatty-bridge-session.d.ts
vendored
@@ -244,7 +244,17 @@ declare global {
|
||||
error?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
cancelZmodem?(sessionId: string, options?: { interrupt?: boolean }): void;
|
||||
startZmodemDragDropUpload?(
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
path?: string;
|
||||
name: string;
|
||||
remoteName: string;
|
||||
data?: ArrayBuffer;
|
||||
}>,
|
||||
uploadCommand?: string,
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
onZmodemOverwriteRequest?(
|
||||
sessionId: string,
|
||||
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
|
||||
|
||||
Reference in New Issue
Block a user