perf(tui): flatten a browsed share once and draw one page of it

Moving through a browsed share felt laggy for the same reason the
results did: the popup re-flattened the whole expanded tree on every
frame, cloning every row's name and path, then built a table row for
each, and every keypress flattened it twice more. A heavy sharer's
tree runs to tens of thousands of rows once a few folders are open.

The flattened rows now live on the browse state and are rebuilt only
when a folder opens or closes, and the popup draws the visible page
alone. In the harness a 30,000-row tree went from 11ms to 0.35ms a
frame and a keypress from 2.7ms to under a microsecond.

The downloads pane had the same shape (every transfer became a row
each frame, and a select-all can queue ten thousand), so it takes the
same page window, which now lives beside page_of for every pane to
share.
This commit is contained in:
Michel de Graaf
2026-09-08 11:24:36 +02:00
parent 1fcde0a78b
commit 0931bb01b4
6 changed files with 129 additions and 68 deletions
+38 -3
View File
@@ -267,7 +267,10 @@ pub struct BrowseState {
pub username: String,
pub status: BrowseStatus,
pub tree: Vec<BrowseNode>,
pub expanded: HashSet<String>,
expanded: HashSet<String>,
/// The tree flattened for the current expansion, kept between frames:
/// a heavy sharer's tree runs to tens of thousands of rows.
rows: Vec<BrowseRow>,
pub selected_row: usize,
pub file_count: usize,
pub folder_count: usize,
@@ -282,6 +285,7 @@ impl BrowseState {
status: BrowseStatus::Loading,
tree: Vec::new(),
expanded: HashSet::new(),
rows: Vec::new(),
selected_row: 0,
file_count: 0,
folder_count: 0,
@@ -310,12 +314,30 @@ impl BrowseState {
self.file_count = built.file_count;
self.folder_count = built.folder_count;
self.selected_row = 0;
self.refresh();
}
/// The flattened visible rows for the current expansion state.
#[must_use]
pub fn rows(&self) -> Vec<BrowseRow> {
flatten_browse(&self.tree, &self.expanded)
pub fn rows(&self) -> &[BrowseRow] {
&self.rows
}
/// Open or close the folder at `path`.
pub fn set_expanded(&mut self, path: &str, expanded: bool) {
if expanded {
self.expanded.insert(path.to_string());
} else {
self.expanded.remove(path);
}
self.refresh();
}
/// Re-flatten after the tree or the expansion changed under `rows`.
pub fn refresh(&mut self) {
self.rows = flatten_browse(&self.tree, &self.expanded);
self.selected_row =
self.selected_row.min(self.rows.len().saturating_sub(1));
}
}
@@ -579,6 +601,19 @@ mod tests {
assert_eq!(tabs.retry_active(), None);
}
#[test]
fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]);
assert_eq!(state.rows().len(), 2, "music open, album closed");
state.set_expanded("music\\album", true);
assert_eq!(state.rows().len(), 4);
state.selected_row = 3;
state.set_expanded("music\\album", false);
assert_eq!(state.rows().len(), 2);
assert_eq!(state.selected_row, 1, "pulled back onto the last row");
}
#[test]
fn browse_state_load_auto_expands_top_level() {
let mut state = BrowseState::loading("bob".to_string());