Compare commits

...

11 Commits

Author SHA1 Message Date
陈大猫
a24e27586a Merge pull request #257 from binaricat/fix/issue-254-sftp-bugs
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve multiple SFTP bugs (#254)
2026-03-04 13:20:14 +08:00
bincxz
ca24d3861c fix: limit depth guard to symlink dirs only, allow deep real dirs
Real directories cannot form cycles, so remove depth limit for them.
Only track and limit symlink-directory nesting (MAX_SYMLINK_DEPTH=32)
to prevent cycles like `loop -> .` while allowing legitimate deep
directory structures to download without error.
2026-03-04 13:07:52 +08:00
bincxz
eb3b99b164 fix: cancel active child transfer directly from cancelTask
Add activeChildTransferIdsRef (Map<parentId, childId>) to track the
currently in-flight child transfer for directory downloads. cancelTask
now cancels both the parent ID and the active child transfer ID,
making folder download cancellation immediate and reliable.
2026-03-04 12:56:43 +08:00
bincxz
681f4cb3df fix: fail on depth exceeded + hide folder download for local sessions
- Throw error when MAX_RECURSION_DEPTH exceeded instead of silently
  returning, so download is marked failed with a clear message (P1)
- Hide folder download context menu item for local sessions where
  handleDownload only supports files (P2)
2026-03-04 12:05:59 +08:00
bincxz
6fae312981 fix: add max depth limit to prevent symlink cycle infinite recursion
SFTP doesn't expose realpath, so raw path strings can't detect cycles
like `loop -> .` that produce unique paths each level. Add a hard
MAX_RECURSION_DEPTH=32 guard alongside the existing visitedPaths set
to reliably prevent unbounded recursion.
2026-03-04 11:56:11 +08:00
bincxz
ed199eae8c fix: prevent symlink cycle recursion + handle undefined stream result
- Add visitedPaths Set to prevent infinite recursion from symlink
  cycles (e.g. symlink to parent directory)
- Handle undefined result from startStreamTransfer (bridge unavailable)
  by rejecting immediately instead of hanging indefinitely
2026-03-04 11:45:08 +08:00
bincxz
e38af76bfd fix: handle child transfer result errors + precise mkdir error handling
- Handle resolved result.error from startStreamTransfer to prevent
  hung Promises on cancellation (P1)
- Only ignore EEXIST from subdirectory mkdirLocal, propagate other
  errors like permission failures (P2)
2026-03-04 11:34:42 +08:00
bincxz
1726917db0 fix: abort in-flight child transfer on cancel + handle symlink dirs
- Cancel active child transfer from onProgress callback immediately
  when parent folder download is cancelled (P1)
- Handle symlink -> directory entries in recursive descent so they
  are treated as directories instead of files (P2)
2026-03-04 11:26:39 +08:00
bincxz
1712762305 fix: address code review feedback
- Revert mkdirLocal to safe original (no silent file deletion)
- Move EEXIST handling to download-overwrite flow only (deleteLocalFile)
- Add cancellation support for recursive folder downloads:
  - Track active child transfer ID for cancellation
  - Check cancelledTransferIdsRef between files
  - Cancel in-flight child transfer when parent is cancelled
2026-03-04 11:17:05 +08:00
bincxz
5d75f1acd4 fix: resolve multiple SFTP bugs (#254)
- Fix new folder input not resetting after deletion (SftpPaneToolbar/View)
- Fix folder download stuck at 95% by replacing simulated progress with real child-file progress tracking (useSftpTransfers)
- Add download menu item for directories in SFTP modal context menu (SftpModalFileList)
- Implement recursive folder download in SFTP modal with real-time progress (useSftpModalTransfers, SFTPModal)
- Fix mkdirLocal EEXIST error when target path is an existing file (localFsBridge)
- Close settings window when main window is minimized to tray (windowManager)

Closes #254
2026-03-04 11:04:34 +08:00
陈大猫
18b77f9a87 fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat (#255)
* fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat\n\nSplit linux-arm64 out of the build matrix into a dedicated job that\nruns inside a debian:buster container (GLIBC 2.28) on the ARM64 runner.\nThis ensures the compiled node-pty native module is compatible with\nolder distros like UOS/Deepin.\n\nCloses #253

* fix(ci): use archive.debian.org for EOL Buster repos

* fix(ci): switch to debian:bullseye for Python 3.9 + GLIBC 2.31 compat\n\nBuster's Python 3.7 is too old for node-gyp@11 (walrus operator).\nBullseye provides Python 3.9 and GLIBC 2.31 which is still below\nthe critical 2.34 boundary (libpthread merge into libc).
2026-03-04 10:23:35 +08:00
9 changed files with 452 additions and 97 deletions

View File

@@ -28,9 +28,6 @@ jobs:
- name: linux-x64
os: ubuntu-latest
pack_script: pack:linux-x64
- name: linux-arm64
os: ubuntu-24.04-arm
pack_script: pack:linux-arm64
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -62,13 +59,6 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
# On ARM64 runners, electron-builder's post-build @electron/rebuild incorrectly
# tries to restore native modules to x64, but the ARM g++ doesn't support -m64.
# Setting npm_config_arch=arm64 ensures node-gyp uses the correct host architecture.
- name: Set native module arch for ARM64
if: matrix.name == 'linux-arm64'
run: echo "npm_config_arch=arm64" >> "$GITHUB_ENV"
- name: Build package
env:
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
@@ -89,10 +79,64 @@ jobs:
release/*.tar.gz
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: build
needs: [build, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write

View File

@@ -132,6 +132,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -158,6 +159,9 @@ export const useSftpTransfers = ({
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
@@ -254,6 +258,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -275,6 +280,9 @@ export const useSftpTransfers = ({
throw new Error("No source connection");
}
// Track bytes completed so far in this directory (including subdirectories)
let completedBytesInDir = 0;
for (const file of files) {
if (file.name === "..") continue;
@@ -295,6 +303,13 @@ export const useSftpTransfers = ({
};
if (file.type === "directory") {
// For subdirectories, create a nested progress tracker
let subDirCompletedBytes = 0;
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
subDirCompletedBytes = subCompleted;
// Report to parent: our completed + subdirectory's (completed + in-progress)
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
};
await transferDirectory(
childTask,
sourceSftpId,
@@ -304,8 +319,14 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onSubDirChildProgress,
);
completedBytesInDir += subDirCompletedBytes;
} else {
// For files, report streaming progress
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
onChildProgress?.(completedBytesInDir, transferred, total, speed);
};
await transferFile(
childTask,
sourceSftpId,
@@ -315,7 +336,12 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onFileStreamProgress,
);
// After file completes, add its bytes to completed total
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
completedBytesInDir += childSize;
onChildProgress?.(completedBytesInDir, 0, 0, 0);
}
}
};
@@ -398,7 +424,7 @@ export const useSftpTransfers = ({
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer || task.isDirectory) {
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
@@ -486,6 +512,24 @@ export const useSftpTransfers = ({
}
if (task.isDirectory) {
// Track real progress for directory transfers:
// completedBytes = sum of all finished child files
// + currentFileTransferred = in-progress bytes of the currently transferring file
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
const totalProgress = completedBytes + currentFileTransferred;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
return {
...t,
transferredBytes: Math.max(t.transferredBytes, totalProgress),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
}),
);
};
await transferDirectory(
task,
sourceSftpId,
@@ -495,6 +539,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
onChildProgress,
);
} else {
await transferFile(
@@ -595,14 +640,14 @@ export const useSftpTransfers = ({
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
@@ -638,11 +683,11 @@ export const useSftpTransfers = ({
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {

View File

@@ -403,6 +403,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);

View File

@@ -329,18 +329,26 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
) : (
<>
{isNavigableDirectory && (
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>

View File

@@ -40,6 +40,8 @@ interface UseSftpModalTransfersParams {
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
listSftp?: (sftpId: string, path: string) => Promise<RemoteFile[]>;
deleteLocalFile?: (path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress: (
sftpId: string,
@@ -113,6 +115,8 @@ export const useSftpModalTransfers = ({
setLoading,
t,
useCompressedUpload = false,
listSftp,
deleteLocalFile,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
@@ -127,6 +131,9 @@ export const useSftpModalTransfers = ({
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
// Track active child transfer IDs for directory downloads (parentId -> childId)
const activeChildTransferIdsRef = useRef<Map<string, string>>(new Map());
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
@@ -157,7 +164,7 @@ export const useSftpModalTransfers = ({
onComplete || (() => { }),
onError || (() => { })
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
@@ -320,8 +327,8 @@ export const useSftpModalTransfers = ({
const [folderName, phase] = newName.split('|');
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
displayName = `${folderName} (${phaseLabel})`;
}
setUploadTasks(prev =>
@@ -410,12 +417,236 @@ export const useSftpModalTransfers = ({
return;
}
// For remote SFTP files, use streaming download with save dialog
// For remote SFTP files/directories, use streaming download with save dialog
if (!showSaveDialog || !startStreamTransfer) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Check if this is a directory download
const isDirectory = file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory');
if (isDirectory) {
// For directories, download recursively
if (!listSftp) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Show save dialog to get target path (the saved "file" becomes the folder path)
const targetPath = await showSaveDialog(file.name);
if (!targetPath) return;
const sftpId = await ensureSftp();
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Track the currently active child transfer ID for cancellation
let activeChildTransferId: string | null = null;
const setActiveChild = (childId: string | null) => {
activeChildTransferId = childId;
if (childId) {
activeChildTransferIdsRef.current.set(transferId, childId);
} else {
activeChildTransferIdsRef.current.delete(transferId);
}
};
// Create download task for progress display
const downloadTask: TransferTask = {
id: transferId,
fileName: file.name,
status: "downloading",
progress: 0,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
direction: "download",
isDirectory: true,
};
setUploadTasks(prev => [...prev, downloadTask]);
try {
// Safely create target directory.
// showSaveDialog "Replace" may leave a file (not directory) at the path,
// so we remove it first — ONLY in this explicit overwrite context.
try {
await createUploadBridge.mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (isEEXIST && deleteLocalFile) {
// Path exists as a file (from save dialog replace), remove it and retry
await deleteLocalFile(targetPath);
await createUploadBridge.mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
// Recursively download directory contents
let completedBytes = 0;
// Track visited remote paths to prevent symlink cycles
const visitedPaths = new Set<string>();
// Max symlink-directory nesting depth to prevent cycles (only applies to symlinks)
const MAX_SYMLINK_DEPTH = 32;
const downloadDir = async (remotePath: string, localPath: string, symlinkDepth = 0): Promise<void> => {
// Prevent revisiting the same path
if (visitedPaths.has(remotePath)) return;
visitedPaths.add(remotePath);
// Check if transfer was cancelled
if (cancelledTransferIdsRef.current.has(transferId)) {
throw new Error('Transfer cancelled');
}
const entries = await listSftp(sftpId, remotePath);
for (const entry of entries) {
if (entry.name === '..' || entry.name === '.') continue;
// Check cancellation between files
if (cancelledTransferIdsRef.current.has(transferId)) {
// Cancel the active child transfer if any
if (activeChildTransferId && cancelTransfer) {
try { await cancelTransfer(activeChildTransferId); } catch { /* ignore */ }
}
throw new Error('Transfer cancelled');
}
const remoteEntryPath = joinPath(remotePath, entry.name);
const localEntryPath = `${localPath}/${entry.name}`;
const isRealDir = entry.type === 'directory';
const isSymlinkDir = entry.type === 'symlink' && entry.linkTarget === 'directory';
if (isRealDir || isSymlinkDir) {
// Only symlink directories can form cycles; enforce depth limit for them
if (isSymlinkDir && symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error('Maximum symlink directory depth exceeded (possible symlink cycle)');
}
try {
await createUploadBridge.mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
// Only ignore EEXIST (directory already exists), propagate other errors
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (!isEEXIST) throw mkdirErr;
}
await downloadDir(remoteEntryPath, localEntryPath, isSymlinkDir ? symlinkDepth + 1 : symlinkDepth);
} else {
// Download individual file
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferId = childTransferId;
setActiveChild(childTransferId);
const entrySize = typeof entry.size === 'number' ? entry.size : parseInt(String(entry.size), 10) || 0;
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: remoteEntryPath,
targetPath: localEntryPath,
sourceType: 'sftp',
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: entrySize,
},
// onProgress - update parent task
(transferred, total, speed) => {
if (cancelledTransferIdsRef.current.has(transferId)) {
// Actively cancel the in-flight child transfer
if (cancelTransfer) {
cancelTransfer(childTransferId).catch(() => { /* ignore */ });
}
return;
}
const totalProgress = completedBytes + transferred;
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: Math.max(task.transferredBytes, totalProgress),
totalBytes: Math.max(task.totalBytes, totalProgress, completedBytes + total),
progress: (() => {
const effectiveTotal = Math.max(task.totalBytes, completedBytes + total);
if (effectiveTotal <= 0) return task.progress;
const percent = (totalProgress / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 99));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
);
},
// onComplete
() => {
completedBytes += entrySize;
setActiveChild(null);
resolve();
},
// onError
(error) => {
setActiveChild(null);
reject(new Error(error));
}
).then((result) => {
// Handle resolved result with error (e.g. cancellation)
if (result === undefined) {
setActiveChild(null);
reject(new Error('Stream transfer unavailable'));
} else if (result?.error) {
setActiveChild(null);
reject(new Error(result.error));
}
}).catch(reject);
});
}
}
};
await downloadDir(fullPath, targetPath);
// Mark as completed
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: completedBytes,
totalBytes: completedBytes,
speed: 0,
}
: task
)
);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (e) {
const errorMsg = e instanceof Error ? e.message : t("sftp.error.downloadFailed");
const isCancelError = errorMsg.includes('cancelled') || errorMsg.includes('canceled')
|| cancelledTransferIdsRef.current.has(transferId);
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: isCancelError ? "cancelled" as const : "failed" as const,
speed: 0,
error: isCancelError ? undefined : errorMsg,
}
: task
)
);
if (!isCancelError) {
toast.error(errorMsg, "SFTP");
}
} finally {
cancelledTransferIdsRef.current.delete(transferId);
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -461,20 +692,20 @@ export const useSftpModalTransfers = ({
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: Math.max(
task.transferredBytes,
Math.min(transferred, total > 0 ? total : transferred)
),
totalBytes: total > 0 ? total : task.totalBytes,
progress: (() => {
const effectiveTotal = total > 0 ? total : task.totalBytes;
if (effectiveTotal <= 0) return task.progress;
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 100));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
...task,
transferredBytes: Math.max(
task.transferredBytes,
Math.min(transferred, total > 0 ? total : transferred)
),
totalBytes: total > 0 ? total : task.totalBytes,
progress: (() => {
const effectiveTotal = total > 0 ? total : task.totalBytes;
if (effectiveTotal <= 0) return task.progress;
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 100));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
);
@@ -485,12 +716,12 @@ export const useSftpModalTransfers = ({
prev.map(task =>
task.id === transferId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
speed: 0,
}
...task,
status: "completed" as const,
progress: 100,
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
speed: 0,
}
: task
)
);
@@ -569,7 +800,7 @@ export const useSftpModalTransfers = ({
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t, listSftp, createUploadBridge, deleteLocalFile, cancelledTransferIdsRef, cancelTransfer],
);
@@ -786,13 +1017,27 @@ export const useSftpModalTransfers = ({
if (!task) return;
if (task.direction === "download") {
// For download tasks, cancel only this specific transfer
// For download tasks, cancel the specific transfer
// Add to cancelled set so recursive downloads can check
cancelledTransferIdsRef.current.add(taskId);
if (cancelTransfer) {
try {
// Cancel the parent task ID (works for single-file downloads)
await cancelTransfer(taskId);
} catch {
// Ignore cancellation errors
}
// Also cancel the active child transfer for directory downloads
const activeChildId = activeChildTransferIdsRef.current.get(taskId);
if (activeChildId) {
try {
await cancelTransfer(activeChildId);
} catch {
// Ignore cancellation errors
}
activeChildTransferIdsRef.current.delete(taskId);
}
}
// Mark task as cancelled
setUploadTasks(prev =>

View File

@@ -40,6 +40,7 @@ interface SftpPaneToolbarProps {
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
// Bookmark props
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
@@ -79,6 +80,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
setNewFolderName,
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
@@ -278,7 +280,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
onClick={() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />

View File

@@ -226,7 +226,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onNewFolder: () => setShowNewFolderDialog(true),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
},
onNewFile: () => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
@@ -241,6 +244,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane.files,
setFileNameError,
setNewFileName,
setNewFolderName,
setShowNewFileDialog,
setShowNewFolderDialog,
],
@@ -318,6 +322,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
bookmarks={bookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}

View File

@@ -55,10 +55,10 @@ async function listLocalDir(event, payload) {
const fullPath = path.join(dirPath, entry.name);
// fs.promises.stat follows symlinks, so we get the target's stats
const stat = await fs.promises.stat(fullPath);
let type;
let linkTarget = null;
if (entry.isSymbolicLink()) {
// This is a symlink - mark it as such and record the target type
type = "symlink";
@@ -69,10 +69,10 @@ async function listLocalDir(event, payload) {
} else {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
@@ -201,7 +201,7 @@ async function getSystemInfo() {
async function readKnownHosts() {
const homeDir = os.homedir();
const knownHostsPaths = [];
if (process.platform === "win32") {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
@@ -212,9 +212,9 @@ async function readKnownHosts() {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
}
let combinedContent = "";
for (const knownHostsPath of knownHostsPaths) {
try {
if (fs.existsSync(knownHostsPath)) {
@@ -227,7 +227,7 @@ async function readKnownHosts() {
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
}
}
return combinedContent || null;
}

View File

@@ -519,15 +519,15 @@ function attachOAuthLoadingOverlay(win) {
`;
win.webContents.on("did-start-loading", () => {
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => { });
});
win.webContents.on("did-stop-loading", () => {
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
});
win.webContents.on("did-fail-load", () => {
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
});
}
@@ -626,10 +626,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
@@ -775,6 +775,7 @@ async function createWindow(electronModule, options) {
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowStateSync(state);
closeSettingsWindow();
return;
}
@@ -871,7 +872,7 @@ async function createWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html");
onRegisterBridge?.(win);
return win;
}
@@ -882,13 +883,13 @@ async function createWindow(electronModule, options) {
async function openSettingsWindow(electronModule, options) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return settingsWindow;
}
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
@@ -1001,7 +1002,7 @@ async function openSettingsWindow(electronModule, options) {
// Load the settings page
const settingsPath = '/#/settings';
if (isDev) {
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
@@ -1014,7 +1015,7 @@ async function openSettingsWindow(electronModule, options) {
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html#/settings");
return win;
}
@@ -1188,19 +1189,19 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
const template = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
: []),
{
label: tMenu(language, "edit"),
@@ -1239,7 +1240,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
],
},
];
return Menu.buildFromTemplate(template);
}