From f91ecdc938cc230dd33403eb6b27b16370ee9439 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:36:09 +0300 Subject: [PATCH] feat(send): whole folders, not just files --- CHANGELOG.md | 15 ++ README.md | 20 ++- src/app/mod.rs | 72 ++++---- src/overlay/browser.rs | 368 ++++++++++++++++++++++++++++++++------- src/transfer/files.rs | 271 ++++++++++++++++++++++++++++ src/transfer/history.rs | 13 +- src/transfer/outbound.rs | 44 +++-- src/ui/browser.rs | 27 ++- src/ui/mod.rs | 6 +- tests/send.rs | 53 ++++++ 10 files changed, 751 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 238c60e..e205a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` + takes a folder too. + ## [0.8.0] - 2026-08-26 ### Changed diff --git a/README.md b/README.md index 1c90fa7..b0bc265 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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)| @@ -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 diff --git a/src/app/mod.rs b/src/app/mod.rs index 77c9c31..108150e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; @@ -82,10 +82,10 @@ impl App { let staged: Vec = 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); @@ -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. @@ -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; } @@ -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 @@ -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}"), }); } @@ -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 { @@ -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, usize) { - let files: Vec = 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, usize) { + let sources: Vec = 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 diff --git a/src/overlay/browser.rs b/src/overlay/browser.rs index 00ec1bb..d3b7d9d 100644 --- a/src/overlay/browser.rs +++ b/src/overlay/browser.rs @@ -2,6 +2,7 @@ //! multi-select across directories, and a root carousel for the handheld's //! mount points. Pure state — `crate::ui::browser` renders it. +use crate::transfer::files; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -28,6 +29,35 @@ pub struct Entry { pub pinned: bool, } +/// What one picked row contributes to a send. A folder is walked as it is +/// picked, so the footer can total it before Start. +#[derive(Clone, Copy)] +pub struct Picked { + pub bytes: u64, + /// 1 for a file; the tree's file count for a folder. + pub files: usize, + pub is_dir: bool, +} + +/// What [`FileBrowser::take`] did, for the toast that reports it. +pub enum Taken { + /// `partial` when the walk left something out. + Folder { + name: String, + files: usize, + bytes: u64, + partial: bool, + }, + /// The files of the folder being looked at. + Files { files: usize, bytes: u64 }, + /// Given back; `name` is `None` when it was the files of the cwd. + Given { name: Option, files: usize }, + /// Nothing here to send. + Nothing, + /// A picked folder already carries this row. + Covered { name: String }, +} + /// Outcome of a [`FileBrowser::toggle_pin`]: the new list for the config, and /// which path went in or out (for the toast). pub struct PinChange { @@ -68,8 +98,9 @@ pub struct FileBrowser { pub cwd: PathBuf, pub entries: Vec, pub cursor: usize, - /// Selected files (full path → size), surviving directory navigation. - pub selected: BTreeMap, + /// Picked files and folders, surviving directory navigation. No entry ever + /// sits inside another: a folder carries its own tree. + pub selected: BTreeMap, roots: Vec, root_index: usize, /// Pinned folders, shown above every listing so the jump is one press from @@ -95,7 +126,7 @@ impl FileBrowser { } /// Open for picking files to send. `extra_roots` and `pinned_paths` come - /// from the config; `initial` pre-selects files (the CLI staging list); `start` + /// from the config; `initial` pre-selects paths (the CLI staging list); `start` /// is where the last send began, empty on a first run. pub fn open_for_send( &mut self, @@ -112,8 +143,18 @@ impl FileBrowser { self.root_index = 0; self.selected = initial .iter() - .filter_map(|p| Some((p.clone(), std::fs::metadata(p).ok()?.len()))) + .filter_map(|p| Some((p.clone(), pick_of(p)?))) + .collect(); + // The CLI can name both a folder and something under it; the folder + // carries it. + let dirs: Vec = self + .selected + .iter() + .filter(|(_, picked)| picked.is_dir) + .map(|(path, _)| path.clone()) .collect(); + self.selected + .retain(|path, _| !dirs.iter().any(|dir| files::is_inside(dir, path))); self.cursor = 0; self.open = true; self.start_at(start); @@ -159,15 +200,31 @@ impl FileBrowser { self.selected.clear(); } - /// (count, total bytes) of the selection. + /// (files, total bytes) of the selection; a picked folder counts its tree. pub fn selection_totals(&self) -> (usize, u64) { - (self.selected.len(), self.selected.values().sum()) + self.selected.values().fold((0, 0), |(files, bytes), p| { + (files + p.files, bytes + p.bytes) + }) } pub fn selected_paths(&self) -> Vec { self.selected.keys().cloned().collect() } + /// The picked folder whose tree already holds `path`, which is then not + /// pickable on its own. + pub fn covered_by(&self, path: &Path) -> Option<&Path> { + self.selected + .iter() + .find(|(picked, entry)| entry.is_dir && files::is_inside(picked, path)) + .map(|(picked, _)| picked.as_path()) + } + + /// Whether X takes a folder rather than the files of the cwd. + pub fn cursor_is_dir(&self) -> bool { + self.entries.get(self.cursor).is_some_and(|e| e.is_dir) + } + pub fn move_cursor(&mut self, delta: i32) { if self.entries.is_empty() { self.cursor = 0; @@ -191,17 +248,27 @@ impl FileBrowser { return Ok(()); }; if entry.is_dir { - self.change_dir(entry.path.clone()) - } else if self.mode == BrowserMode::PickFiles { - let path = entry.path.clone(); - let size = entry.size; - if self.selected.remove(&path).is_none() { - self.selected.insert(path, size); - } - Ok(()) - } else { - Ok(()) // PickDir: files aren't selectable + return self.change_dir(entry.path.clone()); + } + if self.mode != BrowserMode::PickFiles { + return Ok(()); // PickDir: files aren't selectable } + let (path, size) = (entry.path.clone(), entry.size); + if self.selected.remove(&path).is_some() { + return Ok(()); + } + if let Some(folder) = self.covered_by(&path).map(files::base_name) { + return Err(format!("Already in {folder}")); + } + self.selected.insert( + path, + Picked { + bytes: size, + files: 1, + is_dir: false, + }, + ); + Ok(()) } /// B: go to the parent directory. Returns `false` at a root — the caller @@ -234,36 +301,99 @@ impl FileBrowser { Some(&self.roots[self.root_index]) } - /// X: select every file of the folder being looked at, or clear them when - /// they are all selected already. Returns `(count, bytes, selected)`, or - /// `None` when the folder holds no file to take. - /// - /// Pinned rows are skipped on purpose: they lead the listing but belong to - /// other folders, and "everything here" must not reach into them. - pub fn toggle_folder_files(&mut self) -> Option<(usize, u64, bool)> { + /// X: the folder under the cursor with its whole tree, or — anywhere else — + /// every file of the folder being looked at. Pressing it again gives back + /// what it took. `None` while a folder is being chosen. + pub fn take(&mut self) -> Option { if self.mode != BrowserMode::PickFiles { return None; } + Some(if self.cursor_is_dir() { + self.take_dir() + } else { + self.take_here() + }) + } + + fn take_dir(&mut self) -> Taken { + let entry = &self.entries[self.cursor]; // the caller checked the row + let (path, name) = (entry.path.clone(), entry.name.clone()); + if let Some(given) = self.selected.remove(&path) { + return Taken::Given { + name: Some(name), + files: given.files, + }; + } + if let Some(folder) = self.covered_by(&path).map(files::base_name) { + return Taken::Covered { name: folder }; + } + let walked = files::walk_folder(&path); + if walked.files.is_empty() { + return Taken::Nothing; + } + let (files, bytes, partial) = (walked.files.len(), walked.bytes, walked.partial); + // The folder carries its tree from here on. + self.selected.retain(|p, _| !files::is_inside(&path, p)); + self.selected.insert( + path, + Picked { + bytes, + files, + is_dir: true, + }, + ); + Taken::Folder { + name, + files, + bytes, + partial, + } + } + + /// Pinned rows are skipped on purpose: they lead the listing but belong to + /// other folders, and "everything here" must not reach into them. + fn take_here(&mut self) -> Taken { let here: Vec<(PathBuf, u64)> = self .entries .iter() .filter(|e| !e.is_dir && !e.pinned) .map(|e| (e.path.clone(), e.size)) .collect(); - if here.is_empty() { - return None; + let Some((first, _)) = here.first() else { + return Taken::Nothing; + }; + // One answer for the lot: they share the folder they sit in. + if let Some(folder) = self.covered_by(first).map(files::base_name) { + return Taken::Covered { name: folder }; } - let all_selected = here.iter().all(|(p, _)| self.selected.contains_key(p)); + let all_picked = here.iter().all(|(p, _)| self.selected.contains_key(p)); let mut bytes = 0; for (path, size) in &here { - if all_selected { + if all_picked { self.selected.remove(path); } else { - self.selected.insert(path.clone(), *size); + self.selected.insert( + path.clone(), + Picked { + bytes: *size, + files: 1, + is_dir: false, + }, + ); } bytes += size; } - Some((here.len(), bytes, !all_selected)) + if all_picked { + Taken::Given { + name: None, + files: here.len(), + } + } else { + Taken::Files { + files: here.len(), + bytes, + } + } } /// How many rows at the top of the listing are pins. They always lead, so @@ -342,10 +472,7 @@ impl FileBrowser { return None; } Some(Entry { - name: path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), + name: files::base_name(path), path: path.clone(), is_dir: meta.is_dir(), size: if meta.is_dir() { 0 } else { meta.len() }, @@ -356,32 +483,37 @@ impl FileBrowser { } } -/// Directory listing: dirs first, case-insensitive name order, dotfiles -/// hidden, symlinks skipped (a looped symlink tree on an SD card must not -/// hang navigation). +/// The folder's own rows, in the order a folder send walks them. fn read_entries(dir: &Path) -> std::io::Result> { - let mut entries = Vec::new(); - for entry in std::fs::read_dir(dir)? { - let Ok(entry) = entry else { continue }; - let name = entry.file_name().to_string_lossy().into_owned(); - if name.starts_with('.') { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - if entry.file_type().map(|t| t.is_symlink()).unwrap_or(true) { - continue; - } - entries.push(Entry { - path: entry.path(), - is_dir: meta.is_dir(), - size: if meta.is_dir() { 0 } else { meta.len() }, - name, + Ok(files::list_dir(dir)? + .into_iter() + .map(|e| Entry { + name: e.name, + path: e.path, + is_dir: e.is_dir, + size: e.size, pinned: false, + }) + .collect()) +} + +/// A staged path as a selection entry: a file's size, or a folder walked. +/// `None` when it is gone, or a folder with nothing to send. +fn pick_of(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_dir() { + return Some(Picked { + bytes: meta.len(), + files: 1, + is_dir: false, }); } - // Cached key: one lowercase per entry, not two per comparison. - entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); - Ok(entries) + let walked = files::walk_folder(path); + (!walked.files.is_empty()).then_some(Picked { + bytes: walked.bytes, + files: walked.files.len(), + is_dir: true, + }) } /// Config paths that exist right now, folders or files, deduplicated, order @@ -614,17 +746,21 @@ mod tests { fn x_takes_every_file_of_the_folder_and_gives_them_back() { let root = temp_tree(); let mut b = browser_at(&root); + b.move_cursor(2); // readme.txt; on a directory row X takes the folder - let (count, bytes, selected) = b.toggle_folder_files().expect("readme.txt is here"); - assert!(selected); - assert_eq!(count, 1, "only files; games/ and saves/ are not sent"); + let Taken::Files { files, bytes } = b.take().expect("files are selectable") else { + panic!("readme.txt is here"); + }; + assert_eq!(files, 1, "only files; games/ and saves/ are their own rows"); assert_eq!(bytes, 2); assert!(b.selected.contains_key(&root.join("readme.txt"))); // Pressing it again on a fully selected folder clears it. - let (count, _, selected) = b.toggle_folder_files().expect("readme.txt is here"); - assert!(!selected); - assert_eq!(count, 1); + let Taken::Given { name, files } = b.take().expect("files are selectable") else { + panic!("everything here was already taken"); + }; + assert!(name.is_none(), "the folder itself was never picked"); + assert_eq!(files, 1); assert!(b.selected.is_empty()); std::fs::remove_dir_all(&root).unwrap(); @@ -638,9 +774,10 @@ mod tests { b.activate().unwrap(); // mario.gb, the first row assert_eq!(b.selected.len(), 1); - let (count, _, selected) = b.toggle_folder_files().expect("two roms here"); - assert!(selected, "one of two was selected, so X takes the rest"); - assert_eq!(count, 2); + let Taken::Files { files, .. } = b.take().expect("files are selectable") else { + panic!("one of two was selected, so X takes the rest"); + }; + assert_eq!(files, 2); assert_eq!(b.selected.len(), 2); std::fs::remove_dir_all(&root).unwrap(); @@ -651,21 +788,118 @@ mod tests { let root = temp_tree(); let pinned_file = root.join("games/zelda.gbc"); let mut b = browser_with_pins(&root, &[pinned_file.to_str().unwrap()]); + b.set_cursor(3); // the pin, games/, saves/, then readme.txt - let (count, _, _) = b.toggle_folder_files().expect("readme.txt is here"); - assert_eq!(count, 1, "the pinned row belongs to games/, not here"); + let Taken::Files { files, .. } = b.take().expect("files are selectable") else { + panic!("readme.txt is here"); + }; + assert_eq!(files, 1, "the pinned row belongs to games/, not here"); assert!(!b.selected.contains_key(&pinned_file)); std::fs::remove_dir_all(&root).unwrap(); } + #[test] + fn x_takes_the_folder_under_the_cursor_and_gives_it_back() { + let root = temp_tree(); + let games = root.join("games"); + let mut b = browser_at(&root); // cursor on games/ + + let Taken::Folder { + name, + files, + bytes, + partial, + } = b.take().expect("files are selectable") + else { + panic!("the cursor is on a directory"); + }; + assert_eq!(name, "games"); + assert_eq!((files, bytes), (2, 150)); + assert!(!partial); + assert_eq!(b.selection_totals(), (2, 150), "the tree, not the row"); + assert!(b.selected[&games].is_dir); + + let Taken::Given { name, files } = b.take().expect("files are selectable") else { + panic!("it was picked a moment ago"); + }; + assert_eq!((name.as_deref(), files), (Some("games"), 2)); + assert!(b.selected.is_empty()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_picked_folder_carries_what_is_under_it() { + let root = temp_tree(); + let games = root.join("games"); + let mut b = browser_at(&root); + b.change_dir(games.clone()).unwrap(); + b.activate().unwrap(); // mario.gb + assert_eq!(b.selection_totals(), (1, 50)); + + b.parent(); + b.take().expect("the cursor landed back on games/"); + assert_eq!( + b.selected.keys().collect::>(), + vec![&games], + "the file it already held went with it" + ); + assert_eq!(b.selection_totals(), (2, 150)); + + // Back inside, its files read as taken and cannot be taken again. + b.change_dir(games.clone()).unwrap(); + assert_eq!(b.covered_by(&games.join("mario.gb")), Some(games.as_path())); + assert_eq!( + b.activate().unwrap_err(), + "Already in games", + "A on a file the folder already carries" + ); + assert_eq!(b.selection_totals(), (2, 150)); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn x_reports_a_folder_with_nothing_in_it() { + let root = temp_tree(); + let mut b = browser_at(&root); + b.move_cursor(1); // saves/, which is empty + + assert!(matches!(b.take(), Some(Taken::Nothing))); + assert!(b.selected.is_empty(), "an empty pick would total nothing"); + + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn x_does_nothing_when_a_folder_is_being_chosen() { let root = temp_tree(); let mut b = FileBrowser::new(); b.roots = vec![root.to_path_buf()]; b.open_for_dir(&root, &[], &[], DirPurpose::SaveDir, ""); - assert!(b.toggle_folder_files().is_none()); + assert!(b.take().is_none()); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_staged_folder_opens_the_browser_already_holding_its_tree() { + let root = temp_tree(); + let games = root.join("games"); + let mut b = FileBrowser::new(); + b.roots = vec![root.to_path_buf()]; + // The CLI can name both a folder and a file inside it. + b.open_for_send( + "Phone", + &[], + &[], + &[games.clone(), games.join("mario.gb")], + &root, + ); + + assert_eq!(b.selected.keys().collect::>(), vec![&games]); + assert_eq!(b.selection_totals(), (2, 150)); + std::fs::remove_dir_all(&root).unwrap(); } diff --git a/src/transfer/files.rs b/src/transfer/files.rs index bdaf741..afee220 100644 --- a/src/transfer/files.rs +++ b/src/transfer/files.rs @@ -2,6 +2,9 @@ //! byte, so this is a security boundary: strip control characters and //! FAT-illegal characters (handheld SD cards are FAT), keep every component a //! name of its own, and never let a path escape the save directory. +//! +//! And the way out: the directory listing the browser and a folder send share, +//! and the walk turning picked paths into the flat list the protocol carries. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -16,6 +19,11 @@ const MAX_EXT_BYTES: usize = 20; const MAX_DEPTH: usize = 8; /// Stands in for a component that sanitizes away to nothing. const FALLBACK_NAME: &str = "file"; +/// Most files one send may carry. A receiver caps the prepare-upload body +/// (ours at 1 MiB) and every file spends a couple of hundred bytes of it. +const MAX_SEND_FILES: usize = 2048; +/// Deepest folder level a picked directory is walked to. +const MAX_SEND_DEPTH: usize = 16; /// Reduce an untrusted sender-supplied file name to a safe basename, dropping /// any directory components. Guarantees a non-empty result with no separators, @@ -128,6 +136,137 @@ pub fn mime_for(path: &Path) -> &'static str { } } +pub struct DirEntry { + pub name: String, + pub path: PathBuf, + pub is_dir: bool, + /// Files only; 0 for directories. + pub size: u64, +} + +/// Directory listing: dirs first, case-insensitive name order, dotfiles +/// hidden, symlinks skipped (a looped symlink tree on an SD card must not hang +/// navigation, nor a folder send). +pub fn list_dir(dir: &Path) -> std::io::Result> { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if entry.file_type().map(|t| t.is_symlink()).unwrap_or(true) { + continue; + } + entries.push(DirEntry { + path: entry.path(), + is_dir: meta.is_dir(), + size: if meta.is_dir() { 0 } else { meta.len() }, + name, + }); + } + // Cached key: one lowercase per entry, not two per comparison. + entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); + Ok(entries) +} + +/// One file of an outbound send. Protocol v2 has no directory of its own, so +/// the files of a sent folder carry their relative path in `name`. +pub struct SendFile { + pub path: PathBuf, + pub name: String, + pub size: u64, +} + +/// What a set of picked sources expands to. +#[derive(Default)] +pub struct SendList { + pub files: Vec, + pub bytes: u64, + /// The walk left something out: [`MAX_SEND_FILES`], [`MAX_SEND_DEPTH`], or + /// a folder that could not be read. + pub partial: bool, +} + +impl SendList { + fn push(&mut self, path: PathBuf, name: String, size: u64) { + self.bytes += size; + self.files.push(SendFile { path, name, size }); + } +} + +/// Is `path` strictly inside `dir`? +pub fn is_inside(dir: &Path, path: &Path) -> bool { + path != dir && path.starts_with(dir) +} + +/// The last component of `path`, or the whole path when it has none. +pub fn base_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +/// The files picked sources carry: a file itself, a directory its tree under +/// the directory's own name. A source inside another picked directory is +/// dropped rather than sent twice; a missing one errors. +pub fn expand_sources(sources: &[PathBuf]) -> std::io::Result { + let mut picked: Vec<&Path> = sources.iter().map(PathBuf::as_path).collect(); + picked.sort_unstable(); + picked.dedup(); + let dirs: Vec<&Path> = picked.iter().copied().filter(|p| p.is_dir()).collect(); + + let mut list = SendList::default(); + for source in picked { + if dirs.iter().any(|dir| is_inside(dir, source)) { + continue; + } + let meta = std::fs::metadata(source)?; + if meta.is_dir() { + walk_into(source, &base_name(source), 1, &mut list); + } else { + list.push(source.to_path_buf(), base_name(source), meta.len()); + } + } + Ok(list) +} + +/// A folder's files, walked eagerly so a browser can total them before the send. +pub fn walk_folder(dir: &Path) -> SendList { + let mut list = SendList::default(); + walk_into(dir, &base_name(dir), 1, &mut list); + list +} + +/// Files under `dir` named `/`, over [`list_dir`] — so a folder +/// sends what its listing showed, dotfiles and symlinks in neither. +fn walk_into(dir: &Path, prefix: &str, depth: usize, list: &mut SendList) { + let entries = match list_dir(dir) { + Ok(entries) => entries, + Err(e) => { + log::warn!("`{}` left out of the send: {e}", dir.display()); + list.partial = true; + return; + } + }; + for entry in entries { + if list.files.len() >= MAX_SEND_FILES { + list.partial = true; + return; + } + let name = format!("{prefix}/{}", entry.name); + if !entry.is_dir { + list.push(entry.path, name, entry.size); + } else if depth < MAX_SEND_DEPTH { + walk_into(&entry.path, &name, depth + 1, list); + } else { + log::warn!("`{}` is nested too deep to send", entry.path.display()); + list.partial = true; + } + } +} + /// Remove leftover `.part` files older than a day from `dir` — debris from /// crashes or yanked power mid-transfer. Fresh ones are left alone in case a /// transfer is somehow still running. Called once at startup, best-effort. @@ -370,6 +509,138 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// `roms/` holding `gb/zelda.gbc`, `gb/saves/zelda.sav` and `notes.txt`, + /// plus a dotfile and an empty folder the walk must pass over. + fn send_tree() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "retsend-walk-{}", + crate::net::protocol::random_token(4) + )); + std::fs::create_dir_all(root.join("roms/gb/saves")).unwrap(); + std::fs::create_dir_all(root.join("roms/empty")).unwrap(); + std::fs::write(root.join("roms/gb/zelda.gbc"), vec![0u8; 100]).unwrap(); + std::fs::write(root.join("roms/gb/saves/zelda.sav"), vec![0u8; 8]).unwrap(); + std::fs::write(root.join("roms/notes.txt"), b"hi").unwrap(); + std::fs::write(root.join("roms/.hidden"), b"x").unwrap(); + root + } + + fn names(list: &SendList) -> Vec<&str> { + list.files.iter().map(|f| f.name.as_str()).collect() + } + + #[test] + fn a_folder_expands_to_its_tree_under_its_own_name() { + let root = send_tree(); + let list = expand_sources(&[root.join("roms")]).unwrap(); + + // Dirs first, as the listing shows them; the dotfile is not sent. + assert_eq!( + names(&list), + [ + "roms/gb/saves/zelda.sav", + "roms/gb/zelda.gbc", + "roms/notes.txt" + ] + ); + assert_eq!(list.bytes, 110); + assert!(!list.partial); + assert_eq!(list.files[1].path, root.join("roms/gb/zelda.gbc")); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_loose_file_keeps_its_bare_name() { + let root = send_tree(); + let list = expand_sources(&[root.join("roms/notes.txt")]).unwrap(); + assert_eq!(names(&list), ["notes.txt"]); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_source_inside_a_picked_folder_is_not_sent_twice() { + let root = send_tree(); + let list = expand_sources(&[ + root.join("roms/gb/zelda.gbc"), + root.join("roms"), + root.join("roms/gb"), + root.join("roms"), // the same pick twice + ]) + .unwrap(); + + assert_eq!( + names(&list), + [ + "roms/gb/saves/zelda.sav", + "roms/gb/zelda.gbc", + "roms/notes.txt" + ] + ); + assert_eq!(list.bytes, 110); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn an_empty_folder_expands_to_nothing() { + let root = send_tree(); + let list = expand_sources(&[root.join("roms/empty")]).unwrap(); + assert!(list.files.is_empty() && !list.partial); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_missing_source_is_an_error() { + assert!(expand_sources(&[PathBuf::from("/nonexistent/card/roms")]).is_err()); + } + + #[test] + fn the_walk_stops_at_the_send_cap_and_says_so() { + let root = std::env::temp_dir().join(format!( + "retsend-cap-{}", + crate::net::protocol::random_token(4) + )); + let folder = root.join("many"); + std::fs::create_dir_all(&folder).unwrap(); + for i in 0..MAX_SEND_FILES + 10 { + std::fs::write(folder.join(format!("{i:05}.bin")), b"x").unwrap(); + } + + let list = walk_folder(&folder); + assert_eq!(list.files.len(), MAX_SEND_FILES); + assert!(list.partial); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn the_walk_stops_going_deeper_and_says_so() { + let root = std::env::temp_dir().join(format!( + "retsend-deep-{}", + crate::net::protocol::random_token(4) + )); + let deep: PathBuf = (0..MAX_SEND_DEPTH + 2).map(|i| format!("d{i}")).collect(); + std::fs::create_dir_all(root.join("tree").join(&deep)).unwrap(); + std::fs::write(root.join("tree").join(&deep).join("buried.bin"), b"x").unwrap(); + std::fs::write(root.join("tree/top.bin"), b"x").unwrap(); + + let list = walk_folder(&root.join("tree")); + assert_eq!(names(&list), ["tree/top.bin"]); + assert!(list.partial, "the buried file was left out"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn is_inside_is_strict() { + let dir = Path::new("/roms/gb"); + assert!(is_inside(dir, Path::new("/roms/gb/zelda.gbc"))); + assert!(is_inside(dir, Path::new("/roms/gb/saves/zelda.sav"))); + assert!(!is_inside(dir, dir), "a folder does not contain itself"); + assert!(!is_inside(dir, Path::new("/roms/gba/x.gba"))); + assert!(!is_inside(dir, Path::new("/roms"))); + } + #[test] fn part_path_appends_suffix() { assert_eq!( diff --git a/src/transfer/history.rs b/src/transfer/history.rs index 26fa56d..4f911cd 100644 --- a/src/transfer/history.rs +++ b/src/transfer/history.rs @@ -12,8 +12,8 @@ use std::sync::atomic::Ordering; /// Default cap on retained entries when the config omits `transfer.history_limit`. pub const DEFAULT_MAX_ENTRIES: usize = 200; -/// Cap on source paths kept for a resend; a bigger send is logged without them -/// rather than growing `history.json` without bound. +/// Cap on picked paths kept for a resend; a bigger selection is logged without +/// them rather than growing `history.json` without bound. const MAX_RESEND_FILES: usize = 64; #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -116,13 +116,10 @@ impl HistoryEntry { .filter(|f| *f.state.lock().unwrap() == FileState::Done) .filter_map(|f| f.path.parent()), ), - // A resend repeats the whole selection, so every file is kept — + // A resend repeats the whole selection, so every source is kept — // including the ones that failed, which is when it is worth most. - files: if s.files.len() <= MAX_RESEND_FILES { - s.files - .iter() - .map(|f| f.path.display().to_string()) - .collect() + files: if s.sources.len() <= MAX_RESEND_FILES { + s.sources.iter().map(|p| p.display().to_string()).collect() } else { Vec::new() }, diff --git a/src/transfer/outbound.rs b/src/transfer/outbound.rs index d0b1e43..9f76b23 100644 --- a/src/transfer/outbound.rs +++ b/src/transfer/outbound.rs @@ -38,6 +38,9 @@ pub struct OutboundSession { pub peer_alias: String, /// `http://ip:port` this send dials; the history keeps it for a resend. pub base: String, + /// The paths the user picked, folders unexpanded: a resend walks them + /// again rather than repeating the files they held. + pub sources: Vec, pub files: Vec, pub total_bytes: u64, pub sent_total: AtomicU64, @@ -82,43 +85,46 @@ impl OutboundSession { } } -/// Build the session (stat every file) and start the worker thread. -/// `base` is `http://ip:port`; `me` is our announced identity. +/// Build the session (expanding picked folders into their files) and start the +/// worker thread. `base` is `http://ip:port`; `me` is our announced identity. pub fn spawn( peer_alias: String, base: String, me: DeviceInfo, - paths: Vec, + sources: Vec, wake: Arc, ) -> std::io::Result> { - let mut files = Vec::with_capacity(paths.len()); - let mut total = 0u64; - for path in paths { - let size = std::fs::metadata(&path)?.len(); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "file".to_string()); - total += size; - files.push(OutboundFile { + let list = super::files::expand_sources(&sources)?; + if list.files.is_empty() { + return Err(std::io::Error::other("nothing to send")); + } + if list.partial { + log::warn!("`{peer_alias}` is not getting every file: the walk left some out"); + } + let total = list.bytes; + let files: Vec = list + .files + .into_iter() + .map(|file| OutboundFile { meta: FileMeta { id: protocol::random_token(8), - file_name, - size, - file_type: super::files::mime_for(&path).to_string(), + file_name: file.name, + size: file.size, + file_type: super::files::mime_for(&file.path).to_string(), sha256: None, preview: None, metadata: None, }, - path, + path: file.path, state: Mutex::new(FileState::Pending), sent: AtomicU64::new(0), - }); - } + }) + .collect(); let session = Arc::new(OutboundSession { peer_alias, base, + sources, files, total_bytes: total, sent_total: AtomicU64::new(0), diff --git a/src/ui/browser.rs b/src/ui/browser.rs index 3324ec7..dc10fd6 100644 --- a/src/ui/browser.rs +++ b/src/ui/browser.rs @@ -87,7 +87,12 @@ pub fn render( ("Start", start_hint, Some(AppCommand::Start)), ]; if !picking_dir { - hints.push(("X", "All", Some(AppCommand::Alt))); + let what = if browser.cursor_is_dir() { + "Folder" + } else { + "All" + }; + hints.push(("X", what, Some(AppCommand::Alt))); } hints.push(( "Y", @@ -180,19 +185,24 @@ pub fn render( let padding = 10.0; // A star for pinned rows, a slash for directories, a checkbox - // for files — and both for a pinned file, whose selection state - // still has to be readable. No checkboxes when only a directory - // is being picked. - let checked = browser.selected.contains_key(&entry.path); + // for whatever is picked — and both for a pinned file, whose + // selection state still has to be readable. No checkboxes when + // only a directory is being picked. + let picked = browser.selected.get(&entry.path); + // Checked, but dimmed: a picked folder put it there. + let covered = picked.is_none() && browser.covered_by(&entry.path).is_some(); + let checked = picked.is_some() || covered; let (marker, marker_color) = match (entry.pinned, entry.is_dir) { + (true, _) if checked => ("★[x]", theme::ACCENT), (true, true) => (" ★", theme::ACCENT), - (true, false) if checked => ("★[x]", theme::ACCENT), (true, false) => ("★[ ]", theme::ACCENT), + (false, true) if checked => ("[x]/", theme::ACCENT), (false, true) => (" /", theme::DIM), (false, false) if picking_dir => ("", theme::DIM), (false, false) if checked => ("[x]", theme::ACCENT), (false, false) => ("[ ]", theme::DIM), }; + let marker_color = if covered { theme::DIM } else { marker_color }; painter.text( rect.left_center() + egui::vec2(padding, 0.0), egui::Align2::LEFT_CENTER, @@ -208,11 +218,12 @@ pub fn render( ui.visuals().text_color(), ); // Pinned rows show where they lead: two cards can carry folders - // with the same name. + // with the same name. A picked folder shows what it weighs — + // nothing else on the row can say it. let trailing = if entry.pinned { truncate_middle(&entry.path.display().to_string(), 40) } else if entry.is_dir { - String::new() + picked.map_or_else(String::new, |p| super::fmt_bytes(p.bytes)) } else { super::fmt_bytes(entry.size) }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a9d5e39..38159db 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -455,9 +455,13 @@ impl AppUi { } } +/// Room a transfer row has for a name. Reached by the files of a sent folder, +/// which are named by their path. +const ROW_NAME_CHARS: usize = 44; + fn file_row(name: String, size: u64, state: &FileState, moved: u64) -> transfer::FileRow { transfer::FileRow { - name, + name: truncate_middle(&name, ROW_NAME_CHARS), size, glyph: match state { FileState::Done => "√", diff --git a/tests/send.rs b/tests/send.rs index 1b2a370..e3ab579 100644 --- a/tests/send.rs +++ b/tests/send.rs @@ -129,6 +129,59 @@ fn sends_files_end_to_end() { stop(); } +#[test] +fn sends_a_folder_as_a_folder() { + let (_shared, base, save_dir, stop) = start_receiver(true); + + let src = temp_dir("src-folder"); + let games = src.join("games"); + std::fs::create_dir_all(games.join("gb/saves")).unwrap(); + std::fs::write(games.join("gb/zelda.gbc"), b"ROM").unwrap(); + std::fs::write(games.join("gb/saves/zelda.sav"), b"SAVE").unwrap(); + std::fs::write(games.join("readme.txt"), b"hi").unwrap(); + // A loose file beside the folder travels with it, unnested. + std::fs::write(src.join("loose.dat"), b"LOOSE").unwrap(); + + let session = outbound::spawn( + "Receiver".into(), + base.clone(), + device("Sender"), + vec![games.clone(), src.join("loose.dat")], + Arc::new(NoopWake), + ) + .unwrap(); + + assert_eq!(wait_finished(&session), OutboundPhase::Done); + assert_eq!(session.done_count(), 4); + // The tree is rebuilt under the receiver's save folder, name included. + assert_eq!( + std::fs::read(save_dir.join("games/gb/zelda.gbc")).unwrap(), + b"ROM" + ); + assert_eq!( + std::fs::read(save_dir.join("games/gb/saves/zelda.sav")).unwrap(), + b"SAVE" + ); + assert_eq!( + std::fs::read(save_dir.join("games/readme.txt")).unwrap(), + b"hi" + ); + assert_eq!(std::fs::read(save_dir.join("loose.dat")).unwrap(), b"LOOSE"); + + // A resend repeats the picked folder, not the files it happened to hold. + let entry = HistoryEntry::from_outbound(&session); + assert_eq!( + entry.files, + [ + games.display().to_string(), + src.join("loose.dat").display().to_string() + ] + ); + + std::fs::remove_dir_all(&src).unwrap(); + stop(); +} + #[test] fn decline_ends_the_send_as_declined() { let (shared, base, _save_dir, stop) = start_receiver(false);