Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **A folder can be sent, not just files.** X on a directory row takes its whole
tree, under the folder's own name, so it arrives as a folder on the other side
— the mirror of what already worked on the way in. The folder is walked as it
is picked, so the footer totals it before Start; its rows then read as taken
and cannot be picked separately, and picking a folder over files already
chosen inside it takes them with it, so nothing is sent twice. Dotfiles and
symlinks stay out, as they are already out of the listing. A send stops at
2048 files, which is what a receiver's prepare-upload body has room for.

The History tab now records the folder rather than the files it held, so a
resend walks it again and picks up whatever is in there now. `retsend <path>`
takes a folder too.

## [0.8.0] - 2026-08-26

### Changed
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ this is the missing end: a client built for a gamepad and screen.
- **Discovery** — a live radar of nearby devices (LocalSend protocol v2.1), with
a manually typed IP address for the networks that block multicast.
- **Receive** — accept/decline dialog with countdown, speed/ETA, and cancel from either side; X picks a folder for that one transfer; quick-save mode auto-accepts.
- **Send** — gamepad file browser with multi-select and per-file progress; pin the folders and files you send often and they lead every listing.
- **Send** — gamepad file browser with multi-select and per-file progress; X on a folder sends the whole thing; pin the folders and files you send often and they lead every listing.
- **History** — every transfer logged; A repeats a send from its row, X drops the row.
- **Save routes** — received ROMs land in the console folder they belong to, detected from the card; per-extension overrides on top.
- **Folders** — a folder sent from the official app arrives as a folder, its tree rebuilt under the save folder.
- **Folders** — send a folder and it arrives as a folder; one sent from the official app is rebuilt under the save folder the same way.
- **Encryption** — the protocol's HTTPS mode, on by default; works with the official app's default settings both ways.
- **Settings on device** — alias, save folder, and port, applied live.
- **Headless** — `retsend --receive` runs with no screen, for SSH and scripting.
Expand Down Expand Up @@ -139,7 +139,7 @@ hint along the bottom to press the button it names.
| D-pad / stick| Arrows | Navigate · left/right switch tabs, or step a settings value |
| A | Enter | Send to device · select file · accept · repeat a send · type |
| B | Esc | Back · decline · cancel · leave the keyboard |
| X | X / Bksp | Add a device by IP · pick where an incoming transfer lands · delete a history row · erase a character · take every file in the folder |
| X | X / Bksp | Add a device by IP · pick where an incoming transfer lands · delete a history row · erase a character · take the folder under the cursor, or every file in the folder |
| Y | Y | Pin / unpin the row under the cursor |
| Start | F1 | Confirm send · OK (keyboard) |
| Select | Tab / F5 | Refresh radar · switch roots · layer (keyboard)|
Expand Down Expand Up @@ -190,6 +190,20 @@ png = "/roms/screenshots"
Extensions match case-insensitively, folders are created on demand, and anything
unrouted lands in `save_dir`.

### Sent folders

**X** on a directory row takes the whole folder — every file of its tree, under
the folder's own name, so `roms/gb/zelda.gbc` arrives at `roms/gb/zelda.gbc` on
the other side. The folder is walked as it is picked, so the footer totals it
before Start; its rows then read as taken, and picking one of them separately
does nothing, since the folder already carries it. What the listing hides the
send leaves out too: dotfiles and symlinks. A send stops at 2048 files, and a
toast says when a pick ran past that.

X anywhere else still takes every file of the folder being looked at, without
its subfolders. The History tab repeats a send from the folder it was given, so
a resend picks up whatever is in there now.

### Received folders

