Skip to content

Commit ede48a5

Browse files
Implement background operations for file transfers
1 parent d440327 commit ede48a5

16 files changed

Lines changed: 753 additions & 33 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rat-commander"
3-
version = "1.2.4"
3+
version = "1.3.0"
44
edition = "2024"
55
description = "A self-contained Norton/Midnight-Commander-style TUI file manager"
66
license = "GPL-2.0-only"

src/app/state/dialogs.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ impl AppState {
8585
// Keep the progress dialog until TaskDone confirms cancellation.
8686
Flow::Continue
8787
}
88+
DialogResult::Background(id) => {
89+
// Dismiss the progress dialog but keep the transfer running; it
90+
// lives on in `tasks`/`task_progress` and shows in the mini bar.
91+
if let Some(Dialog::Progress(p)) = &self.dialog
92+
&& p.id == id
93+
{
94+
self.dialog = None;
95+
}
96+
// Remote (FTP) transfers: open a fresh browsing connection so the
97+
// panel isn't blocked sharing the transfer's single connection.
98+
self.background_reconnect_ftp(id).await;
99+
Flow::Continue
100+
}
88101
DialogResult::Overwrite(id, decision) => {
89102
// Send the decision back to the paused engine, then restore the
90103
// operation's progress dialog. (On Abort, TaskDone will close it.)
@@ -127,6 +140,12 @@ impl AppState {
127140
}
128141
}
129142
Submit::Compress(sources, name) => self.start_compress(sources, name),
143+
Submit::ForegroundTask(id) => {
144+
// Re-open the progress dialog for a backgrounded transfer.
145+
if self.tasks.contains_key(&id) {
146+
self.dialog = Some(Dialog::Progress(self.progress_dialog_for(id)));
147+
}
148+
}
130149
Submit::Checksum { path, kind, expected } => self.start_checksum(path, kind, expected),
131150
Submit::Connect(side, creds) => self.connect_remote(side, creds).await,
132151
Submit::UserCommand(tpl) => self.pending_run = Some(self.expand_macros(&tpl)),

src/app/state/fileops.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ impl AppState {
9797
OpKind::Move => "Moving",
9898
OpKind::Delete => "Deleting",
9999
};
100+
// Remote backend schemes this op touches, so a later "To background" can
101+
// reopen a browsing connection for FTP (which blocks while transferring).
102+
let mut schemes: Vec<String> = Vec::new();
103+
let src_scheme = self.panels[self.active].cwd.scheme.clone();
104+
if src_scheme != "file" {
105+
schemes.push(src_scheme);
106+
}
107+
if let Some(d) = &dst_dir
108+
&& d.scheme != "file"
109+
&& !schemes.contains(&d.scheme)
110+
{
111+
schemes.push(d.scheme.clone());
112+
}
100113
let req = OpRequest {
101114
kind,
102115
src_fs: self.panels[self.active].backend.clone(),
@@ -108,7 +121,72 @@ impl AppState {
108121
};
109122
let handle = spawn_op(id, req, self.tx.clone());
110123
self.tasks.insert(id, handle);
111-
self.dialog = Some(Dialog::Progress(ProgressDialog::new(id, verb)));
124+
// Track it as a backgroundable transfer (drives the mini bar / list).
125+
self.task_progress.insert(id, BgTransfer { verb, update: None, schemes });
126+
let mut pd = ProgressDialog::new(id, verb);
127+
pd.backgroundable = true;
128+
self.dialog = Some(Dialog::Progress(pd));
129+
}
130+
131+
/// One list row per tracked transfer, ordered by id (stable across updates).
132+
fn background_rows(&self) -> Vec<BgRow> {
133+
let mut rows: Vec<BgRow> = self
134+
.task_progress
135+
.iter()
136+
.map(|(id, t)| {
137+
let (done, total, name) = t
138+
.update
139+
.as_ref()
140+
.map(|u| (u.total_done, u.total_total, u.current_name.clone()))
141+
.unwrap_or((0, 0, String::new()));
142+
let ratio = if total > 0 { done as f64 / total as f64 } else { 0.0 };
143+
let label = if name.is_empty() {
144+
t.verb.to_string()
145+
} else {
146+
format!("{} {name}", t.verb)
147+
};
148+
BgRow { id: *id, label, ratio }
149+
})
150+
.collect();
151+
rows.sort_by_key(|r| r.id);
152+
rows
153+
}
154+
155+
/// Open the "Background operations" list of running transfers.
156+
pub(in crate::app::state) fn open_background_ops(&mut self) {
157+
let rows = self.background_rows();
158+
if rows.is_empty() {
159+
self.show_info("Background operations", "No background operations are running.");
160+
return;
161+
}
162+
self.dialog = Some(Dialog::BackgroundOps(BackgroundOpsDialog::new(rows)));
163+
}
164+
165+
/// Refresh the open "Background operations" list from the latest progress so
166+
/// its bars advance live. Closes the list once no transfers remain.
167+
pub(in crate::app::state) fn refresh_background_ops(&mut self) {
168+
if !matches!(self.dialog, Some(Dialog::BackgroundOps(_))) {
169+
return;
170+
}
171+
let rows = self.background_rows();
172+
if rows.is_empty() {
173+
self.dialog = None;
174+
} else if let Some(Dialog::BackgroundOps(d)) = &mut self.dialog {
175+
d.set_rows(rows);
176+
}
177+
}
178+
179+
/// Rebuild a progress dialog for a (possibly backgrounded) transfer from its
180+
/// latest snapshot — used to foreground it (from the list, or when it hits an
181+
/// overwrite conflict).
182+
pub(in crate::app::state) fn progress_dialog_for(&self, id: TaskId) -> ProgressDialog {
183+
let verb = self.task_progress.get(&id).map(|t| t.verb).unwrap_or("Copying");
184+
let mut d = ProgressDialog::new(id, verb);
185+
d.backgroundable = true;
186+
if let Some(u) = self.task_progress.get(&id).and_then(|t| t.update.as_ref()) {
187+
d.update(u);
188+
}
189+
d
112190
}
113191

