Skip to content

Commit bbe8625

Browse files
committed
Load directory entries asynchronously with streamed sorting
1 parent c25b12b commit bbe8625

10 files changed

Lines changed: 384 additions & 104 deletions

File tree

src/app.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,22 @@ use crate::{
2727
};
2828

2929
/// Maximum number of broadcast cycles per input command. Each cycle resolves
30-
/// one link in an intent → result chain; the longest legitimate chain is 5,
30+
/// one link in an intent → result chain; the longest legitimate chain is 4,
3131
/// which occurs when `Esc` exits the bookmarks or search view:
3232
///
3333
/// 1. `Key` — terminal input
3434
/// 2. `ResetView` — derived from the key
3535
/// 3. `RefreshDirectory` — `TableView` clears bookmarks/search and asks for a reload
3636
/// 4. `RefreshedDirectory` — result emitted by `FileSystem`
37-
/// 5. `SelectionChanged` — emitted by `TableView` for the new listing
3837
///
39-
/// Direct navigation (`Key → GoToParentDirectory/Open → NavigatedDirectory →
40-
/// SelectionChanged`) is one link shorter because the intent maps straight to
41-
/// `NavigatedDirectory` without a `RefreshDirectory` hop.
38+
/// `RefreshedDirectory`/`NavigatedDirectory` only switch the directory; the
39+
/// entries stream in afterward as `DirectoryListing`/`DirectoryListingComplete`,
40+
/// which arrive as fresh channel sends (each its own short chain, e.g.
41+
/// `DirectoryListingComplete → SelectionChanged`) rather than extending this one.
4242
///
4343
/// Also acts as a guard against a handler stuck deriving commands forever.
44-
/// See `broadcast_command` for what happens when it is exceeded.
44+
/// See `broadcast_command` for what happens when it is exceeded. The bound keeps
45+
/// one cycle of headroom over the longest real chain.
4546
const MAX_BROADCAST_CHAIN_LENGTH: u8 = 5;
4647