Protocol v2 carries a folder transfer as ordinary files whose names hold the
Expand Down
72 changes: 40 additions & 32 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::config::AppConfig;
use crate::event::user::UserEventSender;
use crate::event::AppEventHandler;
use crate::net::NetService;
use crate::overlay::browser::{BrowserMode, DirPurpose};
use crate::overlay::browser::{BrowserMode, DirPurpose, Taken};
use crate::overlay::osk::{OskEvent, OskTarget};
use crate::overlay::routes::RouteCursor;
use crate::overlay::settings::SettingsRow;
Expand Down Expand Up @@ -82,10 +82,10 @@ impl App {
let staged: Vec<PathBuf> = std::env::args_os()
.skip(1)
.map(PathBuf::from)
.filter(|p| p.is_file())
.filter(|p| p.exists())
.collect();
if !staged.is_empty() {
log::info!("{} files staged for sending", staged.len());
log::info!("{} paths staged for sending", staged.len());
}

let history = History::load(&crate::config::data_dir(), config.transfer.history_limit);
Expand Down Expand Up @@ -306,7 +306,7 @@ impl App {
}
}
(Focus::Browser, AppCommand::TogglePin) => self.toggle_pin(),
(Focus::Browser, AppCommand::Alt) => self.select_folder_contents(),
(Focus::Browser, AppCommand::Alt) => self.take_into_selection(),

// Routes editor: up/down over the routes, the add row and the auto
// routes; A adds or removes; B goes back to Settings.
Expand Down Expand Up @@ -472,8 +472,8 @@ impl App {
.push(format!("{} is not on the network", entry.peer));
return;
};
let (files, gone) = existing_files(&entry.files);
if files.is_empty() {
let (sources, gone) = existing_sources(&entry.files);
if sources.is_empty() {
self.ui.toasts.push("Those files are gone");
return;
}
Expand All @@ -482,7 +482,7 @@ impl App {
.toasts
.push(format!("{gone} files are gone — sending the rest"));
}
self.start_send(entry.peer, base, files);
self.start_send(entry.peer, base, sources);
}

/// Where a resend dials: the peer under that alias as the radar has it now
Expand Down Expand Up @@ -627,21 +627,33 @@ impl App {
}
}

/// X in the browser: take every file of this folder into the selection (or
/// drop them again). Subfolders are left alone — the protocol has no notion
/// of a directory, so a send is always a flat list of files.
fn select_folder_contents(&mut self) {
let Some((count, bytes, selected)) = self.ui.browser.toggle_folder_files() else {
if self.ui.browser.mode == BrowserMode::PickFiles {
self.ui.toasts.push("No files in this folder");
}
/// X in the browser: take a folder or the files of the cwd, or drop them.
fn take_into_selection(&mut self) {
let Some(taken) = self.ui.browser.take() else {
return;
};
let size = crate::ui::fmt_bytes(bytes);
self.ui.toasts.push(if selected {
format!("Selected {count} files · {size}")
} else {
format!("Cleared {count} files")
let size = crate::ui::fmt_bytes;
self.ui.toasts.push(match taken {
Taken::Folder {
name,
files,
bytes,
partial,
} => {
let mut toast = format!("{name} · {files} files · {}", size(bytes));
if partial {
toast.push_str(" — some left out");
}
toast
}
Taken::Files { files, bytes } => format!("Selected {files} files · {}", size(bytes)),
Taken::Given {
name: Some(name),
files,
} => format!("Cleared {name} · {files} files"),
Taken::Given { name: None, files } => format!("Cleared {files} files"),
Taken::Nothing => "No files in this folder".to_string(),
Taken::Covered { name } => format!("Already in {name}"),
});
}

Expand All @@ -663,11 +675,7 @@ impl App {
};
self.config.transfer.pinned_paths = change.paths;
self.config.save();
let name = change
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| change.path.display().to_string());
let name = crate::transfer::files::base_name(&change.path);
self.ui.toasts.push(if change.pinned {
format!("★ {name}")
} else {
Expand Down Expand Up @@ -923,16 +931,16 @@ impl App {
}
}

/// Recorded paths that are still files, and how many of them vanished — a
/// resend takes what is left rather than failing on the first missing one.
fn existing_files(paths: &[String]) -> (Vec<PathBuf>, usize) {
let files: Vec<PathBuf> = paths
/// Recorded paths still on disk, and how many vanished — a resend takes what
/// is left rather than failing on the first missing one.
fn existing_sources(paths: &[String]) -> (Vec<PathBuf>, usize) {
let sources: Vec<PathBuf> = paths
.iter()
.map(PathBuf::from)
.filter(|p| p.is_file())
.filter(|p| p.exists())
.collect();
let gone = paths.len() - files.len();
(files, gone)
let gone = paths.len() - sources.len();
(sources, gone)
}

/// Vertical lists: up/down move the cursor; left/right are reserved for value
Expand Down
Loading
Loading