Skip to main content

max / audiofiles

Fix file list scroll-follow, collection breadcrumb, and collection export Arrow key navigation now scrolls the table to keep the focused row visible. Collection view shows the collection name in the breadcrumb (instead of the stale VFS path) and standard navigation exits the view cleanly. Export button works in collection view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 21:55 UTC
Commit: d9130c848675ff307d58cbffca6bec314d265255
Parent: e0db01b
6 files changed, +86 insertions, -31 deletions
@@ -268,6 +268,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
268 268 if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) {
269 269 if shift {
270 270 state.selection.extend_down(state.visible_len());
271 + state.scroll_to_row = Some(state.selection.focus);
271 272 } else {
272 273 state.select_next();
273 274 state.autoplay_current();
@@ -276,6 +277,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
276 277 if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) {
277 278 if shift {
278 279 state.selection.extend_up();
280 + state.scroll_to_row = Some(state.selection.focus);
279 281 } else {
280 282 state.select_prev();
281 283 state.autoplay_current();
@@ -601,6 +601,23 @@ impl BrowserState {
601 601 }
602 602 }
603 603 all_items
604 + } else if self.active_collection.is_some() {
605 + // Export collection contents
606 + self.contents.iter().filter_map(|node| {
607 + let hash = node.node.sample_hash.as_ref()?;
608 + let ext = self.backend.sample_extension(hash).unwrap_or_default();
609 + Some(audiofiles_core::export::ExportItem {
610 + hash: hash.clone(),
611 + ext,
612 + relative_path: std::path::PathBuf::from(&node.node.name),
613 + name: node.node.name.clone(),
614 + bpm: node.bpm,
615 + musical_key: node.musical_key.clone(),
616 + classification: node.classification.clone(),
617 + duration: node.duration,
618 + tags: node.tags.clone(),
619 + })
620 + }).collect()
604 621 } else {
605 622 // Export entire current VFS subtree
606 623 self.backend.collect_export_items(vfs_id, self.current_dir)
@@ -172,6 +172,8 @@ pub struct BrowserState {
172 172 pub last_analysis_config: Option<AnalysisConfig>,
173 173 /// Set by "/" keyboard shortcut to focus the search bar on the next frame.
174 174 pub focus_search: bool,
175 + /// Set by keyboard navigation to scroll the file list to the focused row.
176 + pub scroll_to_row: Option<usize>,
175 177
176 178 // Theme
177 179 pub current_theme_id: String,
@@ -346,6 +348,7 @@ impl BrowserState {
346 348 last_analysis_hashes: Vec::new(),
347 349 last_analysis_config: None,
348 350 focus_search: false,
351 + scroll_to_row: None,
349 352 current_theme_id: theme_id,
350 353 collections: collections_list,
351 354 active_collection: None,
@@ -166,6 +166,7 @@ impl BrowserState {
166 166 if len > 0 && self.selection.focus < len - 1 {
167 167 let next = self.selection.focus + 1;
168 168 self.selection.set_single(next);
169 + self.scroll_to_row = Some(next);
169 170 self.refresh_selected_tags();
170 171 self.refresh_selected_detail();
171 172 }
@@ -176,6 +177,7 @@ impl BrowserState {
176 177 if self.selection.focus > 0 {
177 178 let prev = self.selection.focus - 1;
178 179 self.selection.set_single(prev);
180 + self.scroll_to_row = Some(prev);
179 181 self.refresh_selected_tags();
180 182 self.refresh_selected_detail();
181 183 }
@@ -204,8 +206,13 @@ impl BrowserState {
204 206 }
205 207
206 208 /// Navigate to the parent directory, or do nothing if already at root.
209 + /// If a collection is active, exits collection view first.
207 210 pub fn go_up(&mut self) {
208 211 self.similarity_search_hash = None;
212 + if self.active_collection.is_some() {
213 + self.deactivate_collection();
214 + return;
215 + }
209 216 if let Some(current) = self.current_dir {
210 217 if let Ok(node) = self.backend.get_node(current) {
211 218 self.current_dir = node.parent_id;
@@ -71,6 +71,11 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) {
71 71 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
72 72 .column(Column::remainder().at_least(120.0)); // Name (includes icon)
73 73
74 + // Scroll to focused row when keyboard navigation requests it.
75 + if let Some(row) = state.scroll_to_row.take() {
76 + table = table.scroll_to_row(row, None);
77 + }
78 +
74 79 if col_cfg.show_duration {
75 80 table = table.column(Column::exact(60.0));
76 81 }
@@ -172,39 +172,60 @@ fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) {
172 172
173 173 ui.separator();
174 174
175 - // Root link
176 - if ui
177 - .selectable_label(state.current_dir.is_none(), "/")
178 - .on_hover_text("Go to root")
179 - .clicked()
180 - && state.current_dir.is_some()
181 - {
182 - state.current_dir = None;
183 - state.breadcrumb.clear();
184 - state.selection.clear();
185 - state.refresh_contents();
186 - }
187 -
188 - // Breadcrumb path segments — iterate by reference, defer mutation
189 - let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None;
190 - let breadcrumb_len = state.breadcrumb.len();
191 - for (i, crumb) in state.breadcrumb.iter().enumerate() {
175 + // Collection view: show collection name instead of VFS path
176 + if let Some(active_id) = state.active_collection {
177 + let coll_name = state.collections.iter()
178 + .find(|c| c.id == active_id)
179 + .map(|c| c.name.clone())
180 + .unwrap_or_else(|| "Collection".to_string());
181 + if ui
182 + .selectable_label(false, "/")
183 + .on_hover_text("Return to browsing")
184 + .clicked()
185 + {
186 + state.deactivate_collection();
187 + }
192 188 ui.label("/");
193 - let is_last = i == breadcrumb_len - 1;
194 - let crumb_hover = if is_last {
195 - format!("Current directory: {}", crumb.name)
196 - } else {
197 - format!("Navigate to {}", crumb.name)
198 - };
199 - if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last {
200 - nav_to = Some((crumb.id, i + 1));
189 + ui.label(
190 + egui::RichText::new(&coll_name)
191 + .strong()
192 + .color(theme::accent_blue()),
193 + );
194 + } else {
195 + // Root link
196 + if ui
197 + .selectable_label(state.current_dir.is_none(), "/")
198 + .on_hover_text("Go to root")
199 + .clicked()
200 + && state.current_dir.is_some()
201 + {
202 + state.current_dir = None;
203 + state.breadcrumb.clear();
204 + state.selection.clear();
205 + state.refresh_contents();
206 + }
207 +
208 + // Breadcrumb path segments — iterate by reference, defer mutation
209 + let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None;
210 + let breadcrumb_len = state.breadcrumb.len();
211 + for (i, crumb) in state.breadcrumb.iter().enumerate() {
212 + ui.label("/");
213 + let is_last = i == breadcrumb_len - 1;
214 + let crumb_hover = if is_last {
215 + format!("Current directory: {}", crumb.name)
216 + } else {
217 + format!("Navigate to {}", crumb.name)
218 + };
219 + if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last {
220 + nav_to = Some((crumb.id, i + 1));
221 + }
222 + }
223 + if let Some((dir_id, truncate_at)) = nav_to {
224 + state.current_dir = Some(dir_id);
225 + state.breadcrumb.truncate(truncate_at);
226 + state.selection.clear();
227 + state.refresh_contents();
201 228 }
202 - }
203 - if let Some((dir_id, truncate_at)) = nav_to {
204 - state.current_dir = Some(dir_id);
205 - state.breadcrumb.truncate(truncate_at);
206 - state.selection.clear();
207 - state.refresh_contents();
208 229 }
209 230
210 231 // Import + Export buttons + Sync + theme selector (right-aligned)