4748
pub struct App {
@@ -75,9 +76,15 @@ impl App {
7576
}
7677

7778
pub fn run(&mut self, initial_directory: Option<PathBuf>) -> Result<()> {
78-
// An initial command is required to start the main loop
79-
self.tx
80-
.send(self.file_system.run_once(initial_directory)?)?;
79+
// Trigger the initial navigation and handle it synchronously *before*
80+
// entering the loop. `run_once` spawns the directory loader, which begins
81+
// streaming `DirectoryListing` batches into the channel immediately;
82+
// handling the resulting `NavigatedDirectory` here registers its
83+
// generation before those batches are drained, so none are dropped.
84+
let initial = self.file_system.run_once(initial_directory)?;
85+
let remaining = self.broadcast_commands(vec![initial]);
86+
must_not_contain_unhandled(&remaining)?;
87+
self.render()?;
8188

8289
spawn_command_sender(self.tx.clone());
8390

src/command.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,27 @@ pub enum Command {
8787
GoToPreviousDirectory, // Intent: resolved by FileSystem into NavigatedDirectory
8888
Open(PathInfo), // Intent: FileSystem -> NavigatedDirectory (dir) or external open (file)
8989
NavigatedDirectory {
90-
// Result: of GoToParentDirectory / GoToPreviousDirectory / Open (emitted by FileSystem)
90+
// Result: of GoToParentDirectory / GoToPreviousDirectory / Open (emitted by FileSystem).
91+
// The entries are not included; they stream in afterward as DirectoryListing.
9192
directory: PathInfo,
92-
children: Vec<PathInfo>,
93+
generation: u64,
9394
},
9495
RefreshDirectory, // Intent: resolved by FileSystem into RefreshedDirectory
9596
RefreshedDirectory {
96-
// Result: of RefreshDirectory
97+
// Result: of RefreshDirectory. Entries stream in as DirectoryListing.
9798
directory: PathInfo,
98-
children: Vec<PathInfo>,
99+
generation: u64,
100+
},
101+
// Result: a batch of entries for the most recent Navigated/RefreshedDirectory,
102+
// streamed by FileSystem and appended (in read order) by TableView. `generation`
103+
// matches the switch command so stale batches from a superseded load are ignored.
104+
DirectoryListing {
105+
items: Vec<PathInfo>,
106+
generation: u64,
107+
},
108+
DirectoryListingComplete {
109+
// Result: the streamed listing finished; TableView sorts and restores selection.
110+
generation: u64,
99111
},
100112

101113
// File operations

src/file_system.rs

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ pub struct FileSystem {
4141
command_tx: Sender<Command>,
4242
directory: Option<PathInfo>,
4343
previous_directory: Option<PathInfo>,
44+
/// Cancellation token for the in-flight streamed directory load, if any.
45+
/// Cancelled when a new load starts so stale batches don't bleed across.
46+
current_load: Option<CancellationToken>,
47+
/// Monotonic id stamped on each load so consumers can ignore stale batches.
48+
next_load_id: u64,
4449
open_current_directory_template: String,
4550
open_new_window_template: String,
4651
open_selected_file_template: String,
@@ -64,6 +69,8 @@ impl FileSystem {
6469
command_tx,
6570
directory: None,
6671
previous_directory: None,
72+
current_load: None,
73+
next_load_id: 0,
6774
open_current_directory_template: config.openers.open_current_directory.clone(),
6875
open_new_window_template: config.openers.open_new_window.clone(),
6976
open_selected_file_template: config.openers.open_selected_file.clone(),
@@ -113,40 +120,54 @@ impl FileSystem {
113120
}
114121

115122
fn cd(&mut self, directory: PathInfo, navigate: bool) -> CommandResult {
116-
match operations::cd(&directory) {
117-
Ok((children, error_count)) => {
118-
if error_count > 0 {
119-
let _ = self.command_tx.send(Command::AlertWarn(format!(
120-
"{error_count} entries in {directory:?} could not be read"
121-
)));
122-
}
123-
// Track the directory we're leaving so "-" can toggle back to it.
124-
if navigate
125-
&& let Some(current) = &self.directory
126-
&& current.path != directory.path
127-
{
128-
self.previous_directory = Some(current.clone());
129-
}
130-
self.directory = Some(directory.clone());
131-
let path_buf = directory.path.clone();
132-
if let Some(watcher) = &mut self.watcher
133-
&& let Err(e) = watcher.watch_directory(path_buf.clone())
134-
{
135-
self.send_directory_error(&path_buf, e);
136-
}
137-
if navigate {
138-
Command::NavigatedDirectory {
139-
directory,
140-
children,
141-
}
142-
} else {
143-
Command::RefreshedDirectory {
144-
directory,
145-
children,
146-
}
147-
}
123+
// Cheap readability pre-flight so we don't switch into a directory we
124+
// cannot open (e.g. permission denied). The full per-entry read happens
125+
// asynchronously in `stream_cd` below.
126+
if let Err(error) = fs::read_dir(&directory.path) {
127+
return anyhow!("Failed to change to directory {directory:?}: {error}").into();
128+
}
129+
130+
// Track the directory we're leaving so "-" can toggle back to it.
131+
if navigate
132+
&& let Some(current) = &self.directory
133+
&& current.path != directory.path
134+
{
135+
self.previous_directory = Some(current.clone());
136+
}
137+
self.directory = Some(directory.clone());
138+
let path_buf = directory.path.clone();
139+
if let Some(watcher) = &mut self.watcher
140+
&& let Err(e) = watcher.watch_directory(path_buf.clone())
141+
{
142+
self.send_directory_error(&path_buf, e);
143+
}
144+
145+
// Cancel any in-flight load so its batches don't bleed into this one,
146+
// then start streaming the new directory's entries.
147+
if let Some(token) = self.current_load.take() {
148+
token.cancel();
149+
}
150+
self.next_load_id += 1;
151+
let generation = self.next_load_id;
152+
let token = CancellationToken::new();
153+
self.current_load = Some(token.clone());
154+
operations::stream_cd(
155+
directory.clone(),
156+
generation,
157+
self.command_tx.clone(),
158+
token,
159+
);
160+
161+
if navigate {
162+
Command::NavigatedDirectory {
163+
directory,
164+
generation,
165+
}
166+
} else {
167+
Command::RefreshedDirectory {
168+
directory,
169+
generation,
148170
}
149-
Err(error) => anyhow!("Failed to change to directory {directory:?}: {error}").into(),
150171
}
151172
.into()
152173
}

src/file_system/operations.rs

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,107 @@ use std::{
55
process::{Child, Stdio},
66
sync::mpsc::Sender,
77
thread,
8-
time::Duration,
8+
time::{Duration, Instant},
99
};
1010

1111
use anyhow::{Result, anyhow};
1212
use log::{info, warn};
1313

1414
use super::path_info::PathInfo;
15-
use crate::{app::config::Config, command::Command};
16-
17-
pub(super) fn cd(directory: &PathInfo) -> Result<(Vec<PathInfo>, usize)> {
18-
info!("Changing directory to {directory:?}");
19-
let entries = fs::read_dir(&directory.path)?;
20-
21-
// Use collect to gather results, then partition into successes and failures
22-
let results: Vec<Result<PathInfo>> = entries
23-
.map(|entry| {
24-
entry
25-
.map_err(Into::into)
26-
.and_then(|e| PathInfo::try_from(&e.path()))
27-
})
28-
.collect();
29-
30-
let (children, errors): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);
31-
32-
let error_count = errors.len();
33-
if error_count > 0 {
34-
warn!("Some paths could not be read: {:?}", errors);
35-
}
15+
use crate::{
16+
app::config::Config,
17+
command::{Command, progress::CancellationToken},
18+
};
19+
20+
// Entries are streamed in batches (rather than one command per entry) so a flood
21+
// of commands cannot starve terminal input in the single FIFO command channel.
22+
// A batch flushes once it reaches CD_BATCH_SIZE or CD_FLUSH_INTERVAL elapses.
23+
const CD_BATCH_SIZE: usize = 256;
24+
const CD_FLUSH_INTERVAL: Duration = Duration::from_millis(80);
25+
26+
/// Spawns a background thread that reads `directory` and streams its entries as
27+
/// `Command::DirectoryListing` batches, finishing with a
28+
/// `Command::DirectoryListingComplete`. `generation` tags every message so a
29+
/// superseded load (the user navigated away) can be ignored; `cancel` stops the
30+
/// walk early when that happens. Reading off the UI thread keeps navigation into
31+
/// very large directories responsive.
32+
pub(super) fn stream_cd(
33+
directory: PathInfo,
34+
generation: u64,
35+
tx: Sender<Command>,
36+
cancel: CancellationToken,
37+
) {
38+
info!("Streaming directory {directory:?}");
39+
thread::spawn(move || {
40+
let entries = match fs::read_dir(&directory.path) {
41+
Ok(entries) => entries,
42+
Err(error) => {
43+
let _ = tx.send(Command::AlertWarn(format!(
44+
"Failed to read directory {:?}: {error}",
45+
directory.path
46+
)));
47+
let _ = tx.send(Command::DirectoryListingComplete { generation });
48+
return;
49+
}
50+
};
51+
52+
let mut batch: Vec<PathInfo> = Vec::new();
53+
let mut last_flush = Instant::now();
54+
let mut error_count: usize = 0;
55+
56+
for entry in entries {
57+
// A newer load has superseded this one: stop without sending a
58+
// completion (the newer load owns the listing now).
59+
if cancel.is_cancelled() {
60+
return;
61+
}
62+
let path = match entry {
63+
Ok(entry) => entry.path(),
64+
Err(error) => {
65+
warn!("Could not read an entry in {:?}: {error}", directory.path);
66+
error_count += 1;
67+
continue;
68+
}
69+
};
70+
match PathInfo::try_from(&path) {
71+
Ok(info) => batch.push(info),
72+
Err(error) => {
73+
warn!("Could not read metadata for {path:?}: {error}");
74+
error_count += 1;
75+
}
76+
}
77+
if batch.len() >= CD_BATCH_SIZE || last_flush.elapsed() >= CD_FLUSH_INTERVAL {
78+
if !flush_listing(&tx, &mut batch, generation) {
79+
return; // channel closed
80+
}
81+
last_flush = Instant::now();
82+
}
83+
}
84+
85+
if !flush_listing(&tx, &mut batch, generation) {
86+
return;
87+
}
88+
if error_count > 0 {
89+
let _ = tx.send(Command::AlertWarn(format!(
90+
"{error_count} entries in {:?} could not be read",
91+
directory.path
92+
)));
93+
}
94+
let _ = tx.send(Command::DirectoryListingComplete { generation });
95+
});
96+
}
3697

37-
Ok((children.into_iter().flatten().collect(), error_count))
98+
/// Sends the accumulated batch (if any) as a single `Command::DirectoryListing`.
99+
/// Returns `false` if the channel is closed, signalling the caller to stop.
100+
fn flush_listing(tx: &Sender<Command>, batch: &mut Vec<PathInfo>, generation: u64) -> bool {
101+
if batch.is_empty() {
102+
return true;
103+
}
104+
tx.send(Command::DirectoryListing {
105+
items: std::mem::take(batch),
106+
generation,
107+
})
108+
.is_ok()
38109
}
39110

40111
pub(super) fn open_in(path: &PathInfo, template: &str, command_tx: Sender<Command>) -> Result<()> {

src/views/status.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@ use crate::{command::result::CommandResult, file_system::path_info::PathInfo};
88
pub(super) struct StatusView {
99
directory: Option<PathInfo>,
1010
directory_len: usize,
11+
/// Generation of the directory load whose entries `directory_len` counts.
12+
load_generation: u64,
1113
selected: Option<PathInfo>,
1214
}
1315

1416
impl StatusView {
15-
fn set_directory(&mut self, directory: PathInfo, children: &[PathInfo]) -> CommandResult {
17+
fn begin_directory(&mut self, directory: PathInfo, generation: u64) -> CommandResult {
1618
self.directory = Some(directory);
17-
self.directory_len = children.len();
19+
self.directory_len = 0;
20+
self.load_generation = generation;
21+
CommandResult::Handled
22+
}
23+
24+
fn count_listing(&mut self, items: &[PathInfo], generation: u64) -> CommandResult {
25+
if generation == self.load_generation {
26+
self.directory_len += items.len();
27+
}
1828
CommandResult::Handled
1929
}
2030

src/views/status/handler.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ impl CommandHandler for StatusView {
66
match command {
77
Command::NavigatedDirectory {
88
directory,
9-
children,
9+
generation,
1010
}
1111
| Command::RefreshedDirectory {
1212
directory,
13-
children,
14-
} => self.set_directory(directory.clone(), children),
13+
generation,
14+
} => self.begin_directory(directory.clone(), *generation),
15+
Command::DirectoryListing { items, generation } => {
16+
self.count_listing(items, *generation)
17+
}
1518
Command::SelectionChanged(selected) => self.set_selected(selected.clone()),
1619
_ => CommandResult::NotHandled,
1720
}

src/views/table.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use ratatui::{layout::Rect, widgets::TableState};
1818

1919
use self::{
2020
columns::Columns, content::DirectoryContent, double_click::DoubleClick, marks::Marks,
21-
row_map::LineItemMap,
21+
navigation::Reselect, row_map::LineItemMap,
2222
};
2323
use super::ScrollbarView;
2424
use crate::{app::clipboard::ClipboardEntry, file_system::path_info::PathInfo};
@@ -36,6 +36,16 @@ pub(super) struct TableView {
3636
/// ratatui's auto-scroll) so only the visible window's rows are built.
3737
first_visible_item: usize,
3838

39+
/// Generation of the directory load currently being streamed in. Batches
40+
/// stamped with a different generation are stale and ignored.
41+
load_generation: u64,
42+
/// Selection state captured at the start of a streamed load, applied once it
43+
/// completes (see `begin_directory`/`finish_directory`).
44+
loading_reselect: Reselect,
45+
loading_prev_directory: Option<PathInfo>,
46+
loading_prev_selected: Option<PathInfo>,
47+
loading_prev_selected_index: Option<usize>,
48+
3949
columns: Columns,
4050
double_click: DoubleClick,
4151
mapper: LineItemMap,

0 commit comments

Comments
 (0)