114192
/// Open the multi-rename dialog for the currently *selected* files. Requires

src/app/state/keys.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ impl AppState {
182182
MenuAction::Symlink => self.open_symlink(),
183183
MenuAction::Compress => self.open_compress(),
184184
MenuAction::Checksum => self.open_checksum(),
185+
MenuAction::BackgroundOps => self.open_background_ops(),
185186
MenuAction::SelectGroup => self.open_select_group(true),
186187
MenuAction::UnselectGroup => self.open_select_group(false),
187188
MenuAction::Invert => self.invert_selection(),

src/app/state/lifecycle.rs

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ impl AppState {
4141
sessions: Vec::new(),
4242
last_local_cwd: [cwd.clone(), cwd],
4343
tasks: HashMap::new(),
44+
task_progress: HashMap::new(),
4445
next_task_id: 1,
4546
next_session_id: 0,
4647
tx,
@@ -185,6 +186,45 @@ impl AppState {
185186
}
186187
}
187188

189+
/// Aggregate progress of the **background** transfers (those not currently
190+
/// shown as the foreground progress dialog): `(bytes done, bytes total,
191+
/// count)`, or `None` when nothing is running in the background.
192+
pub(crate) fn background_summary(&self) -> Option<(u64, u64, usize)> {
193+
let foreground = match &self.dialog {
194+
Some(Dialog::Progress(p)) => Some(p.id),
195+
_ => None,
196+
};
197+
let (mut done, mut total, mut count) = (0u64, 0u64, 0usize);
198+
for (id, t) in &self.task_progress {
199+
if Some(*id) == foreground {
200+
continue;
201+
}
202+
count += 1;
203+
if let Some(u) = &t.update {
204+
done += u.total_done;
205+
total += u.total_total;
206+
}
207+
}
208+
(count > 0).then_some((done, total, count))
209+
}
210+
211+
/// The menu-bar rect for the mini background-progress bar (left of the
212+
/// system-status widget), or `None` when nothing runs in the background.
213+
/// Shared by the renderer and mouse hit-testing so they stay in sync.
214+
pub(crate) fn menu_progress_rect(&self, menubar_row: Rect) -> Option<Rect> {
215+
self.background_summary()?;
216+
let mini_w = 24u16.min(menubar_row.width);
217+
let status_shown = self.config.system_status
218+
&& menubar_row.width >= crate::ui::menubar::STATUS_MIN_WIDTH;
219+
let right_edge = if status_shown {
220+
menubar_row.x + menubar_row.width.saturating_sub(crate::ui::menubar::STATUS_WIDTH)
221+
} else {
222+
menubar_row.x + menubar_row.width
223+
};
224+
let x = right_edge.saturating_sub(mini_w).max(menubar_row.x);
225+
Some(Rect { x, y: menubar_row.y, width: mini_w, height: 1 })
226+
}
227+
188228
// -- Event handling ----------------------------------------------------
189229

190230
pub async fn apply_event(&mut self, ev: AppEvent) {
@@ -195,22 +235,33 @@ impl AppState {
195235
{
196236
p.update(&u);
197237
}
238+
// Keep the background snapshot current even with no visible dialog
239+
// (drives the menu-bar mini bar and the Background-operations list).
240+
if let Some(t) = self.task_progress.get_mut(&u.id) {
241+
t.update = Some(u);
242+
}
243+
// Advance the open "Background operations" list live.
244+
self.refresh_background_ops();
198245
}
199246
AppEvent::Conflict(info) => {
200-
// The engine is paused awaiting a decision. Stash the progress
201-
// dialog and raise the overwrite prompt over it.
202-
if let Some(Dialog::Progress(p)) = self.dialog.take() {
203-
self.stashed_progress = Some(p);
204-
}
247+
// The engine is paused awaiting a decision. Bring the conflicting
248+
// transfer to the foreground (rebuild its progress dialog from the
249+
// latest snapshot) and raise the overwrite prompt over it; the
250+
// Overwrite reply restores the stashed progress dialog. This works
251+
// whether the task was foreground or in the background.
252+
self.stashed_progress = Some(self.progress_dialog_for(info.id));
205253
self.dialog = Some(Dialog::Overwrite(OverwriteDialog::new(info)));
206254
}
207255
AppEvent::TaskDone { id, outcome } => {
208256
self.tasks.remove(&id);
257+
self.task_progress.remove(&id);
209258
if let Some(Dialog::Progress(p)) = &self.dialog
210259
&& p.id == id
211260
{
212261
self.dialog = None;
213262
}
263+
// Drop the finished task from an open "Background operations" list.
264+
self.refresh_background_ops();
214265
if let TaskOutcome::Failed(msg) = outcome {
215266
self.dialog = Some(Dialog::Message(MessageDialog::error(msg)));
216267
}

src/app/state/mod.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use crate::panel::{Panel, ViewFormat};
1515
use crate::proc::{ProcSignal, ProcView};
1616
use crate::ui::cmdline::CommandLine;
1717
use crate::ui::dialog::{
18-
BusyDialog, ChecksumResultDialog, CompareDialog, CompareMode, ConfirmDialog, Dialog,
19-
DialogResult, DriveDialog,
18+
BackgroundOpsDialog, BgRow, BusyDialog, ChecksumResultDialog, CompareDialog, CompareMode,
19+
ConfirmDialog, Dialog, DialogResult, DriveDialog,
2020
DupCriteria, FileBrowserDialog, FindDialog, FindParams, FlashTargetDialog, FormDialog, GotoDialog,
2121
ImageSaveDialog, InputDialog, InputPurpose, MessageDialog, MultiRenameDialog, OverwriteDialog,
2222
ProgressDialog, SaveAsDialog, SearchReplaceDialog, SearchReplaceParams, SelectDialog, Submit,
@@ -85,6 +85,23 @@ pub struct RemoteSession {
8585
pub label: String,
8686
/// The last directory visited on this session, restored on switch-back.
8787
pub cwd: VfsPath,
88+
/// Credentials (including the in-memory password) used to open this session,
89+
/// kept so a *second* connection can be opened for browsing when a transfer
90+
/// on this session is sent to the background (see FTP reconnect). Retained in
91+
/// memory only for the session's lifetime — never persisted.
92+
pub creds: RemoteCreds,
93+
}
94+
95+
/// A file transfer that can run in the background: the state kept for the
96+
/// menu-bar mini progress bar and the "Background operations" list once its
97+
/// modal progress dialog has been dismissed.
98+
pub(in crate::app::state) struct BgTransfer {
99+
/// "Copying" / "Moving" / "Deleting".
100+
pub verb: &'static str,
101+
/// Latest progress snapshot (`None` until the first update arrives).
102+
pub update: Option<ProgressUpdate>,
103+
/// Remote backend schemes this op touches (used to decide FTP reconnect).
104+
pub schemes: Vec<String>,
88105
}
89106

90107
/// How to execute a privileged command on the background task.
@@ -152,6 +169,11 @@ pub struct AppState {
152169
/// returns to where it was before going remote (drive-letter style).
153170
last_local_cwd: [VfsPath; 2],
154171
tasks: HashMap<TaskId, TaskHandle>,
172+
/// Live progress state for backgroundable transfers (copy/move/delete),
173+
/// keyed by task id. Populated for every such task so the menu-bar mini bar
174+
/// and the "Background operations" list keep updating even when the task's
175+
/// progress dialog is not the foreground one.
176+
pub(in crate::app::state) task_progress: HashMap<TaskId, BgTransfer>,
155177
next_task_id: TaskId,
156178
next_session_id: usize,
157179
tx: AppSender,

src/app/state/mouse.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ impl AppState {
157157
if let Some(flow) = self.fkey_bar_click(area, col, row).await {
158158
return flow;
159159
}
160+
// A click on the menu-bar mini progress bar opens the list of
161+
// background operations.
162+
let menubar_row = Rect { x: area.x, y: area.y, width: area.width, height: 1 };
163+
if let Some(mr) = self.menu_progress_rect(menubar_row)
164+
&& col >= mr.x
165+
&& col < mr.x + mr.width
166+
&& row == mr.y
167+
{
168+
self.open_background_ops();
169+
return Flow::Continue;
170+
}
160171
// A click on the menu bar (top row) opens that menu.
161172
if let Some(i) = MenuBarState::title_index_at(area, col, row) {
162173
self.menu = Some(MenuBarState::new(i, &self.session_list()));

src/app/state/remote.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ impl AppState {
8383
scheme,
8484
label: conn.label,
8585
cwd,
86+
creds: creds.clone(),
8687
});
8788

8889
// Remember this server (without the password) for the dropdown.
@@ -99,6 +100,54 @@ impl AppState {
99100
}
100101
}
101102

103+
/// When a transfer is sent to the background, open a fresh browsing
104+
/// connection for any panel sitting on an **FTP** session that the transfer
105+
/// uses — FTP holds a single control connection for the whole transfer, so
106+
/// browsing would otherwise block. SFTP/SCP multiplex safely and are left
107+
/// alone. The transfer keeps its own backend `Arc`, so re-registering schemes
108+
/// never disturbs it.
109+
pub(in crate::app::state) async fn background_reconnect_ftp(&mut self, id: TaskId) {
110+
let schemes = match self.task_progress.get(&id) {
111+
Some(t) if !t.schemes.is_empty() => t.schemes.clone(),
112+
_ => return,
113+
};
114+
for side in 0..2 {
115+
let scheme = self.panels[side].cwd.scheme.clone();
116+
if !schemes.contains(&scheme) {
117+
continue;
118+
}
119+
let Some(sess_idx) = self.sessions.iter().position(|s| s.scheme == scheme) else {
120+
continue;
121+
};
122+
if self.sessions[sess_idx].creds.protocol != crate::vfs::remote::Protocol::Ftp {
123+
continue;
124+
}
125+
let creds = self.sessions[sess_idx].creds.clone();
126+
match crate::vfs::remote::connect(&creds).await {
127+
Ok(conn) => {
128+
let new_id = self.next_session_id;
129+
self.next_session_id += 1;
130+
let new_scheme = format!("{}-{}", creds.protocol.scheme_prefix(), new_id);
131+
self.registry.register(new_scheme.clone(), conn.backend.clone());
132+
// Safe: the transfer holds its own Arc, not the registry entry.
133+
self.registry.unregister(&scheme);
134+
let path = self.panels[side].cwd.path.clone();
135+
let new_cwd =
136+
VfsPath { scheme: new_scheme.clone(), path, container: None };
137+
self.panels[side].cwd = new_cwd.clone();
138+
self.panels[side].backend = conn.backend;
139+
let _ = self.panels[side].reload().await;
140+
// Repoint the session record in place (same id/label, new scheme).
141+
self.sessions[sess_idx].scheme = new_scheme;
142+
self.sessions[sess_idx].cwd = new_cwd;
143+
}
144+
Err(e) => {
145+
self.show_error(format!("Could not open a browsing connection: {e}"))
146+
}
147+
}
148+
}
149+
}
150+
102151
/// Switch panel `side` to an already-open remote session, landing on the
103152
/// directory it was last viewing there.
104153
pub(in crate::app::state) async fn switch_to_session(&mut self, side: usize, id: usize) {

0 commit comments

Comments
 (0)