Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24e27586a | ||
|
|
ca24d3861c | ||
|
|
eb3b99b164 | ||
|
|
681f4cb3df | ||
|
|
6fae312981 | ||
|
|
ed199eae8c | ||
|
|
e38af76bfd | ||
|
|
1726917db0 | ||
|
|
1712762305 | ||
|
|
5d75f1acd4 | ||
|
|
18b77f9a87 |
66
.github/workflows/build.yml
vendored
66
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -403,6 +403,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
listSftp: listSftpWithEncoding,
|
||||
deleteLocalFile,
|
||||
});
|
||||
const hasEverOpenedRef = useRef(false);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user