diff --git a/CHANGELOG.md b/CHANGELOG.md
index c231442..513ee95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **A send can be repeated from the History tab**, with A on its row. The log
+ now keeps the source paths of a send and the address it went to; the resend
+ dials the peer under that alias as the radar has it *now*, so a device that
+ changed address in between is still reached, and falls back to the recorded
+ address when it is off the radar. Files deleted since are left out — the rest
+ still go, and a toast says how many were missing. Sends of more than 64 files
+ are logged without their paths rather than growing `history.json` without
+ bound, and their rows offer no resend.
+- **A history row can be dropped from the log**, with X.
+
## [0.7.1] - 2026-08-24
### Changed
diff --git a/README.md b/README.md
index c67355d..cf6273c 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,10 @@ or PC over Wi-Fi, no cable or SSH. Compatible with the official LocalSend apps.
It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running OnionOS or Allium, all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android handhelds and phones too.
+
+
+
+
| Receive | Request | Save | Transfer |
|:---:|:---:|:---:|:---:|
|  |  |  |  |
@@ -38,6 +42,7 @@ this is the missing end: a client built for a gamepad and screen.
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.
+- **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.
- **Encryption** — the protocol's HTTPS mode, on by default; works with the official app's default settings both ways.
@@ -124,9 +129,9 @@ hint along the bottom to press the button it names.
| Pad | Keyboard | Action |
|--------------|------------|-----------------------------------------------|
| D-pad / stick| Arrows | Navigate · left/right switch tabs |
-| A | Enter | Send to device · select file · accept · type |
+| 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 · 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 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)|
diff --git a/resources/retsend-devices.jpg b/resources/retsend-devices.jpg
new file mode 100644
index 0000000..f850c16
Binary files /dev/null and b/resources/retsend-devices.jpg differ
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 34d53f5..f157215 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -15,7 +15,7 @@ use crate::overlay::settings::SettingsRow;
use crate::overlay::tabs::Tab;
use crate::overlay::transfer::Viewed;
use crate::overlay::Focus;
-use crate::transfer::history::History;
+use crate::transfer::history::{History, HistoryEntry};
use crate::transfer::outbound::{self, OutboundSession};
use crate::ui::AppUi;
use std::path::PathBuf;
@@ -325,6 +325,10 @@ impl App {
.osk
.open(OskTarget::PeerAddress, &local_subnet_prefix());
}
+ // X on the history: drop the row from the log.
+ (Focus::Tabs, AppCommand::Alt) if self.ui.tabs.active() == Tab::History => {
+ self.delete_history_entry();
+ }
// Nothing left to leave. Android's Back is a system button that has
// to lead somewhere, so there it quits; on the handhelds the
// launcher owns quitting and this stays inert.
@@ -408,8 +412,8 @@ impl App {
}
}
- /// A on a tab: Send opens the browser for the selected device, Settings
- /// edits the current row, Receive does nothing.
+ /// A on a tab: Send opens the browser for the selected device, History
+ /// repeats the selected send, Settings edits the current row.
fn tab_confirm(&mut self) {
match self.ui.tabs.active() {
Tab::Send => {
@@ -418,11 +422,67 @@ impl App {
}
}
Tab::Receive => {}
- Tab::History => {}
+ Tab::History => self.resend_history_entry(),
Tab::Settings => self.edit_setting(),
}
}
+ /// X on a History row: drop it from the log. The count is refreshed here
+ /// because the cursor is clamped against it before the next frame builds it.
+ fn delete_history_entry(&mut self) {
+ let Some(row) = self.ui.history.cursor(self.ui.history_count) else {
+ return;
+ };
+ if self.history.remove(row).is_some() {
+ self.ui.history_count = self.history.entries().len();
+ self.ui.toasts.push("Entry removed");
+ }
+ }
+
+ /// A on a History row: send the same files to the same peer again.
+ fn resend_history_entry(&mut self) {
+ let Some(row) = self.ui.history.cursor(self.ui.history_count) else {
+ return;
+ };
+ let Some(entry) = self.history.get(row).cloned() else {
+ return;
+ };
+ if !entry.resendable() {
+ self.ui.toasts.push("Only a send can be repeated");
+ return;
+ }
+ let Some(base) = self.resend_base(&entry) else {
+ self.ui
+ .toasts
+ .push(format!("{} is not on the network", entry.peer));
+ return;
+ };
+ let (files, gone) = existing_files(&entry.files);
+ if files.is_empty() {
+ self.ui.toasts.push("Those files are gone");
+ return;
+ }
+ if gone > 0 {
+ self.ui
+ .toasts
+ .push(format!("{gone} files are gone — sending the rest"));
+ }
+ self.start_send(entry.peer, base, files);
+ }
+
+ /// Where a resend dials: the peer under that alias as the radar has it now
+ /// (its address may have moved since), else the recorded address.
+ fn resend_base(&self, entry: &HistoryEntry) -> Option {
+ self.net
+ .shared
+ .peers
+ .snapshot()
+ .iter()
+ .find(|p| p.info.alias == entry.peer)
+ .map(|p| p.base_url())
+ .or_else(|| (!entry.peer_base.is_empty()).then(|| entry.peer_base.clone()))
+ }
+
/// A on a settings row: open its editor or toggle it.
fn edit_setting(&mut self) {
match self.ui.settings.row() {
@@ -782,8 +842,18 @@ impl App {
let files = self.ui.browser.selected_paths();
self.remember_send_dir();
self.ui.browser.close();
+ self.start_send(target.alias, target.base, files);
+ }
+
+ /// Start the worker for a send and take over the screen with it. Shared by
+ /// the browser's Start and the History tab's resend.
+ fn start_send(&mut self, alias: String, base: String, files: Vec) {
+ if self.outbound.as_ref().is_some_and(|o| !o.is_finished()) {
+ self.ui.toasts.push("A send is already running");
+ return;
+ }
let me = self.net.shared.me.lock().unwrap().clone();
- match outbound::spawn(target.alias, target.base, me, files, self.wake.clone()) {
+ match outbound::spawn(alias, base, me, files, self.wake.clone()) {
Ok(session) => {
self.net
.shared
@@ -823,6 +893,18 @@ 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
+ .iter()
+ .map(PathBuf::from)
+ .filter(|p| p.is_file())
+ .collect();
+ let gone = paths.len() - files.len();
+ (files, gone)
+}
+
/// Vertical lists: up/down move the cursor; left/right are reserved for value
/// steppers and do nothing in plain lists.
fn nav_delta(dir: Direction) -> i32 {
diff --git a/src/transfer/history.rs b/src/transfer/history.rs
index f54da66..26fa56d 100644
--- a/src/transfer/history.rs
+++ b/src/transfer/history.rs
@@ -12,6 +12,10 @@ 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.
+const MAX_RESEND_FILES: usize = 64;
+
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Direction {
Sent,
@@ -45,9 +49,22 @@ pub struct HistoryEntry {
/// Empty when nothing moved, or for entries written before this field.
#[serde(default)]
pub path: String,
+ /// Source paths of a send, for a resend. Empty on received entries, on
+ /// sends over [`MAX_RESEND_FILES`], and on entries predating this field.
+ #[serde(default)]
+ pub files: Vec,
+ /// `http://ip:port` the send went to — where a resend goes when the peer
+ /// is no longer on the radar.
+ #[serde(default)]
+ pub peer_base: String,
}
impl HistoryEntry {
+ /// Can this entry be sent again?
+ pub fn resendable(&self) -> bool {
+ self.direction == Direction::Sent && !self.files.is_empty()
+ }
+
pub fn from_inbound(s: &InboundSession) -> Self {
let done = s.done_count();
let total = s.files.len();
@@ -70,6 +87,8 @@ impl HistoryEntry {
.filter(|f| *f.state.lock().unwrap() == FileState::Done)
.filter_map(|f| f.dest.parent()),
),
+ files: Vec::new(),
+ peer_base: String::new(),
}
}
@@ -97,6 +116,17 @@ 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 —
+ // 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()
+ } else {
+ Vec::new()
+ },
+ peer_base: s.base.clone(),
}
}
}
@@ -143,14 +173,48 @@ impl History {
}
}
- /// Append and persist. Best-effort write — a read-only SD degrades to an
- /// in-memory log, not a crash.
+ /// Append and persist.
pub fn record(&mut self, entry: HistoryEntry) {
self.entries.push(entry);
let overflow = self.entries.len().saturating_sub(self.limit);
if overflow > 0 {
self.entries.drain(..overflow);
}
+ self.save();
+ }
+
+ /// Entries oldest-first (the renderer walks them newest-first).
+ pub fn entries(&self) -> &[HistoryEntry] {
+ &self.entries
+ }
+
+ /// Entries newest-first — the order the History tab lists them, and what
+ /// its row indices count.
+ pub fn newest_first(&self) -> impl Iterator- {
+ self.entries.iter().rev()
+ }
+
+ /// The entry a History row shows.
+ pub fn get(&self, row: usize) -> Option<&HistoryEntry> {
+ self.entries.get(self.index_of(row)?)
+ }
+
+ /// Drop the entry a History row shows and persist.
+ pub fn remove(&mut self, row: usize) -> Option {
+ let index = self.index_of(row)?;
+ let entry = self.entries.remove(index);
+ self.save();
+ Some(entry)
+ }
+
+ /// Storage index (oldest-first) of a newest-first row.
+ fn index_of(&self, row: usize) -> Option {
+ self.entries.len().checked_sub(row + 1)
+ }
+
+ /// Best-effort write — a read-only SD degrades to an in-memory log, not a
+ /// crash.
+ fn save(&self) {
match serde_json::to_string_pretty(&self.entries) {
Ok(text) => {
if let Err(e) = std::fs::write(&self.path, text) {
@@ -160,11 +224,6 @@ impl History {
Err(e) => log::warn!("could not serialize history: {e}"),
}
}
-
- /// Entries oldest-first (the renderer walks them newest-first).
- pub fn entries(&self) -> &[HistoryEntry] {
- &self.entries
- }
}
fn now_unix() -> u64 {
@@ -196,6 +255,8 @@ mod tests {
outcome: Outcome::Completed,
at: 0,
path: "/save".to_string(),
+ files: vec!["/save/rom.gbc".to_string()],
+ peer_base: "http://10.0.0.2:53317".to_string(),
}
}
@@ -214,6 +275,24 @@ mod tests {
std::fs::remove_dir_all(dir.trim_end_matches('/')).unwrap();
}
+ #[test]
+ fn remove_takes_the_newest_first_row_and_persists() {
+ let dir = temp_dir("remove");
+ {
+ let mut h = History::load(&dir, 10);
+ for i in 0..3 {
+ h.record(entry(&format!("p{i}")));
+ }
+ // Row 0 is the newest ("p2"), row 2 the oldest.
+ assert_eq!(h.get(0).unwrap().peer, "p2");
+ assert_eq!(h.remove(1).unwrap().peer, "p1");
+ assert!(h.remove(2).is_none());
+ assert_eq!(peers(&h), ["p0", "p2"]);
+ }
+ assert_eq!(peers(&History::load(&dir, 10)), ["p0", "p2"]);
+ std::fs::remove_dir_all(dir.trim_end_matches('/')).unwrap();
+ }
+
#[test]
fn inbound_entry_records_where_the_files_landed() {
use crate::net::protocol::FileMeta;
diff --git a/src/transfer/outbound.rs b/src/transfer/outbound.rs
index 24421da..d0b1e43 100644
--- a/src/transfer/outbound.rs
+++ b/src/transfer/outbound.rs
@@ -36,6 +36,8 @@ pub struct OutboundFile {
pub struct OutboundSession {
pub peer_alias: String,
+ /// `http://ip:port` this send dials; the history keeps it for a resend.
+ pub base: String,
pub files: Vec,
pub total_bytes: u64,
pub sent_total: AtomicU64,
@@ -116,6 +118,7 @@ pub fn spawn(
let session = Arc::new(OutboundSession {
peer_alias,
+ base,
files,
total_bytes: total,
sent_total: AtomicU64::new(0),
@@ -127,13 +130,14 @@ pub fn spawn(
let worker = session.clone();
std::thread::Builder::new()
.name("outbound".into())
- .spawn(move || run(worker, base, me, wake))?;
+ .spawn(move || run(worker, me, wake))?;
Ok(session)
}
-fn run(session: Arc, base: String, me: DeviceInfo, wake: Arc) {
+fn run(session: Arc, me: DeviceInfo, wake: Arc) {
+ let base = &session.base;
let metas: Vec = session.files.iter().map(|f| f.meta.clone()).collect();
- let response = match client::prepare_upload(&base, &me, &metas) {
+ let response = match client::prepare_upload(base, &me, &metas) {
Ok(r) => r,
Err(e) => {
let phase = match e {
@@ -152,7 +156,7 @@ fn run(session: Arc, base: String, me: DeviceInfo, wake: Arc, base: String, me: DeviceInfo, wake: Arc, base: String, me: DeviceInfo, wake: Arc,
pub cursor: Option,
+ /// The row under the cursor is a send whose files are still recorded, so
+ /// the resend hint is offered.
+ pub can_resend: bool,
}
/// Build a row from an entry, resolving the relative time against `now`
@@ -56,7 +60,14 @@ pub fn row(e: &HistoryEntry, now: u64) -> HistoryRow {
pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec) {
egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| {
ui.add_space(4.0);
- super::home::hint_bar(ui, &[("← →", "Tabs", None)], taps);
+ let mut hints: Vec = vec![("← →", "Tabs", None)];
+ if !data.rows.is_empty() {
+ hints.push(("X", "Delete", Some(AppCommand::Alt)));
+ }
+ if data.can_resend {
+ hints.push(("A", "Send again", Some(AppCommand::Confirm)));
+ }
+ super::home::hint_bar(ui, &hints, taps);
ui.add_space(4.0);
});
@@ -101,7 +112,8 @@ pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec viewport.max.y {
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index d43e2cf..aeb0cbe 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -326,12 +326,14 @@ impl AppUi {
Tab::History => {
self.history_count = history.entries().len();
let now = unix_now();
+ let cursor = self.history.cursor(self.history_count);
Screen::History(history::HistoryData {
- cursor: self.history.cursor(self.history_count),
+ cursor,
+ can_resend: cursor
+ .and_then(|row| history.get(row))
+ .is_some_and(|e| e.resendable()),
rows: history
- .entries()
- .iter()
- .rev()
+ .newest_first()
.map(|e| history::row(e, now))
.collect(),
})
diff --git a/tests/send.rs b/tests/send.rs
index 49fe68d..1b2a370 100644
--- a/tests/send.rs
+++ b/tests/send.rs
@@ -4,6 +4,7 @@
use retsend::net::discovery::PeerRegistry;
use retsend::net::protocol::{self, DeviceInfo};
use retsend::net::{server, NetShared, TransferSettings, Wake, WakeReason};
+use retsend::transfer::history::HistoryEntry;
use retsend::transfer::outbound::{self, OutboundPhase};
use std::net::TcpStream;
use std::path::PathBuf;
@@ -93,7 +94,7 @@ fn sends_files_end_to_end() {
let session = outbound::spawn(
"Receiver".into(),
- base,
+ base.clone(),
device("Sender"),
vec![src.join("game.gbc"), src.join("save.dat")],
Arc::new(NoopWake),
@@ -112,6 +113,18 @@ fn sends_files_end_to_end() {
);
assert_eq!(std::fs::read(save_dir.join("save.dat")).unwrap(), b"SAVE");
+ // What the History tab needs to repeat this send.
+ let entry = HistoryEntry::from_outbound(&session);
+ assert!(entry.resendable());
+ assert_eq!(
+ entry.files,
+ [
+ src.join("game.gbc").display().to_string(),
+ src.join("save.dat").display().to_string()
+ ]
+ );
+ assert_eq!(entry.peer_base, base);
+
std::fs::remove_dir_all(&src).unwrap();
stop();
}