Skip to main content

max / audiofiles

Flip the import flow to the described screens Importing serves from `quasi::importing` and `ui/import_screens/` is gone, all four files of it, along with the preflight's half of `ui/overlays.rs`. Nine drawing functions chosen by a `match` on `ImportMode` in two places become one call to one address. The described flow reads the mode itself, so the app asks `/import` and the route decides what the user is looking at -- which is what a stage is, and the shipped side was spelling it twice. Two things kept their own addresses on purpose. The sweep is not a stage of this flow, as `importing`'s header argues, so `Cleaning` still goes to `/cleanup`. And the preflight is the one part that is a modal rather than a stage: nothing has begun, so there is no pane to take over. The export flow stays shipped. Its three modes still reach `export_screens`, and its flip waits on the working-directory vocabulary gap (`ec92f9cb`). `ui/dialog.rs` is untouched, per the task: it is not a screen. It is the mechanism a host uses to ask the operating system a question, which is what the four import doors reach through and what `quasi:vocabulary:host-save-location` is filed about.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 21:36 UTC
Signed with PGP, not checked
Commit: 5ef5bdbe8089bc8e6f56a9dfe8373cbd7db777bf
Parent: 89e9d03
10 files changed, +91 insertions, -1518 deletions
@@ -4,8 +4,8 @@
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 - export_screens, file_list, footer, import_screens, instrument_panel, layout_strip, overlays,
8 - sidebar, theme, toolbar,
7 + export_screens, file_list, footer, instrument_panel, layout_strip, overlays, sidebar, theme,
8 + toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -49,53 +49,28 @@
49 49 handle_keyboard(ctx, state);
50 50 draw_normal_browser(ui, state, sync_manager);
51 51 }
52 - ImportMode::ConfigureImport { .. } => {
53 - import_screens::draw_configure_import(ui, state);
54 - }
55 - ImportMode::Importing { .. }
52 + // Every import-owned stage, from one address. The described flow reads
53 + // the mode itself, so the app asks `/import` and the route decides what
54 + // the user is looking at.
55 + ImportMode::ConfigureImport { .. }
56 + | ImportMode::Importing { .. }
56 57 | ImportMode::Analyzing { .. }
57 - | ImportMode::Exporting { .. }
58 - | ImportMode::Cleaning { .. } => {
59 - match &state.import_wf.import_mode {
60 - ImportMode::Importing { .. } => {
61 - import_screens::draw_import_progress(ui, state);
62 - }
63 - ImportMode::Analyzing { .. } => {
64 - import_screens::draw_analysis_progress(ui, state);
65 - }
66 - ImportMode::Exporting { .. } => {
67 - export_screens::draw_export_progress(ui, state);
68 - }
69 - ImportMode::Cleaning { .. } => {
70 - import_screens::draw_cleanup_progress(ui, state);
71 - }
72 - // poll_workers may have transitioned the mode
73 - ImportMode::TagFolders { .. } => {
74 - import_screens::draw_tag_folders(ui, state);
75 - }
76 - ImportMode::ConfigureAnalysis { .. } => {
77 - import_screens::draw_configure_analysis(ui, state);
78 - }
79 - ImportMode::ReviewSuggestions { .. } => {
80 - import_screens::draw_review_suggestions(ui, state);
81 - }
82 - ImportMode::ExportComplete { .. } => {
83 - export_screens::draw_export_complete(ui, state);
84 - }
85 - ImportMode::ReviewErrors => {
86 - import_screens::draw_review_errors(ui, state);
87 - }
88 - _ => {}
89 - }
58 + | ImportMode::TagFolders { .. }
59 + | ImportMode::ConfigureAnalysis { .. }
60 + | ImportMode::ReviewSuggestions { .. }
61 + | ImportMode::ReviewErrors
62 + | ImportMode::OperationCancelled { .. } => {
63 + crate::quasi::panel::draw_import(ui, state);
90 64 }
91 - ImportMode::TagFolders { .. } => {
92 - import_screens::draw_tag_folders(ui, state);
65 + // The sweep is not a stage of the import flow, which is why it has an
66 + // address of its own. See `importing`'s header.
67 + ImportMode::Cleaning { .. } => {
68 + crate::quasi::panel::draw_sweep(ui, state);
93 69 }
94 - ImportMode::ConfigureAnalysis { .. } => {
95 - import_screens::draw_configure_analysis(ui, state);
96 - }
97 - ImportMode::ReviewSuggestions { .. } => {
98 - import_screens::draw_review_suggestions(ui, state);
70 + // The export flow is still shipped: its flip waits on the working-directory
71 + // vocabulary gap (`ec92f9cb`).
72 + ImportMode::Exporting { .. } => {
73 + export_screens::draw_export_progress(ui, state);
99 74 }
100 75 ImportMode::ConfigureExport { .. } => {
101 76 export_screens::draw_configure_export(ui, state);
@@ -103,15 +78,9 @@
103 78 ImportMode::ExportComplete { .. } => {
104 79 export_screens::draw_export_complete(ui, state);
105 80 }
106 - ImportMode::ReviewErrors => {
107 - import_screens::draw_review_errors(ui, state);
108 - }
109 81 ImportMode::ReviewLibrary { .. } => {
110 82 crate::quasi::panel::draw_queue(ui, state);
111 83 }
112 - ImportMode::OperationCancelled { .. } => {
113 - import_screens::draw_operation_cancelled(ui, state);
114 - }
115 84 }
116 85
117 86 // Scrim behind genuine modals (not the floating tool windows). Painted once
@@ -172,7 +141,7 @@
172 141 crate::quasi::panel::draw_integrity(ctx, state);
173 142 }
174 143 if state.import_wf.pending_import_preflight.is_some() {
175 - overlays::draw_import_preflight(ctx, state);
144 + crate::quasi::panel::draw_preflight(ctx, state);
176 145 }
177 146
178 147 // Settings window
@@ -226,38 +195,6 @@
226 195 crate::quasi::panel::draw_export(ctx, state);
227 196 }
228 197
229 - // The described import flow, beside whichever of the shipped wizard screens
230 - // is showing, and on the same terms as the export one: the shipped side
231 - // takes over the central pane rather than being a window, so the described
232 - // window opens when the flow does and closes when it ends.
233 - #[cfg(feature = "quasi")]
234 - if matches!(
235 - state.import_wf.import_mode,
236 - crate::state::ImportMode::ConfigureImport { .. }
237 - | crate::state::ImportMode::Importing { .. }
238 - | crate::state::ImportMode::TagFolders { .. }
239 - | crate::state::ImportMode::ConfigureAnalysis { .. }
240 - | crate::state::ImportMode::Analyzing { .. }
241 - | crate::state::ImportMode::ReviewSuggestions { .. }
242 - | crate::state::ImportMode::ReviewErrors
243 - | crate::state::ImportMode::OperationCancelled {
244 - kind: crate::state::CancelKind::Import | crate::state::CancelKind::Analysis,
245 - ..
246 - }
247 - ) {
248 - crate::quasi::panel::draw_import(ctx, state);
249 - }
250 -
251 - // The sweep, which shares a shipped file with the import screens and is not
252 - // one of them. See `quasi::importing`'s header.
253 - #[cfg(feature = "quasi")]
254 - if matches!(
255 - state.import_wf.import_mode,
256 - crate::state::ImportMode::Cleaning { .. }
257 - ) {
258 - crate::quasi::panel::draw_sweep(ctx, state);
259 - }
260 -
261 198 // Sync panel overlay. One call for both cases now: `None` is `Unconfigured`,
262 199 // which says syncing is unavailable and offers nothing, where the shipped
263 200 // side had a second window for it.
@@ -62,6 +62,7 @@
62 62 integrity: Option<Runtime>,
63 63 naming: Option<Runtime>,
64 64 bulk: Option<Runtime>,
65 + preflight: Option<Runtime>,
65 66 /// Whether the described main window is open.
66 67 pub show_shell: bool,
67 68 /// Whether the described file list is open.
@@ -320,14 +321,19 @@
320 321 }
321 322 }
322 323
323 - /// Draw the described import flow, and act on whatever was pressed.
324 + /// Draw the import flow, and act on whatever was pressed.
324 325 ///
325 - /// **Refreshed unconditionally**, and it is the export flow's reason twice over:
326 - /// two of its nine stages are a worker copying files and a worker analysing
327 - /// them, so the screen moves with nothing pressed, and every control on the
328 - /// configure and tagging stages writes through an intent that lands after the
329 - /// answer was built.
330 - pub fn draw_import(ctx: &egui::Context, state: &mut BrowserState) {
326 + /// One call for the whole flow. The shipped side is nine drawing functions
327 + /// chosen by a `match` on `ImportMode` in two places; the described side is one
328 + /// address whose answer depends on the stage, so the app asks once and the
329 + /// route decides what the user is looking at.
330 + ///
331 + /// Into the app's own pane rather than a window, because every stage of this was
332 + /// a full-screen mode.
333 + ///
334 + /// Refreshed unconditionally, and this is the screen that most needs it: a
335 + /// worker moves the counts with nothing pressed, and the stage changes under it.
336 + pub fn draw_import(ui: &mut egui::Ui, state: &mut BrowserState) {
331 337 let intents = RefCell::new(Vec::new());
332 338 let mut runtime = state.described.import.take();
333 339 let host = Host {
@@ -336,19 +342,9 @@
336 342 themes: themes(),
337 343 intents: &intents,
338 344 };
339 - let closed = window(
340 - ctx,
341 - "Import (described)",
342 - &mut runtime,
343 - &host,
344 - "/import",
345 - true,
346 - );
345 + inline(ui, &mut runtime, &host, "/import", true);
347 346 state.described.import = runtime;
348 - apply(ctx, state, None, intents.into_inner());
349 - if closed {
350 - state.described.import = None;
351 - }
347 + apply(ui.ctx(), state, None, intents.into_inner());
352 348 }
353 349
354 350 /// Draw the tag review queue, and act on whatever was pressed.
@@ -535,6 +531,39 @@
535 531 }
536 532 }
537 533
534 + /// Draw the import preflight, and act on whatever was pressed.
535 + ///
536 + /// The question asked before a large import starts, and the only part of the
537 + /// flow that is a modal rather than a stage: nothing has begun yet, so there is
538 + /// no pane to take over. `importing`'s header calls it the first consumer of the
539 + /// unprompted-overlay shape; the loose-files warning is the other.
540 + pub fn draw_preflight(ctx: &egui::Context, state: &mut BrowserState) {
541 + let intents = RefCell::new(Vec::new());
542 + let mut runtime = state.described.preflight.take();
543 + let host = Host {
544 + state,
545 + sync: None,
546 + themes: themes(),
547 + intents: &intents,
548 + };
549 + let closed = window(
550 + ctx,
551 + "Import folder",
552 + &mut runtime,
553 + &host,
554 + "/import/preflight",
555 + true,
556 + );
557 + state.described.preflight = runtime;
558 + apply(ctx, state, None, intents.into_inner());
559 + // The X is the same answer as Cancel: nothing has started, so there is
560 + // nothing to leave running.
561 + if closed {
562 + state.described.preflight = None;
563 + state.cancel_import_preflight();
564 + }
565 + }
566 +
538 567 /// Do what a described screen asked the app to do to itself.
539 568 ///
540 569 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -1100,3 +1100,24 @@
1100 1100 .in_a_window("Cloud Sync")
1101 1101 .assert(&described, &drawn);
1102 1102 }
1103 +
1104 + #[test]
1105 + fn the_import_preflight_serves_what_it_describes() {
1106 + let (mut state, _dir) = fixture();
1107 + state.import_wf.pending_import_preflight =
1108 + Some(crate::state::import_workflow::ImportPreflight {
1109 + source: std::path::PathBuf::from("/music/samples"),
1110 + file_count: 4_200,
1111 + total_bytes: 9_000_000_000,
1112 + });
1113 +
1114 + let described = described(&super::panel::described_screen(&state, "/import/preflight"));
1115 + let drawn = shipped(|ui| {
1116 + super::panel::draw_preflight(ui.ctx(), &mut state);
1117 + });
1118 +
1119 + described.addresses_resolve();
1120 + Parity::strict()
1121 + .in_a_window("Import folder")
1122 + .assert(&described, &drawn);
1123 + }
@@ -7,7 +7,6 @@
7 7 pub mod file_list;
8 8 pub mod file_list_menus;
9 9 pub mod footer;
10 - pub mod import_screens;
11 10 pub mod instrument_panel;
12 11 pub mod layout_strip;
13 12 pub mod overlays;
@@ -371,68 +371,6 @@
371 371
372 372 /// Draw the Quick-Import preflight: confirms the file count and size with the
373 373 /// user before any files are touched. Triggered only for large imports
374 - /// (≥ 100 files OR ≥ 1 GiB) so the small-folder path stays frictionless.
375 - pub fn draw_import_preflight(ctx: &egui::Context, state: &mut BrowserState) {
376 - let Some(preflight) = state.import_wf.pending_import_preflight.clone() else {
377 - return;
378 - };
379 - let prompt = format!(
380 - "About to import {} audio file{} (~{}) from {}",
381 - preflight.file_count,
382 - if preflight.file_count == 1 { "" } else { "s" },
383 - widgets::format_bytes(preflight.total_bytes),
384 - preflight.source.display(),
385 - );
386 -
387 - // M-9: custom-render the modal (instead of using confirm_modal) so the
388 - // "Don't ask again" checkbox can live above the action row. confirm_modal
389 - // is off-limits for this batch.
390 - let mut outcome = ConfirmOutcome::None;
391 - widgets::modal_window(ctx, "Import folder", false, None, |ui| {
392 - ui.label(&prompt);
393 - ui.add_space(theme::space::bound());
394 - ui.label(
395 - egui::RichText::new("Files stay where they are: audiofiles only indexes them.")
396 - .small()
397 - .color(theme::content_secondary()),
398 - );
399 - ui.add_space(theme::space::peer());
400 - // M-9: checkbox state is transient on BrowserState; committed only
401 - // when the user confirms.
402 - ui.checkbox(
403 - &mut state.import_wf.preflight_dont_ask,
404 - "Don't ask again for folders this size",
405 - );
406 - ui.add_space(theme::space::group());
407 - outcome = widgets::confirm_action_row(ui, "Import", true, false);
408 - });
409 -
410 - match outcome {
411 - ConfirmOutcome::Confirmed => {
412 - // M-9: persist the dismissal before starting the import. Both the
413 - // transient flag and the in-memory `import_preflight_disabled`
414 - // mirror update so the next quick_import_folder bypass-check sees
415 - // the new value without a reload.
416 - if state.import_wf.preflight_dont_ask {
417 - if let Err(e) = state
418 - .backend
419 - .set_config(crate::backend::ConfigKey::ImportPreflightDisabled, "1")
420 - {
421 - tracing::warn!("Failed to persist preflight dismissal: {e}");
422 - }
423 - state.import_wf.import_preflight_disabled = true;
424 - }
425 - state.import_wf.preflight_dont_ask = false;
426 - state.accept_import_preflight();
427 - }
428 - ConfirmOutcome::Cancelled => {
429 - state.import_wf.preflight_dont_ask = false;
430 - state.cancel_import_preflight();
431 - }
432 - ConfirmOutcome::None => {}
433 - }
434 - }
435 -
436 374 #[cfg(test)]
437 375 mod tests {
438 376 use super::*;
@@ -1,355 +1,0 @@
1 - //! Import wizard configure screens: import options and analysis settings.
2 -
3 - use super::super::{theme, widgets};
4 - use egui;
5 - use makeover_immediate;
6 - use makeover_layout;
7 -
8 - use crate::import::ImportStrategy;
9 - use crate::state::{BrowserState, ImportMode};
10 -
11 - /// Draw the import configuration screen with strategy radio buttons.
12 - pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
13 - let (source_display, file_count) = match &state.import_wf.import_mode {
14 - ImportMode::ConfigureImport {
15 - source,
16 - audio_file_count,
17 - ..
18 - } => (source.display().to_string(), *audio_file_count),
19 - _ => return,
20 - };
21 -
22 - egui::CentralPanel::default().show(ui, |ui| {
23 - // Scroll the body so the Cancel/Import row stays reachable on a short
24 - // window or when the merge combo + vault field + warnings all expand (P5).
25 - egui::ScrollArea::vertical().show(ui, |ui| {
26 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
27 - ui.heading("Import Folder");
28 - ui.add_space(theme::space::peer());
29 - // Source: path + Change button (M-5). Picking the wrong folder no
30 - // longer requires Cancel-and-restart from the toolbar.
31 - ui.horizontal(|ui| {
32 - ui.label(format!("Source: {source_display}"));
33 - if ui
34 - .small_button("Change...")
35 - .on_hover_text("Pick a different source folder")
36 - .clicked()
37 - {
38 - state.dialogs.pick_folder("Choose source folder", |s, p| {
39 - s.change_import_source(p);
40 - });
41 - }
42 - });
43 - ui.add_space(theme::space::bound());
44 -
45 - // Dry-run preview
46 - ui.label(
47 - egui::RichText::new(format!(
48 - "{file_count} audio file{} found",
49 - if file_count == 1 { "" } else { "s" },
50 - ))
51 - .strong(),
52 - );
53 - ui.label(
54 - egui::RichText::new(format!(
55 - "Supported: {}. Duplicates will be skipped automatically.",
56 - audiofiles_core::util::AUDIO_EXTENSIONS.join(", "),
57 - ))
58 - .small()
59 - .weak(),
60 - );
61 - ui.add_space(theme::space::group());
62 -
63 - // The strategy and the one input it opens are a described form, drawn
64 - // as a `group` so the spacing between the question and its follow-up is
65 - // the field style's own `group_gap` rather than a number chosen here.
66 - //
67 - // A `Radio` and not a `Select`, for the reason the kind exists: this
68 - // decides where every imported file lands and it cannot be revised
69 - // afterwards without re-importing, so the three answers have to be
70 - // readable without opening anything.
71 - const STRATEGIES: [makeover_layout::Choice<'static>; 3] = [
72 - makeover_layout::Choice::new("flat", "Flat (all files in current directory)"),
73 - makeover_layout::Choice::new("new", "New vault (preserve directory structure)"),
74 - makeover_layout::Choice::new("merge", "Merge into existing vault"),
75 - ];
76 -
77 - // The description names its answers by string and this app holds a
78 - // strategy enum, a name and an index, so the two are marshalled here
79 - // rather than by reshaping the state. Same division as the storage-style
80 - // choice in `settings_panel`: the question is described, the app's
81 - // encoding of the answer stays private.
82 - let current_vfs_id = state.current_vfs_id();
83 - let current_dir = state.nav.current_dir;
84 - let ImportMode::ConfigureImport {
85 - strategy,
86 - new_vfs_name,
87 - available_vfs,
88 - selected_merge_vfs_idx,
89 - ..
90 - } = &state.import_wf.import_mode
91 - else {
92 - return;
93 - };
94 - let mut strategy_value = String::from(match strategy {
95 - ImportStrategy::Flat { .. } => "flat",
96 - ImportStrategy::NewVfs { .. } => "new",
97 - ImportStrategy::MergeIntoVfs { .. } => "merge",
98 - });
99 - let mut vault_name = new_vfs_name.clone();
100 - let mut merge_value = selected_merge_vfs_idx.to_string();
101 - let no_vaults = available_vfs.is_empty();
102 - // Owned first, borrowed second: `Choice` holds `&str`, and the vault
103 - // names live behind the same borrow of `state` the fillings need
104 - // mutably.
105 - let vault_options: Vec<(String, String)> = available_vfs
106 - .iter()
107 - .enumerate()
108 - .map(|(i, vfs)| (i.to_string(), vfs.name.clone()))
109 - .collect();
110 - let vault_choices: Vec<makeover_layout::Choice<'_>> = vault_options
111 - .iter()
112 - .map(|(value, label)| makeover_layout::Choice::new(value, label))
113 - .collect();
114 -
115 - let mut fields = vec![makeover_layout::Field::radio(
116 - "import_strategy",
117 - "Import strategy",
118 - &STRATEGIES,
119 - )];
120 - match strategy_value.as_str() {
121 - // The error is the state the old screen left unexplained: a new
122 - // vault chosen and no name, where Import disables itself and says
123 - // why only to a pointer that hovers it.
124 - "new" => fields.push(makeover_layout::Field {
125 - required: true,
126 - placeholder: Some("Drum Kits"),
127 - error: vault_name
128 - .trim()
129 - .is_empty()
130 - .then_some("Enter a name for the new vault."),
131 - ..makeover_layout::Field::new(
132 - makeover_layout::FieldKind::Text,
133 - "new_vault_name",
134 - "Vault name",
135 - )
136 - }),
137 - "merge" => fields.push(makeover_layout::Field {
138 - error: no_vaults.then_some("No existing vaults to merge into."),
139 - ..makeover_layout::Field::select("merge_vault", "Merge into", &vault_choices)
140 - }),
141 - _ => {}
142 - }
143 -
144 - widgets::group(ui, &fields, false, |ui, field| {
145 - let filling = match field.name {
146 - "import_strategy" => makeover_immediate::Filling::Text(&mut strategy_value),
147 - "new_vault_name" => makeover_immediate::Filling::Text(&mut vault_name),
148 - _ => makeover_immediate::Filling::Text(&mut merge_value),
149 - };
150 - widgets::field(ui, field, filling, None);
151 - });
152 -
153 - // Back the other way. Unconditional rather than gated on `.changed()`:
154 - // the strategy is derived from the three answers, so re-deriving it
155 - // every frame keeps it correct without every edit site having to
156 - // remember to rebuild it — which the vault-name edit was the only site
157 - // that did.
158 - let ImportMode::ConfigureImport {
159 - strategy,
160 - new_vfs_name,
161 - available_vfs,
162 - selected_merge_vfs_idx,
163 - ..
164 - } = &mut state.import_wf.import_mode
165 - else {
166 - return;
167 - };
168 - new_vfs_name.clone_from(&vault_name);
169 - if let Ok(i) = merge_value.parse::<usize>() {
170 - *selected_merge_vfs_idx = i;
171 - }
172 - // A strategy the app cannot form yet (no current vault to import flat
173 - // into, no vault to merge with) leaves the previous one standing, and
174 - // the radio reads back from it on the next frame.
175 - let next = match strategy_value.as_str() {
176 - "flat" => current_vfs_id.map(|vfs_id| ImportStrategy::Flat {
177 - vfs_id,
178 - parent_id: current_dir,
179 - }),
180 - "new" => Some(ImportStrategy::NewVfs {
181 - vfs_name: new_vfs_name.clone(),
182 - }),
183 - _ => available_vfs
184 - .get(*selected_merge_vfs_idx)
185 - .map(|vfs| ImportStrategy::MergeIntoVfs {
186 - vfs_id: vfs.id,
187 - parent_id: None,
188 - }),
189 - };
190 - if let Some(next) = next {
191 - *strategy = next;
192 - }
193 -
194 - ui.add_space(theme::space::section());
195 -
196 - // One-way edge warning (C-1). Configure → Importing is the only
197 - // non-recoverable transition in the wizard: once files start landing
198 - // in the content store, cancelling preserves the partial work rather
199 - // than rolling it back. Make that explicit so the user reads "Import"
200 - // as a commit, not a preview.
201 - ui.label(
202 - egui::RichText::new(
203 - "Once started, you can cancel mid-import but copies already made will stay in the library."
204 - )
205 - .small()
206 - .color(theme::content_muted()),
207 - );
208 - ui.add_space(theme::space::bound());
209 -
210 - // m-11: gate Import on required fields per strategy variant. NewVfs
211 - // needs a non-empty vault name; MergeIntoVfs needs at least one vault
212 - // available (the indexed access on available_vfs would otherwise panic
213 - // if the user reached this screen with no vaults).
214 - let (can_import, disabled_reason) = match &state.import_wf.import_mode {
215 - ImportMode::ConfigureImport { strategy, new_vfs_name, available_vfs, .. } => {
216 - match strategy {
217 - ImportStrategy::Flat { .. } => (true, ""),
218 - ImportStrategy::NewVfs { .. } => {
219 - if new_vfs_name.trim().is_empty() {
220 - (false, "Enter a name for the new vault.")
221 - } else {
222 - (true, "")
223 - }
224 - }
225 - ImportStrategy::MergeIntoVfs { .. } => {
226 - if available_vfs.is_empty() {
227 - (false, "No existing vaults to merge into.")
228 - } else {
229 - (true, "")
230 - }
231 - }
232 - }
233 - }
234 - _ => (false, ""),
235 - };
236 -
237 - ui.horizontal(|ui| {
238 - if ui.button("Cancel").clicked() {
239 - state.import_wf.import_mode = ImportMode::None;
240 - }
241 - let import_btn = ui.add_enabled(can_import, egui::Button::new("Import"));
242 - let import_btn = if !can_import && !disabled_reason.is_empty() {
243 - import_btn.on_disabled_hover_text(disabled_reason)
244 - } else {
245 - import_btn
246 - };
247 - if import_btn.clicked()
248 - && let ImportMode::ConfigureImport {
249 - ref source,
250 - strategy: ref strat,
251 - ref new_vfs_name,
252 - ref available_vfs,
253 - selected_merge_vfs_idx,
254 - ..
255 - } = state.import_wf.import_mode
256 - {
257 - let source = source.clone();
258 - let strategy = match strat {
259 - ImportStrategy::Flat { vfs_id, parent_id } => Some(ImportStrategy::Flat {
260 - vfs_id: *vfs_id,
261 - parent_id: *parent_id,
262 - }),
263 - ImportStrategy::NewVfs { .. } => Some(ImportStrategy::NewVfs {
264 - vfs_name: new_vfs_name.clone(),
265 - }),
266 - ImportStrategy::MergeIntoVfs { .. } => available_vfs
267 - .get(selected_merge_vfs_idx)
268 - .map(|vfs| ImportStrategy::MergeIntoVfs {
269 - vfs_id: vfs.id,
270 - parent_id: None,
271 - }),
272 - };
273 - if let Some(strategy) = strategy {
274 - state.start_folder_import(source, strategy);
275 - }
276 - }
277 - });
278 - });
279 - });
280 - }
281 -
282 - /// Draw the analysis configuration screen.
283 - pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
284 - let (sample_count, mut config) = match &state.import_wf.import_mode {
285 - ImportMode::ConfigureAnalysis {
286 - sample_hashes,
287 - config,
288 - } => (sample_hashes.len(), config.clone()),
289 - _ => return,
290 - };
291 -
292 - egui::CentralPanel::default().show(ui, |ui| {
293 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
294 - ui.heading("Configure Analysis");
295 - ui.add_space(theme::space::peer());
296 - ui.label(format!("{sample_count} samples to analyze"));
297 - ui.add_space(theme::space::group());
298 -
299 - ui.checkbox(&mut config.loudness, "Loudness (Peak, RMS, LUFS)");
300 - ui.checkbox(&mut config.bpm, "BPM Detection");
301 - ui.checkbox(&mut config.key, "Key Detection");
302 - ui.checkbox(&mut config.spectral, "Spectral Features");
303 - ui.checkbox(&mut config.loop_detect, "Loop Detection");
304 - ui.checkbox(&mut config.auto_suggest_tags, "Auto-suggest Tags");
305 - ui.checkbox(&mut config.fingerprint, "Fingerprint (duplicate detection)");
306 -
307 - // Top level, not nested: smart skip used to be gated on the auto-classify
308 - // toggle because it read the sample's class. It now decides from duration
309 - // and spectral flatness, so it stands on its own.
310 - ui.checkbox(
311 - &mut config.smart_skip,
312 - "Smart skip (skip BPM/key where they cannot apply)",
313 - );
314 -
315 - ui.add_space(theme::space::section());
316 -
317 - ui.horizontal(|ui| {
318 - // Back (C-1): return to the TagFolders screen with previously
319 - // entered tag inputs restored. add_tag is INSERT OR IGNORE, so any
320 - // tags applied on the first pass don't double up if the user
321 - // commits again. Disabled when nothing's stashed (e.g. reached
322 - // here outside the folder-import flow).
323 - let can_back = state.import_wf.last_folder_tags.is_some();
324 - if ui
325 - .add_enabled(can_back, egui::Button::new("Back"))
326 - .on_hover_text("Return to the folder tagging step")
327 - .clicked()
328 - {
329 - state.back_to_tag_folders();
330 - return;
331 - }
332 - if ui.button("Run Analysis").clicked() {
333 - let hashes = match &state.import_wf.import_mode {
334 - ImportMode::ConfigureAnalysis { sample_hashes, .. } => sample_hashes.clone(),
335 - _ => Vec::new(),
336 - };
337 - state.run_analysis(hashes, config.clone());
338 - return;
339 - }
340 -
341 - if ui.button("Skip analysis").clicked() {
342 - state.import_wf.import_mode = ImportMode::None;
343 - state.status = "Imported. Run analysis from the sidebar when ready.".to_string();
344 - }
345 - });
346 - });
347 -
348 - if let ImportMode::ConfigureAnalysis {
349 - config: ref mut cfg,
350 - ..
351 - } = state.import_wf.import_mode
352 - {
353 - *cfg = config;
354 - }
355 - }
@@ -1,19 +1,0 @@
1 - //! Import/analysis workflow modal screens: configure import, progress, tagging, analysis config,
2 - //! analysis progress, tag suggestion review, and error summary.
3 -
4 - mod configure;
5 - mod progress;
6 - mod summary;
7 - mod tagging;
8 -
9 - /// Shared wizard step labels for the import flow. The step rail renders on every
10 - /// screen of the flow (including the progress screens) so the "where am I / how
11 - /// much is left" cue never vanishes mid-flow (P5).
12 - pub(super) const WIZARD_STEPS: &[&str] = &["Configure", "Tag folders", "Analyze", "Review"];
13 -
14 - pub use configure::{draw_configure_analysis, draw_configure_import};
15 - pub use progress::{
16 - draw_analysis_progress, draw_cleanup_progress, draw_import_progress, draw_operation_cancelled,
17 - };
18 - pub use summary::draw_review_errors;
19 - pub use tagging::{draw_review_suggestions, draw_tag_folders};
@@ -1,389 +1,0 @@
1 - //! Import wizard progress screens: import, cleanup, and analysis progress, plus
2 - //! the cancelled-operation screen.
3 -
4 - use egui;
5 -
6 - use crate::state::{BrowserState, CancelKind, ImportMode};
7 -
8 - use super::super::{theme, widgets};
9 -
10 - /// Render the accumulated import + analysis error log. Default-expanded so the
11 - /// user sees actionable errors as they accumulate (M-1); a "Hide"/"Show" toggle
12 - /// at the top-right of the section dismisses noise without losing the count.
13 - fn draw_error_log(
14 - ui: &mut egui::Ui,
15 - expanded: &mut bool,
16 - import_errors: &[crate::state::ImportFileError],
17 - analysis_errors: &[crate::state::AnalysisFileError],
18 - ) {
19 - let err_count = import_errors.len() + analysis_errors.len();
20 - if err_count == 0 {
21 - return;
22 - }
23 - ui.add_space(theme::space::bound());
24 - ui.horizontal(|ui| {
25 - ui.label(
26 - egui::RichText::new(format!(
27 - "{err_count} error{}",
28 - if err_count == 1 { "" } else { "s" },
29 - ))
30 - .color(theme::danger()),
31 - );
32 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
33 - let toggle_label = if *expanded { "Hide" } else { "Show" };
34 - if ui
35 - .small_button(toggle_label)
36 - .on_hover_text("Toggle the error list")
37 - .clicked()
38 - {
39 - *expanded = !*expanded;
40 - }
41 - });
42 - });
43 -
44 - if *expanded {
45 - egui::ScrollArea::vertical()
46 - .max_height(120.0)
47 - .show(ui, |ui| {
48 - for err in import_errors {
49 - ui.label(
50 - egui::RichText::new(format!("{}: {}", err.path, err.error))
51 - .small()
52 - .color(theme::danger()),
53 - );
54 - }
55 - for err in analysis_errors {
56 - ui.label(
57 - egui::RichText::new(format!("{}: {}", err.name, err.error))
58 - .small()
59 - .color(theme::danger()),
60 - );
61 - }
62 - });
63 - }
64 - }
65 -
66 - /// Render the rate + ETA readout below a progress bar (M-11). Reads from the
67 - /// rolling sample buffer on `state.import_wf.operation_progress`; silently suppresses
68 - /// itself until the buffer has enough data to predict.
69 - fn draw_rate_and_eta(
70 - ui: &mut egui::Ui,
71 - state: &mut BrowserState,
72 - completed: usize,
73 - total: usize,
74 - noun_per_sec: &str,
75 - ) {
76 - if let Some(progress) = state.import_wf.operation_progress.as_mut() {
77 - progress.record(completed);
78 - let parts: Vec<String> = [
79 - progress.rate().map(|r| format!("{r:.1} {noun_per_sec}")),
80 - progress.eta(completed, total),
81 - ]
82 - .into_iter()
83 - .flatten()
84 - .collect();
85 - if !parts.is_empty() {
86 - ui.label(
87 - egui::RichText::new(parts.join(" \u{00B7} "))
88 - .small()
89 - .color(theme::content_muted()),
90 - );
91 - }
92 - }
93 - }
94 -
95 - /// Draw the folder import progress screen.
96 - pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
97 - let ctx = ui.ctx().clone();
98 - let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) =
99 - match &state.import_wf.import_mode {
100 - ImportMode::Importing {
101 - total,
102 - completed,
103 - current_name,
104 - walking,
105 - walking_count,
106 - total_bytes,
107 - loose_files,
108 - } => (
109 - *total,
110 - *completed,
111 - current_name.clone(),
112 - *walking,
113 - *walking_count,
114 - *total_bytes,
115 - *loose_files,
116 - ),
117 - _ => return,
118 - };
119 -
120 - egui::CentralPanel::default().show(ui, |ui| {
121 - // Keep the step rail visible during the import work (P5), current step
122 - // is still Configure, since import is the work that step kicks off.
123 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
124 - ui.heading("Importing Folder...");
125 - ui.add_space(theme::space::group());
126 -
127 - if walking {
128 - // m-12: running file count from throttled ImportWalkProgress
129 - // events. Holds at "Scanning for audio files..." until the first
130 - // event arrives so very fast walks don't flash a zero.
131 - ui.horizontal(|ui| {
132 - ui.spinner();
133 - if walking_count > 0 {
134 - let label = if total_bytes > 0 {
135 - format!(
136 - "Scanning for audio files... {walking_count} found ({})",
137 - widgets::format_bytes(total_bytes),
138 - )
139 - } else {
140 - format!("Scanning for audio files... {walking_count} found")
141 - };
142 - ui.label(label);
143 - } else {
144 - ui.label("Scanning for audio files...");
145 - }
146 - });
147 - } else {
148 - // Storage estimate
149 - if total_bytes > 0 {
150 - let size_label = widgets::format_bytes(total_bytes);
151 - let storage_text = if loose_files {
152 - format!("{total} files, {size_label} total (referenced in place, no copies)")
153 - } else {
154 - format!("{total} files, ~{size_label} will be duplicated into vault")
155 - };
156 - ui.label(
157 - egui::RichText::new(storage_text)
158 - .small()
159 - .color(if loose_files {
160 - theme::warning()
161 - } else {
162 - theme::content_secondary()
163 - }),
164 - );
165 - ui.add_space(theme::space::bound());
166 - }
167 -
168 - let progress = if total > 0 {
169 - completed as f32 / total as f32
170 - } else {
171 - 0.0
172 - };
173 - let pct = (progress * 100.0) as u32;
174 - ui.add(
175 - egui::ProgressBar::new(progress)
176 - .text(format!("{pct}% \u{2014} {completed}/{total} files")),
177 - );
178 - // Rate + ETA (M-11).
179 - draw_rate_and_eta(ui, state, completed, total, "files/sec");
180 -
181 - ui.add_space(theme::space::peer());
182 - if !current_name.is_empty() {
183 - ui.label(format!("Importing: {current_name}"));
184 - }
185 - }
186 -
187 - // Error log: default-expanded so accumulating errors don't pile up
188 - // behind a click (M-1). Hide toggle at the top-right.
189 - draw_error_log(
190 - ui,
191 - &mut state.import_wf.import_errors_expanded,
192 - &state.import_wf.import_file_errors,
193 - &state.import_wf.analysis_errors,
194 - );
195 - let err_count =
196 - state.import_wf.import_file_errors.len() + state.import_wf.analysis_errors.len();
197 -
198 - ui.add_space(theme::space::section());
199 - ui.horizontal(|ui| {
200 - // Cancel during the walking phase is disabled with an explanatory
201 - // tooltip (M-2): cancel_import's interruption semantics for the
202 - // walker are not guaranteed, and the walk usually completes in
203 - // seconds anyway. Once walking finishes, Cancel becomes available.
204 - if walking {
205 - let _ = ui
206 - .add_enabled(false, egui::Button::new("Cancel"))
207 - .on_disabled_hover_text("Scanning. Cancel available once the scan completes.");
208 - } else if ui.button("Cancel").clicked() {
209 - state.cancel_import();
210 - }
211 - if err_count > 0
212 - && ui
213 - .button("Retry")
214 - .on_hover_text("Cancel and re-open import configuration")
215 - .clicked()
216 - {
217 - state.retry_import();
218 - }
219 - });
220 - });
221 -
222 - ctx.request_repaint();
223 - }
224 -
225 - /// Draw the cleanup (orphaned sample removal) progress screen.
226 - pub fn draw_cleanup_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
227 - let ctx = ui.ctx().clone();
228 - let (completed, total, current_name) = match &state.import_wf.import_mode {
229 - ImportMode::Cleaning {
230 - completed,
231 - total,
232 - current_name,
233 - } => (*completed, *total, current_name.clone()),
234 - _ => return,
235 - };
236 -
237 - egui::CentralPanel::default().show(ui, |ui| {
238 - ui.heading("Cleaning Up Samples...");
239 - ui.add_space(theme::space::group());
240 -
241 - if total == 0 {
242 - ui.horizontal(|ui| {
243 - ui.spinner();
244 - ui.label("Scanning for orphaned samples...");
245 - });
246 - } else {
247 - let progress = completed as f32 / total as f32;
248 - let pct = (progress * 100.0) as u32;
249 - ui.add(
250 - egui::ProgressBar::new(progress)
251 - .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
252 - );
253 -
254 - ui.add_space(theme::space::peer());
255 - if !current_name.is_empty() {
256 - ui.label(format!("Removing: {current_name}"));
257 - }
258 - }
259 -
260 - ui.add_space(theme::space::section());
261 - if ui.button("Cancel").clicked() {
262 - state.cancel_cleanup();
263 - }
264 - });
265 -
266 - ctx.request_repaint();
267 - }
268 -
269 - /// Draw the analysis progress screen.
270 - pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
271 - let ctx = ui.ctx().clone();
272 - let (completed, total, current_name) = match &state.import_wf.import_mode {
273 - ImportMode::Analyzing {
274 - completed,
275 - total,
276 - current_name,
277 - } => (*completed, *total, current_name.clone()),
278 - _ => return,
279 - };
280 -
281 - egui::CentralPanel::default().show(ui, |ui| {
282 - // Step rail stays visible on the slow Analyze screen, where orientation
283 - // matters most (P5). Analyze is step index 2.
284 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
285 - ui.heading("Analyzing Samples...");
286 - ui.add_space(theme::space::group());
287 -
288 - let progress = if total > 0 {
289 - completed as f32 / total as f32
290 - } else {
291 - 0.0
292 - };
293 - let pct = (progress * 100.0) as u32;
294 - ui.add(
295 - egui::ProgressBar::new(progress)
296 - .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
297 - );
298 - // Rate + ETA (M-11).
299 - draw_rate_and_eta(ui, state, completed, total, "samples/sec");
300 -
301 - ui.add_space(theme::space::peer());
302 - if !current_name.is_empty() {
303 - ui.label(format!("Analysing: {current_name}"));
304 - }
305 -
306 - // Error log (M-1).
307 - draw_error_log(
308 - ui,
309 - &mut state.import_wf.import_errors_expanded,
310 - &state.import_wf.import_file_errors,
311 - &state.import_wf.analysis_errors,
312 - );
313 - let err_count =
314 - state.import_wf.import_file_errors.len() + state.import_wf.analysis_errors.len();
315 -
316 - ui.add_space(theme::space::section());
317 - ui.horizontal(|ui| {
318 - if ui.button("Cancel").clicked() {
319 - state.cancel_analysis();
320 - }
321 - if err_count > 0
322 - && ui
323 - .button("Retry")
324 - .on_hover_text("Cancel and restart analysis")
325 - .clicked()
326 - {
327 - state.retry_analysis();
328 - }
329 - });
330 - });
331 -
332 - ctx.request_repaint();
333 - }
334 -
335 - /// Acknowledgement screen shown after the user cancels a long-running import,
336 - /// analysis, or export. Phase-5 C-3: cancelling shouldn't drop straight to
337 - /// `None`: the user needs to know what landed vs what was discarded.
338 - pub fn draw_operation_cancelled(ui: &mut egui::Ui, state: &mut BrowserState) {
339 - let (kind, completed, total, destination) = match &state.import_wf.import_mode {
340 - ImportMode::OperationCancelled {
341 - kind,
342 - completed,
343 - total,
344 - destination,
345 - } => (*kind, *completed, *total, destination.clone()),
346 - _ => return,
347 - };
348 -
349 - let (heading, noun, follow_up) = match kind {
350 - CancelKind::Import => (
351 - "Import cancelled",
352 - "files",
353 - "Imported files remain in the library. Re-run the import to add the rest \u{2014} duplicates will be skipped.",
354 - ),
355 - CancelKind::Analysis => (
356 - "Analysis cancelled",
357 - "samples",
358 - "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} run analysis again to complete them.",
359 - ),
360 - CancelKind::Export => (
361 - "Export cancelled",
362 - "files",
363 - "Files already written to the destination folder remain. A partial file for the in-progress item may also be present.",
364 - ),
365 - };
366 -
367 - egui::CentralPanel::default().show(ui, |ui| {
368 - ui.heading(heading);
369 - ui.add_space(theme::space::group());
370 - ui.label(
371 - egui::RichText::new(format!("Stopped at {completed} of {total} {noun}.")).strong(),
372 - );
373 - ui.add_space(theme::space::bound());
374 - ui.label(egui::RichText::new(follow_up).color(theme::content_secondary()));
375 - if let Some(dest) = destination.as_ref() {
376 - ui.add_space(theme::space::bound());
377 - ui.label(
378 - egui::RichText::new(format!("Destination: {}", dest.display()))
379 - .small()
380 - .color(theme::content_muted()),
381 - );
382 - }
383 -
384 - ui.add_space(theme::space::section());
385 - if widgets::primary_button(ui, "Done").clicked() {
386 - state.import_wf.import_mode = ImportMode::None;
387 - }
388 - });
389 - }
@@ -1,136 +1,0 @@
1 - //! Import wizard error summary screen: what failed during the run.
2 -
3 - use egui;
4 -
5 - use crate::state::{BrowserState, ConfirmAction, ImportMode};
6 -
7 - use super::super::{theme, widgets};
8 -
9 - /// Draw the post-import error review screen.
10 - pub fn draw_review_errors(ui: &mut egui::Ui, state: &mut BrowserState) {
11 - if !matches!(state.import_wf.import_mode, ImportMode::ReviewErrors) {
12 - return;
13 - }
14 -
15 - egui::CentralPanel::default().show(ui, |ui| {
16 - ui.heading("Import Summary");
17 - ui.add_space(theme::space::group());
18 -
19 - let analysis_count = state.import_wf.analysis_errors.len();
20 - let import_count = state.import_wf.import_file_errors.len();
21 -
22 - // Analysis errors: files in the store that couldn't be analyzed.
23 - // M-13: explanatory copy distinguishes this category from the
24 - // import-error category below (recoverable here, informational there).
25 - if analysis_count > 0 {
26 - ui.label(
27 - egui::RichText::new(format!(
28 - "{analysis_count} file{} failed analysis",
29 - if analysis_count == 1 { "" } else { "s" },
30 - ))
31 - .strong()
32 - .color(theme::danger()),
33 - );
34 - ui.label(
35 - egui::RichText::new(
36 - "These files are in the library but couldn't be analysed. \
37 - You can remove them, ignore them, or re-analyse later.",
38 - )
39 - .small()
40 - .color(theme::content_muted()),
41 - );
42 - ui.add_space(theme::space::bound());
43 -
44 - let mut remove_request: Option<(usize, String)> = None;
45 - egui::ScrollArea::vertical()
46 - .id_salt("analysis_errors")
47 - .max_height(200.0)
48 - .show(ui, |ui| {
49 - for (i, err) in state.import_wf.analysis_errors.iter().enumerate() {
50 - ui.horizontal(|ui| {
51 - // Row labels rendered in primary text. The section
52 - // heading carries the red emphasis (M-13); per-row
53 - // red on top reads as a wall of failure rather
54 - // than a list of files to triage.
55 - ui.label(&err.name);
56 - ui.label(
57 - egui::RichText::new(&err.error)
58 - .small()
59 - .color(theme::content_secondary()),
60 - );
61 - if widgets::danger_small_button(ui, "Remove").clicked() {
62 - remove_request = Some((i, err.name.clone()));
63 - }
64 - });
65 - }
66 - });
67 -
68 - // Route per-row Remove through the confirm dialog rather than
69 - // deleting on click (C-2). Detail line names the specific file.
70 - if let Some((idx, name)) = remove_request {
71 - state.overlay.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
72 - single_index: Some(idx),
73 - count: 1,
74 - name: Some(name),
75 - });
76 - }
77 - ui.add_space(theme::space::peer());
78 - }
79 -
80 - // Import errors: files that failed before entering the store
81 - // (informational only, nothing to remediate from this screen). M-13.
82 - if import_count > 0 {
83 - ui.label(
84 - egui::RichText::new(format!(
85 - "{import_count} file{} failed to import",
86 - if import_count == 1 { "" } else { "s" },
87 - ))
88 - .strong()
89 - .color(theme::danger()),
90 - );
91 - ui.label(
92 - egui::RichText::new(
93 - "These files weren't imported. Re-running the import \
94 - is the only way to retry \u{2014} duplicates will be skipped.",
95 - )
96 - .small()
97 - .color(theme::content_muted()),
98 - );
99 - ui.add_space(theme::space::bound());
100 -
101 - egui::ScrollArea::vertical()
102 - .id_salt("import_errors")
103 - .max_height(200.0)
104 - .show(ui, |ui| {
105 - for err in &state.import_wf.import_file_errors {
106 - ui.horizontal(|ui| {
107 - ui.label(&err.path);
108 - ui.label(
109 - egui::RichText::new(&err.error)
110 - .small()
111 - .color(theme::content_secondary()),
112 - );
113 - });
114 - }
115 - });
116 - ui.add_space(theme::space::peer());
117 - }
118 -
119 - ui.add_space(theme::space::peer());
120 - ui.horizontal(|ui| {
121 - if widgets::primary_button(ui, "Keep All").clicked() {
122 - state.dismiss_import_errors();
123 - }
124 - // Remove All Failed routes through the confirm dialog (C-2). The
125 - // detail line surfaces the count so the user can see the blast
126 - // radius before committing to permanent deletion.
127 - if analysis_count > 0 && widgets::danger_button(ui, "Remove All Failed").clicked() {
128 - state.overlay.pending_confirm = Some(ConfirmAction::RemoveFailedSamples {
129 - single_index: None,
130 - count: analysis_count,
131 - name: None,
132 - });
133 - }
134 - });
135 - });
136 - }
@@ -1,452 +1,0 @@
1 - //! Import wizard tagging screens: per-folder tag entry and review of suggested
2 - //! tags before they are applied.
3 -
4 - use std::collections::BTreeMap;
5 -
6 - use super::super::{theme, widgets};
7 - use egui;
8 -
9 - use crate::state::{BrowserState, ImportMode, ReviewSort};
10 - use audiofiles_core::tags;
11 -
12 - /// Draw the post-import folder tagging screen.
13 - pub fn draw_tag_folders(ui: &mut egui::Ui, state: &mut BrowserState) {
14 - let (entry_count, total_samples, all_empty) = match &state.import_wf.import_mode {
15 - ImportMode::TagFolders { entries, .. } => {
16 - let total: usize = entries.iter().map(|e| e.folder.samples.len()).sum();
17 - let empty = entries.iter().all(|e| e.tag_input.trim().is_empty());
18 - (entries.len(), total, empty)
19 - }
20 - _ => return,
21 - };
22 -
23 - egui::Panel::bottom("tag_folders_footer").show(ui, |ui| {
24 - ui.add_space(theme::space::bound());
25 - ui.horizontal(|ui| {
26 - if ui.button("Skip").clicked() {
27 - state.skip_folder_tags();
28 - }
29 - // m-10: gate Apply Tags when every per-folder input is empty so the
30 - // button stops doubling as a no-op Skip. Skip remains the explicit
31 - // discard path; this only disables the "commit nothing" click.
32 - if ui
33 - .add_enabled(!all_empty, egui::Button::new("Apply Tags"))
34 - .on_disabled_hover_text("Add at least one tag, or use Skip.")
35 - .clicked()
36 - {
37 - state.apply_folder_tags();
38 - }
39 - });
40 - ui.add_space(theme::space::hair());
41 - });
42 -
43 - egui::CentralPanel::default().show(ui, |ui| {
44 - // m-6: the breadcrumb is decorative per C-1, the wizard has no
45 - // cross-step navigation post-Skip, so the step-1 "Tag folders" cell
46 - // staying highlighted after Skip is cosmetic only. `wizard_steps` has
47 - // no skipped state and adding one isn't justified for a non-navigable
48 - // indicator. Leave as-is.
49 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 1);
50 - ui.heading("Tag Imported Folders");
51 - ui.add_space(theme::space::hair());
52 - // p-2: scope summary under the heading so users gauge the batch size
53 - // before scrolling.
54 - ui.label(
55 - egui::RichText::new(format!(
56 - "{entry_count} folders \u{00B7} {total_samples} samples"
57 - ))
58 - .color(theme::content_muted()),
59 - );
60 - ui.add_space(theme::space::bound());
61 - ui.label("Assign tags to imported folders. Comma-separated. Applied to all samples within each folder.");
62 - ui.add_space(theme::space::peer());
63 -
64 - // Apply-to-all input (M-9). Lets the user broadcast a tag set across
65 - // every folder in one click, the common case for structured imports
66 - // where most folders share the same root taxonomy.
67 - ui.horizontal(|ui| {
68 - ui.label("Apply to all:");
69 - widgets::text_field(ui,
70 - egui::TextEdit::singleline(&mut state.import_wf.tag_folders_apply_all_input)
71 - .hint_text("e.g. one-shots, kick")
72 - .desired_width(240.0),
73 - );
74 - let trimmed = state.import_wf.tag_folders_apply_all_input.trim().to_string();
75 - if ui
76 - .add_enabled(!trimmed.is_empty(), egui::Button::new("Apply to all"))
77 - .on_disabled_hover_text("Type at least one tag to apply.")
78 - .on_hover_text("Copy this tag string into every folder's input below")
79 - .clicked()
80 - {
81 - if let ImportMode::TagFolders { ref mut entries, .. } = state.import_wf.import_mode {
82 - for entry in entries.iter_mut() {
83 - entry.tag_input.clone_from(&trimmed);
84 - }
85 - }
86 - state.import_wf.tag_folders_apply_all_input.clear();
87 - }
88 - });
89 - ui.add_space(theme::space::group());
90 -
91 - egui::ScrollArea::vertical()
92 - .auto_shrink([false, false])
93 - .show(ui, |ui| {
94 - for i in 0..entry_count {
95 - if let ImportMode::TagFolders {
96 - ref mut entries, ..
97 - } = state.import_wf.import_mode
98 - {
99 - let entry = &mut entries[i];
100 - ui.horizontal(|ui| {
101 - ui.label(egui::RichText::new(&entry.folder.name).strong());
102 - ui.label(format!("({} samples)", entry.folder.samples.len()));
103 - });
104 -
105 - ui.horizontal(|ui| {
106 - ui.label("Tags:");
107 - widgets::text_field(
108 - ui,
109 - egui::TextEdit::singleline(&mut entry.tag_input),
110 - );
111 - });
112 -
113 - if !entry.tag_input.trim().is_empty() {
114 - let invalid: Vec<&str> = entry
115 - .tag_input
116 - .split(',')
117 - .map(str::trim)
118 - .filter(|s| !s.is_empty() && tags::validate_tag(s).is_err())
119 - .collect();
120 - if !invalid.is_empty() {
121 - ui.label(
122 - egui::RichText::new(format!(
123 - "Invalid: {}",
124 - invalid.join(", ")
125 - ))
126 - .color(theme::danger())
127 - .small(),
128 - );
129 - }
130 - }
131 -
132 - ui.add_space(theme::space::peer());
133 - }
134 - }
135 - });
136 - });
137 - }
138 -
139 - /// Draw the tag review screen.
140 - pub fn draw_review_suggestions(ui: &mut egui::Ui, state: &mut BrowserState) {
141 - let ctx = ui.ctx().clone();
142 - let (item_count, total_suggestions, accepted_count, _current_idx) =
143 - match &state.import_wf.import_mode {
144 - ImportMode::ReviewSuggestions {
145 - items, current_idx, ..
146 - } => {
147 - let total: usize = items.iter().map(|i| i.suggestions.len()).sum();
148 - let accepted: usize = items
149 - .iter()
150 - .flat_map(|i| &i.suggestions)
151 - .filter(|s| s.accepted)
152 - .count();
153 - (items.len(), total, accepted, *current_idx)
154 - }
155 - _ => return,
156 - };
157 -
158 - egui::Panel::top("review_header").show(ui, |ui| {
159 - widgets::wizard_steps(ui, super::WIZARD_STEPS, 3);
160 - ui.horizontal(|ui| {
161 - ui.heading("Review Tag Suggestions");
162 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
163 - if ui.button("Reject All").clicked()
164 - && let ImportMode::ReviewSuggestions { ref mut items, .. } =
165 - state.import_wf.import_mode
166 - {
167 - for item in items.iter_mut() {
168 - for sug in &mut item.suggestions {
169 - sug.accepted = false;
170 - }
171 - }
172 - }
173 - if ui.button("Accept All").clicked()
174 - && let ImportMode::ReviewSuggestions { ref mut items, .. } =
175 - state.import_wf.import_mode
176 - {
177 - for item in items.iter_mut() {
178 - for sug in &mut item.suggestions {
179 - sug.accepted = true;
180 - }
181 - }
182 - }
183 - });
184 - });
185 - ui.label(format!(
186 - "{item_count} samples, {total_suggestions} suggestions ({accepted_count} accepted)"
187 - ));
188 -
189 - // Aggregate stats: summarise the batch so the user can eyeball whether
190 - // the analysis results look sane before committing tags. Shows the BPM
191 - // range and the top-3 detected keys.
192 - if let ImportMode::ReviewSuggestions { ref items, .. } = state.import_wf.import_mode {
193 - let mut bpm_min = f64::MAX;
194 - let mut bpm_max = f64::MIN;
195 - let mut keys: BTreeMap<String, usize> = BTreeMap::new();
196 -
197 - for item in items {
198 - if let Some(bpm) = item.result.bpm {
199 - bpm_min = bpm_min.min(bpm);
200 - bpm_max = bpm_max.max(bpm);
201 - }
202 - if let Some(ref k) = item.result.musical_key {
203 - *keys.entry(k.clone()).or_default() += 1;
204 - }
205 - }
206 -
207 - // `bpm_min` starts at the `f64::MAX` "no BPM data" sentinel and is only
208 - // ever lowered via `min`; the exact compare against it is intentional.
209 - #[allow(
210 - clippy::float_cmp,
211 - reason = "f64::MAX sentinel means no BPM data; exact compare is intentional"
212 - )]
213 - if bpm_min < bpm_max {
214 - ui.label(format!("BPM: {bpm_min:.0} - {bpm_max:.0}"));
215 - } else if bpm_min != f64::MAX {
216 - ui.label(format!("BPM: {bpm_min:.0}"));
217 - }
218 - if !keys.is_empty() {
219 - // BTreeMap is sorted, so .take(3) gives the first 3 alphabetically.
220 - let top_keys: Vec<_> = keys.iter().take(3).collect();
221 - let label = top_keys
222 - .iter()
223 - .map(|(k, c)| format!("{k} ({c})"))
224 - .collect::<Vec<_>>()
225 - .join(", ");
226 - ui.label(format!("Keys: {label}"));
227 - }
228 - }
229 - });
230 -
231 - egui::Panel::bottom("review_footer").show(ui, |ui| {
232 - ui.add_space(theme::space::bound());
233 - ui.horizontal(|ui| {
234 - if ui.button("Cancel").clicked() {
235 - state.import_wf.import_mode = ImportMode::None;
236 - state.status = "Suggestions discarded".to_string();
237 - }
238 - // M-12: button names the commit count so the user sees the blast
239 - // radius before clicking, and disables itself when there's nothing
240 - // to apply (zero-accept Apply was previously a confusing no-op).
241 - let label = format!(
242 - "Apply {accepted_count} Tag{}",
243 - if accepted_count == 1 { "" } else { "s" },
244 - );
245 - let apply = ui
246 - .add_enabled(accepted_count > 0, egui::Button::new(label))
247 - .on_disabled_hover_text(
248 - "Accept at least one suggestion, or use Cancel to discard.",
249 - );
250 - if apply.clicked() {
251 - state.apply_accepted_suggestions();
252 - }
253 - });
254 - ui.add_space(theme::space::hair());
255 - });
256 -
257 - // p-4: ↑/↓ walk the side-panel sample list. Honours the current sort
258 - // order via the index map computed below. Suppressed while a text input
259 - // owns focus so tag typing isn't hijacked.
260 - let nav_delta: i32 = if ctx.memory(|m| m.focused().is_some()) {
261 - 0
262 - } else {
263 - let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp));
264 - let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown));
265 - match (up, down) {
266 - (true, false) => -1,
267 - (false, true) => 1,
268 - _ => 0,
269 - }
270 - };
271 -
272 - egui::Panel::left("review_samples")
273 - .resizable(true)
274 - .default_size(220.0)
275 - .show(ui, |ui| {
276 - // p-3: sort selector at the top of the side panel. The sort
277 - // doesn't mutate `items`, `display_order` below maps display
278 - // position → item index so `current_idx` keeps pointing at the
279 - // underlying ReviewItem.
280 - if let ImportMode::ReviewSuggestions { ref mut sort, .. } = state.import_wf.import_mode
281 - {
282 - ui.horizontal(|ui| {
283 - ui.label("Sort:");
284 - egui::ComboBox::from_id_salt("review_sort")
285 - .selected_text(sort.label())
286 - .show_ui(ui, |ui| {
287 - for option in ReviewSort::ALL {
288 - ui.selectable_value(sort, *option, option.label());
289 - }
290 - });
291 - });
292 - ui.separator();
293 - }
294 -
295 - egui::ScrollArea::vertical().show(ui, |ui| {
296 - if let ImportMode::ReviewSuggestions {
297 - ref items,
298 - ref mut current_idx,
299 - sort,
300 - } = state.import_wf.import_mode
301 - {
302 - let display_order = sorted_indices(items, sort);
303 -
304 - if nav_delta != 0 && !display_order.is_empty() {
305 - let pos = display_order
306 - .iter()
307 - .position(|&idx| idx == *current_idx)
308 - .unwrap_or(0) as i32;
309 - let new_pos =
310 - (pos + nav_delta).clamp(0, display_order.len() as i32 - 1) as usize;
311 - *current_idx = display_order[new_pos];
312 - }
313 -
314 - for &i in &display_order {
315 - let item = &items[i];
316 - let total = item.suggestions.len();
317 - let accepted = item.suggestions.iter().filter(|s| s.accepted).count();
318 - let selected = i == *current_idx;
319 - // M-10: review status visible at a glance. Yellow when
320 - // there's still work, muted when done or empty (nothing
321 - // to review). Bare name when the sample has no
322 - // suggestions at all.
323 - let suffix = if total == 0 {
324 - String::new()
325 - } else {
326 - format!(" \u{00B7} {accepted}/{total}")
327 - };
328 - let color = if total == 0 {
329 - theme::content_muted()
330 - } else if accepted < total {
331 - theme::warning()
332 - } else {
333 - theme::content_muted()
334 - };
335 - let label = if suffix.is_empty() {
336 - egui::RichText::new(&item.name)
337 - } else {
338 - egui::RichText::new(format!("{}{}", item.name, suffix)).color(color)
339 - };
340 - let response = ui.selectable_label(selected, label);
341 - if response.clicked() {
342 - *current_idx = i;
343 - }
344 - if selected && nav_delta != 0 {
345 - response.scroll_to_me(Some(egui::Align::Center));
346 - }
347 - }
348 - }
349 - });
350 - });
351 -
352 - egui::CentralPanel::default().show(ui, |ui| {
353 - if let ImportMode::ReviewSuggestions {
354 - ref mut items,
355 - current_idx,
356 - ..
357 - } = state.import_wf.import_mode
358 - {
359 - let total = items.len();
360 - if let Some(item) = items.get_mut(current_idx) {
361 - // m-15: sort suggestions by confidence desc so high-confidence
362 - // picks group at the top. Cheap once-per-frame sort on a small
363 - // per-item list.
364 - item.suggestions.sort_by(|a, b| {
365 - b.suggestion
366 - .confidence
367 - .partial_cmp(&a.suggestion.confidence)
368 - .unwrap_or(std::cmp::Ordering::Equal)
369 - });
370 - ui.heading(&item.name);
371 - // m-7: position indicator so the user knows where they are in
372 - // the review walk.
373 - ui.label(
374 - egui::RichText::new(format!("({} of {})", current_idx + 1, total))
375 - .color(theme::content_muted()),
376 - );
377 - ui.horizontal(|ui| {
378 - ui.label(format!("{:.2}s", item.result.duration));
379 - ui.label(format!("{}Hz", item.result.sample_rate));
380 - if let Some(peak) = item.result.peak_db {
381 - ui.label(format!("Peak: {peak:.1}dB"));
382 - }
383 - });
384 - ui.horizontal(|ui| {
385 - if let Some(bpm) = item.result.bpm {
386 - ui.label(format!("{bpm:.1} BPM"));
387 - }
388 - if let Some(ref key) = item.result.musical_key {
389 - ui.label(key);
390 - }
391 - });
392 -
393 - ui.separator();
394 -
395 - egui::ScrollArea::vertical().show(ui, |ui| {
396 - for sug in &mut item.suggestions {
397 - ui.horizontal(|ui| {
398 - ui.checkbox(&mut sug.accepted, "");
399 - ui.label(egui::RichText::new(&sug.suggestion.tag).strong());
400 - // M-14: threshold-driven confidence colour. Lets
401 - // the user scan the list and triage the borderline
402 - // suggestions instead of reading every percentage.
403 - let pct = sug.suggestion.confidence * 100.0;
404 - let color = if pct >= 80.0 {
405 - theme::success()
406 - } else if pct >= 60.0 {
407 - theme::warning()
408 - } else {
409 - theme::content_muted()
410 - };
411 - ui.label(egui::RichText::new(format!("{pct:.0}%")).color(color));
412 - });
413 - ui.indent(sug.suggestion.tag.as_str(), |ui| {
414 - ui.label(
415 - egui::RichText::new(&sug.suggestion.reason)
416 - .small()
417 - .color(theme::content_muted()),
418 - );
419 - });
420 - ui.add_space(theme::space::bound());
421 - }
422 - });
423 - }
424 - }
425 - });
426 - }
427 -
428 - /// p-3 helper: build a display order over `items` for the requested sort.
429 - /// Returns indices into the original Vec so `current_idx` keeps pointing at
430 - /// the underlying item.
431 - fn sorted_indices(items: &[crate::state::ReviewItem], sort: ReviewSort) -> Vec<usize> {
432 - let mut idx: Vec<usize> = (0..items.len()).collect();
433 - match sort {
434 - ReviewSort::ImportOrder => {}
435 - ReviewSort::Name => {
436 - idx.sort_by(|&a, &b| {
437 - items[a]
438 - .name
439 - .to_lowercase()
440 - .cmp(&items[b].name.to_lowercase())
441 - });
442 - }
443 - ReviewSort::Suggestions => {
444 - idx.sort_by(|&a, &b| items[b].suggestions.len().cmp(&items[a].suggestions.len()));
445 - }
446 - ReviewSort::Accepted => {
447 - let accepted = |i: usize| items[i].suggestions.iter().filter(|s| s.accepted).count();
448 - idx.sort_by_key(|&i| std::cmp::Reverse(accepted(i)));
449 - }
450 - }
451 - idx
452 - }