Skip to content

feat(sftp): stream packed downloads and restore folders locally - #4179

Open
jinshenganyuci wants to merge 2 commits into
binaricat:mainfrom
jinshenganyuci:feat/sftp-packed-download
Open

jinshenganyuci wants to merge 2 commits into
binaricat:mainfrom
jinshenganyuci:feat/sftp-packed-download

Conversation

@jinshenganyuci

@jinshenganyuci jinshenganyuci commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Downloading a remote folder currently transfers its files individually. Add default-on packed downloads for folders and multi-selections: the server streams a tar archive over SSH, and Netcatty validates it and restores the selected files and folders in the chosen local destination. For example, downloading compose into Downloads produces Downloads/compose, not an archive to unpack manually. No archive file is created on the server.

Type of Change

  • Bug fix
  • New feature
  • Refactor / code cleanup
  • Documentation update
  • Build / CI change
  • Other (please describe):

Related Issue (optional)

Related to #3912 and follows #3936. This adds the download workflow; the existing Compress... action continues to create an archive on the server.

Changes Made

  • Add Settings > SFTP > Packed downloads, enabled by default, with independent persistence/sync and five UI translations.
  • Use gzip-compressed tar when available, or plain tar streaming otherwise. Restore hidden files, empty directories and the original selected roots without an extra wrapper folder.
  • Fall back to ordinary downloads for unsupported servers/connections, Windows clients, non-UTF-8 names, selected symlinks and selections from different parent directories. A single regular file keeps the existing download flow.
  • Keep list/tree multi-selection paths and the captured connection consistent, including parent/child deduplication and separate destinations for duplicate basenames.
  • Show one cancellable transfer-center task per packed selection. Retain the exact browse connection while queued/running; release/cancel it when the owning renderer closes, crashes or reloads. Packed streams do not pause/resume or automatically replay a parent directory after restart.
  • Validate the received archive before publishing from private local staging, reject unsafe paths/links and damaged archives, preserve existing destination contents, and prompt for a different destination on name conflicts. Clean local staging on completion, handled failure or cancellation.
  • Document compatibility, fallback, local disk requirements and abnormal-termination cleanup.

Screenshots / Demo

Verified with the actual Electron renderer/preload/IPC against an isolated loopback SSH/SFTP fixture:

  • Download compose with a hidden .env, empty directory and a Chinese/quote-containing binary filename; verify the exact restored layout and bytes.
  • Exercise both gzip and plain tar and multi-select siblings.
  • Confirm an existing local directory and sentinel file remain unchanged.
  • Deny remote command execution and confirm fallback leaves ordinary SFTP available.
  • Confirm no remote archive or local staging remains after the completed operations.

Testing

Validation applies to d8717fe07c28b9fe79b9f4b267705f966f8db894.

  • GitHub CI passed: lint/full tests/build and both Windows smoke jobs (test run); macOS, Windows, Linux x64 and Linux arm64 packages (package run). The additional ET binary build also passed on all four targets.
  • Independent local review found no remaining actionable issues.
  • Final focused SFTP, transfer-store, menu, sync and worker suite: 1,232 passed, no failures/skips.
  • Existing transfer lease/close regressions: 8 passed.
  • npm run lint, npm run build and git diff --check passed.
  • Full npm test was run: 16,083 tests, 16,026 passed, 6 failed, 51 skipped. Four failures were missing local plugin example build artifacts; after building the three examples, all four affected tests passed. The two remaining Bash prompt/readiness tests reproduce on untouched upstream main 4a55492 with this Mac's Bash 3.2; their sources are unchanged by this PR.
  • Regression coverage includes cancellation/renderer loss, session leases, connection switches during destination selection, path collisions, symlink fallback, corrupt/truncated archives, traversal rejection, and cleanup.

Checklist

  • My code follows the existing project style
  • I have added or updated relevant documentation
  • I have not introduced any breaking changes

@jinshenganyuci
jinshenganyuci marked this pull request as ready for review November 7, 2026 13:35
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T13:45:57.139829Z d8717fe Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8717fe07c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +37 to +38
transferRuntime.enqueue([{ ...task, ownerId, packedTargetPaths: targetPaths,
controlKind: "packed-download", progressMode: "bytes", resumable: false, retryable: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude packed jobs from automatic pause/resume

Although resumable is false, useAppStartupEffects calls pause() for every unfinished row during close/quit. If the packed stream finishes before the matching Resume completes, the store's pause latch causes patchTask to discard the terminal state; after runWalk unregisters, Resume can take the dedicated directory-resume path. For a multi-selection, that path recursively downloads the saved common remoteDirectory, including unselected siblings, into localDirectory. I reproduced this pause → packed completion → dedicated resume sequence with the existing store. Exclude packed rows from automatic pause/resume rather than relying only on the UI capability flags.

Useful? React with 👍 / 👎.

Comment on lines +379 to +381
temp = await fsp.mkdtemp(path.join(destination, ".netcatty-download-"));
const archive = path.join(temp, "archive"), stage = path.join(temp, "contents");
await fsp.mkdir(stage, { mode: 0o700 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep packed scratch data in the managed temporary directory

The root AGENTS.md explicitly requires temporary files to use tempDirBridge.getTempFilePath(), but this allocates both the received archive and extracted tree in an unmanaged directory under the chosen destination. If the worker/app is terminated before finally runs, those potentially large duplicate contents remain outside the configured temp directory; getTempDirInfo() and clearTempDir() cannot list or clear them in Settings > System. Keep these scratch artifacts within the managed-temp facility instead of bypassing its cleanup and visibility contract.

AGENTS.md reference: AGENTS.md:L151-L151

Useful? React with 👍 / 👎.

let temp;
try {
const destination = await fsp.realpath(localDirectory);
for (const name of names) if (await statIfPresent(path.join(destination, name))) return conflict();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check compatibility before returning a destination conflict

On an unsupported client or connection, such as Windows or a non-UTF-8 session, an existing selected basename returns conflict here before compatibility is checked below. The UI handles that result by asking for another directory and retrying packed download, so it never reaches ordinary SFTP fallback for the chosen target. Existing-directory downloads therefore require disabling the setting or choosing another destination, even though these environments should use the ordinary download workflow. I confirmed that an unsupported non-UTF-8 request returns conflict when its local root already exists. Check compatibility before applying the packed-only destination policy.

Useful? React with 👍 / 👎.

Comment on lines +385 to +386
const report = (phase, bytes) => {
safeSend(event?.sender, "netcatty:sftp:packedProgress", { transferId, phase, bytes });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Broadcast packed transfers to the other app windows

When a packed folder download originates in a detached session window, its task is enqueued only in that renderer's sftpTransferCenterStore, and these progress messages target only its sender. Other windows populate their centers through onGlobalSftpTransferEvent in useAppStartupEffects, but the worker fan-out does not translate packedProgress, and this path emits no shared queued or terminal events. Consequently the main window cannot display or control folder downloads started in the detached window, unlike ordinary downloads. Publish the packed lifecycle through the shared transfer-event path.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant