mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
Merge pull request #77 from binaricat/copilot/add-sftp-reconnect-feature
Add SFTP reconnect UI overlay with spinner
This commit is contained in:
@@ -542,6 +542,12 @@ const en: Messages = {
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
'sftp.reconnected': 'Connection restored',
|
||||
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
|
||||
@@ -780,6 +780,12 @@ const zhCN: Messages = {
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
'sftp.reconnected': '连接已恢复',
|
||||
'sftp.error.reconnectFailed': '重连失败,请重试。',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
|
||||
+128
-2
@@ -325,6 +325,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
|
||||
// Reconnect state
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const reconnectingRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
// Rename dialog state
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
|
||||
@@ -536,6 +542,40 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
// Check if an error indicates a stale/lost SFTP session
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("eof")
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle session error - triggers auto-reconnect
|
||||
const handleSessionError = useCallback(() => {
|
||||
if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts
|
||||
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
setReconnecting(false);
|
||||
reconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear stale session reference
|
||||
sftpIdRef.current = null;
|
||||
|
||||
// Set reconnecting state
|
||||
reconnectingRef.current = true;
|
||||
reconnectAttemptsRef.current++;
|
||||
setReconnecting(true);
|
||||
}, [t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
const requestId = ++loadSeqRef.current;
|
||||
@@ -569,6 +609,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setSelectedFiles(new Set());
|
||||
} catch (e) {
|
||||
if (loadSeqRef.current !== requestId) return;
|
||||
|
||||
// Check if this is a session error that can trigger auto-reconnect
|
||||
if (!isLocalSession && isSessionError(e) && files.length > 0) {
|
||||
logger.info("[SFTP] Session lost, attempting to reconnect...");
|
||||
handleSessionError();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error("Failed to load files", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
@@ -581,7 +629,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t],
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -615,6 +663,71 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
};
|
||||
}, [closeSftpSession]);
|
||||
|
||||
// Auto-reconnect effect
|
||||
useEffect(() => {
|
||||
if (!reconnecting || !reconnectingRef.current || isLocalSession) return;
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
// Small delay before reconnecting
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (!reconnectingRef.current) return; // May have been cancelled
|
||||
|
||||
try {
|
||||
// Re-establish SFTP connection
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
hostname: credentials.hostname,
|
||||
username: credentials.username || "root",
|
||||
port: credentials.port || 22,
|
||||
password: credentials.password,
|
||||
privateKey: credentials.privateKey,
|
||||
certificate: credentials.certificate,
|
||||
passphrase: credentials.passphrase,
|
||||
publicKey: credentials.publicKey,
|
||||
keyId: credentials.keyId,
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
|
||||
// Refresh current directory
|
||||
const list = await listSftp(sftpId, currentPath);
|
||||
dirCacheRef.current.set(`${host.id}::${currentPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setFiles(list);
|
||||
setSelectedFiles(new Set());
|
||||
|
||||
// Reconnect successful
|
||||
reconnectingRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setReconnecting(false);
|
||||
toast.success(t("sftp.reconnected"), "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SFTP] Reconnect failed", e);
|
||||
// Check if we can retry
|
||||
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
// Trigger another attempt
|
||||
sftpIdRef.current = null;
|
||||
reconnectingRef.current = false; // Reset to allow handleSessionError to work
|
||||
handleSessionError();
|
||||
} else {
|
||||
// Max retries reached
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
attemptReconnect();
|
||||
}, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Check if we need to reinitialize (either first time or initialPath changed)
|
||||
@@ -1703,7 +1816,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
className="h-7 w-7"
|
||||
onClick={() => loadFiles(currentPath, { force: true })}
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
<RefreshCw size={14} className={cn((loading || reconnecting) && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
{/* Editable Breadcrumbs */}
|
||||
@@ -1897,6 +2010,19 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay */}
|
||||
{reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={48} className="mb-3 opacity-50" />
|
||||
|
||||
+15
-2
@@ -1291,7 +1291,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : pane.error ? (
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{pane.error}</span>
|
||||
@@ -1341,12 +1341,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && (
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
|
||||
{pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
|
||||
<Loader2 size={32} className="animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
|
||||
Generated
+1
@@ -7212,6 +7212,7 @@
|
||||
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.4.0",
|
||||
"builder-util": "26.3.4",
|
||||
|
||||
Reference in New Issue
Block a user