Skip to main content

max / audiofiles

Split state/import_workflow into analysis/export/edit workflow modules import_workflow.rs was a 1733-line god-file: one impl BrowserState mixing import, analysis/backfill, export, and in-place edit, with a ~448-line poll_workers match over every BackendEvent. Follow the existing state/ sibling pattern and carve three new modules: analysis_workflow.rs, export_workflow.rs, edit_workflow.rs. poll_workers stays as the event pump but its analysis/export/cleanup arms are now thin dispatch to extracted on_* handlers living beside their domain's methods; import/edit/forge/search/classifier/loose-files arms remain inline. import_workflow.rs is now 885 lines. The method set is byte-identical to before and the poll_workers early-return semantics are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 13:36 UTC
Commit: 2fd0d04a3b1b23585bfd931b21dac9e652abecb0
Parent: 3a9ce2a
5 files changed, +927 insertions, -437 deletions
@@ -0,0 +1,262 @@
1 + //! Analysis workflow: feature extraction, backfill, and the tag-suggestion
2 + //! review flow. Drives the analysis worker and owns the review/backfill
3 + //! bookkeeping applied to its `BackendEvent`s in `poll_workers`.
4 +
5 + use super::*;
6 +
7 + /// How many analysis results to accumulate before flushing them to the DB in one
8 + /// transaction. Bounds buffered memory while keeping per-result fsyncs rare.
9 + const ANALYSIS_SAVE_BATCH: usize = 64;
10 +
11 + impl BrowserState {
12 + /// Persist buffered analysis results in one transaction, then apply tag rules
13 + /// to the flushed hashes (also one transaction). Rules run after the save so
14 + /// they evaluate against the persisted analysis. No-op when the buffer empty.
15 + fn flush_analysis_saves(&mut self) {
16 + if self.import_wf.analysis_save_buffer.is_empty() {
17 + return;
18 + }
19 + let results = std::mem::take(&mut self.import_wf.analysis_save_buffer);
20 + let hashes: Vec<String> = results.iter().map(|r| r.hash.clone()).collect();
21 + let n = results.len();
22 + if let Err(e) = self.backend.save_analysis_batch(&results) {
23 + // The transaction is atomic (no partial write), but the user should
24 + // know the analysis didn't persist rather than see silent loss.
25 + self.status = format!("Failed to save analysis for {n} samples: {e}");
26 + return;
27 + }
28 + super::log_backend_err("apply_rules_to_samples", self.backend.apply_rules_to_samples(&hashes));
29 + }
30 +
31 + /// Silently recompute feature vectors for samples that lack a current-version one
32 + /// (libraries imported before the `sample_features` store existed, or after a
33 + /// feature-extractor version bump). Reuses the analysis worker but suppresses the
34 + /// review/progress screens, and yields to user-initiated analysis (which cancels and
35 + /// replaces the worker). Resumable: leftover samples are picked up on the next call.
36 + pub fn start_backfill(&mut self) {
37 + if self.import_wf.backfill_in_progress || !matches!(self.import_wf.import_mode, ImportMode::None) {
38 + return;
39 + }
40 + let needed = self.backend.samples_needing_features().unwrap_or_default();
41 + if needed.is_empty() {
42 + return;
43 + }
44 + let count = needed.len();
45 + self.import_wf.backfill_in_progress = true;
46 + self.status = format!("Backfilling features for {count} samples…");
47 + if self
48 + .backend
49 + .start_analysis(needed, AnalysisConfig::default())
50 + .is_err()
51 + {
52 + self.import_wf.backfill_in_progress = false;
53 + self.status = "Feature backfill could not start".to_string();
54 + }
55 + }
56 +
57 + /// Begin the analysis workflow by showing the configuration screen.
58 + pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) {
59 + if sample_hashes.is_empty() {
60 + return;
61 + }
62 + self.import_wf.import_mode = ImportMode::ConfigureAnalysis {
63 + sample_hashes,
64 + config: AnalysisConfig::default(),
65 + };
66 + }
67 +
68 + /// Spawn the background analysis worker and start processing samples.
69 + pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) {
70 + // This is a foreground, user-initiated analysis (it drives the Analyzing
71 + // screen and populates pending_review_items). If a background Settings
72 + // "Backfill audio features" run was in flight, its flag must not survive
73 + // into this batch: the completion handler routes results by
74 + // `backfill_in_progress`, so a leaked flag would make this import's
75 + // Analyzing screen freeze at 0/total and silently skip tag review. The
76 + // foreground analysis supersedes the backfill. (Mirrors cancel_import's
77 + // quick_import reset — the flag-leak class from fuzz-2026-07-06 B1.)
78 + self.import_wf.backfill_in_progress = false;
79 +
80 + // Stash parameters so the retry button can restart analysis.
81 + self.import_wf.last_analysis_hashes = sample_hashes.clone();
82 + self.import_wf.last_analysis_config = Some(config.clone());
83 +
84 + let total = sample_hashes.len();
85 +
86 + self.import_wf.pending_review_items.clear();
87 + self.import_wf.analysis_errors.clear();
88 + self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
89 + self.import_wf.import_mode = ImportMode::Analyzing {
90 + completed: 0,
91 + total,
92 + current_name: String::new(),
93 + };
94 + self.status = format!("Analyzing {total} samples...");
95 +
96 + super::log_backend_err("start_analysis (import)", self.backend.start_analysis(sample_hashes, config));
97 + }
98 +
99 + /// Cancel the running analysis batch and land in the acknowledgement
100 + /// screen (C-3) so the user sees what was analysed vs what was discarded.
101 + /// Falls through to `None` only when there's no meaningful progress to
102 + /// acknowledge (cancel before any work happened).
103 + pub fn cancel_analysis(&mut self) {
104 + let progress = match &self.import_wf.import_mode {
105 + ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)),
106 + _ => None,
107 + };
108 + let _ = self.backend.cancel_analysis();
109 + // Persist whatever finished before the cancel landed (matches the old
110 + // save-per-result behavior), then clear the in-flight flags so a later
111 + // import isn't silently treated as quick/backfill (they only reset on the
112 + // happy path otherwise — the flag-leak finding).
113 + self.flush_analysis_saves();
114 + self.import_wf.quick_import = false;
115 + self.import_wf.backfill_in_progress = false;
116 + self.import_wf.pending_review_items.clear();
117 + self.status = "Analysis cancelled".to_string();
118 + self.import_wf.import_mode = match progress {
119 + Some((completed, total)) if total > 0 => ImportMode::OperationCancelled {
120 + kind: crate::state::CancelKind::Analysis,
121 + completed,
122 + total,
123 + destination: None,
124 + },
125 + _ => ImportMode::None,
126 + };
127 + }
128 +
129 + /// Cancel the current analysis and restart it with the same parameters.
130 + pub fn retry_analysis(&mut self) {
131 + self.cancel_analysis();
132 + let hashes = std::mem::take(&mut self.import_wf.last_analysis_hashes);
133 + if let Some(config) = self.import_wf.last_analysis_config.take()
134 + && !hashes.is_empty() {
135 + self.run_analysis(hashes, config);
136 + }
137 + }
138 +
139 + /// Update the Analyzing screen (or a silent backfill status line) as samples
140 + /// are processed. `poll_workers` handler for `AnalysisProgress`.
141 + pub(super) fn on_analysis_progress(&mut self, completed: usize, total: usize, current_name: String) {
142 + if self.import_wf.backfill_in_progress {
143 + // Silent: keep the normal browser view, just a status line.
144 + self.status = format!("Backfilling features… {completed}/{total}");
145 + } else {
146 + self.import_wf.import_mode = ImportMode::Analyzing {
147 + completed,
148 + total,
149 + current_name,
150 + };
151 + }
152 + }
153 +
154 + /// Buffer a finished sample's analysis for batched persistence and, outside
155 + /// backfill, queue it for tag review. `poll_workers` handler for
156 + /// `AnalysisSampleDone`.
157 + pub(super) fn on_analysis_sample_done(
158 + &mut self,
159 + result: AnalysisResult,
160 + suggestions: Vec<audiofiles_core::analysis::suggest::TagSuggestion>,
161 + ) {
162 + // Buffer the DB write; flushed in one transaction every
163 + // ANALYSIS_SAVE_BATCH results and on AnalysisBatchComplete.
164 + // (Rules are applied per flushed batch, after the save, so they
165 + // still see the persisted analysis.)
166 + self.import_wf.analysis_save_buffer.push(result.clone());
167 + if self.import_wf.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH {
168 + self.flush_analysis_saves();
169 + }
170 +
171 + // Backfill: persist vectors + apply rules only — no review queue.
172 + if !self.import_wf.backfill_in_progress {
173 + let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone());
174 + let name = self.backend.sample_original_name(&hash)
175 + .unwrap_or_else(|_| hash.to_string());
176 +
177 + self.import_wf.pending_review_items.push(ReviewItem {
178 + hash,
179 + name,
180 + suggestions: suggestions
181 + .into_iter()
182 + .map(|s| SuggestionState {
183 + accepted: true,
184 + suggestion: s,
185 + })
186 + .collect(),
187 + result,
188 + });
189 + }
190 + }
191 +
192 + /// Record a per-sample analysis failure for the error-review screen.
193 + /// `poll_workers` handler for `AnalysisSampleError`.
194 + pub(super) fn on_analysis_sample_error(&mut self, hash: String, error: String) {
195 + let name = self.backend.sample_original_name(&hash)
196 + .unwrap_or_else(|_| hash.clone());
197 + self.import_wf.analysis_errors.push(super::AnalysisFileError { hash, name, error });
198 + }
199 +
200 + /// Flush buffered analysis, then route to the review, backfill-done, or
201 + /// error screen. Returns `true` when the caller (`poll_workers`) should stop
202 + /// draining this event batch and return immediately (backfill completion).
203 + /// `poll_workers` handler for `AnalysisBatchComplete`.
204 + pub(super) fn on_analysis_batch_complete(&mut self) -> bool {
205 + // Persist any analysis results still buffered, then apply rules,
206 + // before the review/backfill bookkeeping below.
207 + self.flush_analysis_saves();
208 + if self.import_wf.backfill_in_progress {
209 + self.import_wf.backfill_in_progress = false;
210 + let errors = self.import_wf.analysis_errors.len();
211 + self.import_wf.analysis_errors.clear();
212 + self.import_wf.pending_review_items.clear();
213 + self.refresh_contents();
214 + self.status = if errors == 0 {
215 + "Feature backfill complete".to_string()
216 + } else {
217 + format!("Feature backfill complete ({errors} skipped)")
218 + };
219 + return true;
220 + }
221 + let items = std::mem::take(&mut self.import_wf.pending_review_items);
222 + let error_count = self.import_wf.analysis_errors.len();
223 + if self.import_wf.quick_import {
224 + // Auto-accept all suggestions in quick mode
225 + for item in &items {
226 + for s in &item.suggestions {
227 + super::log_backend_err("add_tag (import suggestion)", self.backend.add_tag(&item.hash, &s.suggestion.tag));
228 + }
229 + }
230 + self.import_wf.quick_import = false;
231 + self.refresh_contents();
232 + self.refresh_vfs_list();
233 + let count = items.len();
234 + self.import_wf.import_mode = ImportMode::None;
235 + self.status = if error_count == 0 {
236 + format!("Quick import complete: {count} samples analyzed")
237 + } else {
238 + format!("Quick import complete: {count} samples analyzed ({error_count} errors)")
239 + };
240 + } else if items.is_empty() {
241 + if self.has_import_errors() {
242 + self.import_wf.import_mode = ImportMode::ReviewErrors;
243 + } else {
244 + self.import_wf.import_mode = ImportMode::None;
245 + self.status = "Analysis complete, no results".to_string();
246 + }
247 + } else {
248 + let count = items.len();
249 + self.import_wf.import_mode = ImportMode::ReviewSuggestions {
250 + items,
251 + current_idx: 0,
252 + sort: crate::state::ReviewSort::ImportOrder,
253 + };
254 + self.status = if error_count == 0 {
255 + format!("Analyzed {count} samples")
256 + } else {
257 + format!("Analyzed {count} samples ({error_count} errors)")
258 + };
259 + }
260 + false
261 + }
262 + }
@@ -0,0 +1,449 @@
1 + //! Sample edit workflow: the floating editor window, single and batch edit
2 + //! operations, result-mode handling, and undo. Edit-completion events from
3 + //! the worker land in `poll_workers`, which calls `handle_edit_complete`.
4 +
5 + use super::*;
6 +
7 + impl BrowserState {
8 + /// Open the floating sample editor for the given hash.
9 + pub fn open_edit_window(&mut self, hash: &str) {
10 + // Load analysis for total frames and result mode preference
11 + let analysis = self.backend.get_analysis(hash).ok().flatten();
12 + let total_frames = analysis.as_ref()
13 + .map(|a| (a.duration * a.sample_rate as f64) as usize)
14 + .unwrap_or(0);
15 +
16 + self.edit.hash = Some(hash.to_string());
17 + self.edit.show_window = true;
18 +
19 + // Reset all params to defaults
20 + self.edit.trim_start = 0.0;
21 + self.edit.trim_end = 1.0;
22 + self.edit.total_frames = total_frames;
23 + self.edit.gain_db = 0.0;
24 + self.edit.norm_peak = true;
25 + self.edit.norm_target = -0.1;
26 + self.edit.fade_in = true;
27 + self.edit.fade_duration_ms = 100.0;
28 + self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear;
29 +
30 + // Cache result mode from user_config
31 + self.edit.result_mode = match self.backend.get_config("edit_result_mode").ok().flatten().as_deref() {
32 + Some("replace") => Some(super::EditResultMode::Replace),
33 + Some("sibling") => Some(super::EditResultMode::Sibling),
34 + _ => None,
35 + };
36 + }
37 +
38 + /// Close the floating sample editor.
39 + pub fn close_edit_window(&mut self) {
40 + self.edit.show_window = false;
41 + self.edit.hash = None;
42 + }
43 +
44 + /// M-11: best-effort cancel of the in-flight edit. Signals the worker via
45 + /// the backend and clears `in_progress` so the UI is interactive again.
46 + /// The worker may still finish writing its output; if it does, the result
47 + /// path will eventually be handled by `handle_edit_complete` as normal.
48 + pub fn cancel_edit_operation(&mut self) {
49 + let _ = self.backend.cancel_edit();
50 + self.edit.in_progress = false;
51 + self.status = "Edit cancelled.".to_string();
52 + }
53 +
54 + /// M-12: discard the pending edit result without applying it. The result
55 + /// file held in `pending_result` is dropped; if the temp path still exists
56 + /// on disk it is removed.
57 + pub fn discard_edit_result(&mut self) {
58 + if let Some(pending) = self.edit.pending_result.take() {
59 + let _ = std::fs::remove_file(&pending.result_path);
60 + }
61 + self.edit.result_prompt = false;
62 + self.status = "Edit result discarded.".to_string();
63 + }
64 +
65 + /// Apply trim to the current edit target.
66 + pub fn apply_edit_trim(&mut self) {
67 + let hash = match &self.edit.hash {
68 + Some(h) => h.clone(),
69 + None => return,
70 + };
71 + // total_frames falls back to 0 when analysis is absent; a 0-length span
72 + // (or an inverted one) would dispatch Trim{0,0} and produce a zero-length
73 + // sample. Guard it like apply_edit_remove_range does (fuzz-2026-07-06 B4).
74 + if self.edit.total_frames == 0 {
75 + self.status = "Cannot trim: sample length is unknown.".to_string();
76 + return;
77 + }
78 + let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize;
79 + let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize;
80 + if start >= end {
81 + self.status = "Trim range: start must be before end".to_string();
82 + return;
83 + }
84 + let op = audiofiles_core::edit::EditOperation::Trim { start_frame: start, end_frame: end };
85 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
86 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
87 + self.status = format!("Edit failed: {e}");
88 + }
89 + }
90 +
91 + /// Apply gain adjustment to the current edit target.
92 + pub fn apply_edit_gain(&mut self) {
93 + let hash = match &self.edit.hash {
94 + Some(h) => h.clone(),
95 + None => return,
96 + };
97 + let op = audiofiles_core::edit::EditOperation::Gain { db: self.edit.gain_db };
98 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
99 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
100 + self.status = format!("Edit failed: {e}");
101 + }
102 + }
103 +
104 + /// Apply normalize to the current edit target.
105 + pub fn apply_edit_normalize(&mut self) {
106 + let hash = match &self.edit.hash {
107 + Some(h) => h.clone(),
108 + None => return,
109 + };
110 + let op = if self.edit.norm_peak {
111 + audiofiles_core::edit::EditOperation::NormalizePeak { target_db: self.edit.norm_target }
112 + } else {
113 + audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs: self.edit.norm_target }
114 + };
115 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
116 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
117 + self.status = format!("Edit failed: {e}");
118 + }
119 + }
120 +
121 + /// Apply reverse to the current edit target.
122 + pub fn apply_edit_reverse(&mut self) {
123 + let hash = match &self.edit.hash {
124 + Some(h) => h.clone(),
125 + None => return,
126 + };
127 + let op = audiofiles_core::edit::EditOperation::Reverse;
128 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
129 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
130 + self.status = format!("Edit failed: {e}");
131 + }
132 + }
133 +
134 + /// Apply fade to the current edit target.
135 + pub fn apply_edit_fade(&mut self) {
136 + let hash = match &self.edit.hash {
137 + Some(h) => h.clone(),
138 + None => return,
139 + };
140 + // Convert ms to frames using sample rate from analysis
141 + let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
142 + .map(|a| a.sample_rate)
143 + .unwrap_or(44100);
144 + let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize;
145 + let op = if self.edit.fade_in {
146 + audiofiles_core::edit::EditOperation::FadeIn { frames, curve: self.edit.fade_curve }
147 + } else {
148 + audiofiles_core::edit::EditOperation::FadeOut { frames, curve: self.edit.fade_curve }
149 + };
150 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
151 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
152 + self.status = format!("Edit failed: {e}");
153 + }
154 + }
155 +
156 + /// Insert silence at the configured position and duration.
157 + pub fn apply_edit_insert_silence(&mut self) {
158 + let hash = match &self.edit.hash {
159 + Some(h) => h.clone(),
160 + None => return,
161 + };
162 + let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
163 + .map(|a| a.sample_rate)
164 + .unwrap_or(44100);
165 + let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize;
166 + let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize;
167 + let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames };
168 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
169 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
170 + self.status = format!("Edit failed: {e}");
171 + }
172 + }
173 +
174 + /// Remove a range of frames between the configured start and end positions.
175 + pub fn apply_edit_remove_range(&mut self) {
176 + let hash = match &self.edit.hash {
177 + Some(h) => h.clone(),
178 + None => return,
179 + };
180 + let sample_rate = self.backend.get_analysis(&hash).ok().flatten()
181 + .map(|a| a.sample_rate)
182 + .unwrap_or(44100);
183 + let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize;
184 + let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize;
185 + if start_frame >= end_frame {
186 + self.status = "Remove range: start must be before end".to_string();
187 + return;
188 + }
189 + let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame };
190 + let ext = self.backend.sample_extension(&hash).unwrap_or_default();
191 + if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
192 + self.status = format!("Edit failed: {e}");
193 + }
194 + }
195 +
196 + /// Apply an edit operation to all selected samples (batch edit).
197 + /// Skips directories and samples without hashes.
198 + pub fn batch_edit(&mut self, op_factory: impl Fn(&str) -> Option<audiofiles_core::edit::EditOperation>) {
199 + let hashes = self.selected_sample_hashes();
200 + if hashes.is_empty() {
201 + self.status = "No samples selected for batch edit".to_string();
202 + return;
203 + }
204 + let mut applied = 0;
205 + let mut errors = 0;
206 + for hash in &hashes {
207 + let Some(op) = op_factory(hash) else { continue; };
208 + let ext = self.backend.sample_extension(hash).unwrap_or_default();
209 + match self.backend.start_edit(hash, &ext, op) {
210 + Ok(()) => applied += 1,
211 + Err(_) => errors += 1,
212 + }
213 + }
214 + self.status = if errors > 0 {
215 + format!("Batch edit: {applied} applied, {errors} failed")
216 + } else {
217 + format!("Batch edit: {applied} samples processed")
218 + };
219 + }
220 +
221 + /// Batch normalize (peak) all selected samples.
222 + pub fn batch_normalize_peak(&mut self, target_db: f64) {
223 + self.batch_edit(|_hash| {
224 + Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db })
225 + });
226 + }
227 +
228 + /// Batch normalize (LUFS) all selected samples.
229 + pub fn batch_normalize_lufs(&mut self, target_lufs: f64) {
230 + self.batch_edit(|_hash| {
231 + Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs })
232 + });
233 + }
234 +
235 + /// Batch reverse all selected samples.
236 + pub fn batch_reverse(&mut self) {
237 + self.batch_edit(|_hash| {
238 + Some(audiofiles_core::edit::EditOperation::Reverse)
239 + });
240 + }
241 +
242 + /// Batch apply gain to all selected samples.
243 + pub fn batch_gain(&mut self, db: f64) {
244 + self.batch_edit(|_hash| {
245 + Some(audiofiles_core::edit::EditOperation::Gain { db })
246 + });
247 + }
248 +
249 + /// Save the user's preferred edit result mode.
250 + pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) {
251 + let mode_str = match mode {
252 + super::EditResultMode::Replace => "replace",
253 + super::EditResultMode::Sibling => "sibling",
254 + };
255 + super::log_backend_err("set_config edit_result_mode", self.backend.set_config("edit_result_mode", mode_str));
256 + self.edit.result_mode = Some(mode);
257 + }
258 +
259 + /// Handle a completed edit: import result, update VFS, record history.
260 + pub(super) fn handle_edit_complete(
261 + &mut self,
262 + source_hash: String,
263 + result_path: PathBuf,
264 + operation: audiofiles_core::edit::EditOperation,
265 + ) {
266 + let op_name = operation.display_name().to_string();
267 +
268 + // Use cached result mode preference
269 + let mode = match self.edit.result_mode {
270 + Some(m) => m,
271 + None => {
272 + // No preference set — store result and show prompt
273 + self.edit.pending_result = Some(super::PendingEditResult {
274 + source_hash,
275 + result_path,
276 + operation,
277 + });
278 + self.edit.result_prompt = true;
279 + return;
280 + }
281 + };
282 +
283 + self.finalize_edit(source_hash, result_path, operation, mode);
284 + self.status = format!("Edit applied — {op_name}");
285 + }
286 +
287 + /// Apply the chosen edit result mode to a pending edit.
288 + pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) {
289 + if remember {
290 + self.set_edit_result_mode(mode);
291 + }
292 +
293 + if let Some(pending) = self.edit.pending_result.take() {
294 + let op_name = pending.operation.display_name().to_string();
295 + self.finalize_edit(pending.source_hash, pending.result_path, pending.operation, mode);
296 + self.status = format!("Edit applied — {op_name}");
297 + }
298 + self.edit.result_prompt = false;
299 + }
300 +
301 + /// Common finalization: import result file, update VFS, record history, trigger analysis.
302 + fn finalize_edit(
303 + &mut self,
304 + source_hash: String,
305 + result_path: PathBuf,
306 + operation: audiofiles_core::edit::EditOperation,
307 + mode: super::EditResultMode,
308 + ) {
309 + // 1. Import the result file into the content-addressed store. Always
310 + // clean up the temp regardless of outcome — the prior code only removed
311 + // on the success branch, leaking the temp on every import failure.
312 + // (The temp is the user's edit output; after import_file copies it
313 + // into the store, we own the canonical copy by hash.)
314 + let import_result = self.backend.import_file(&result_path);
315 + let _ = std::fs::remove_file(&result_path);
316 + let new_hash = match import_result {
317 + Ok(h) => h,
318 + Err(e) => {
319 + self.status = format!("Failed to import edit result: {e}");
320 + return;
321 + }
322 + };
323 +
324 + // 2. Record in edit_history
325 + super::log_backend_err("record_edit_history", self.backend.record_edit_history(&source_hash, &new_hash, &operation));
326 +
327 + // 3. Update VFS based on mode
328 + let Some(vfs_id) = self.current_vfs_id() else {
329 + self.status = "No VFS available".to_string();
330 + return;
331 + };
332 + let source_hashes = [source_hash.as_str()];
333 + let source_nodes = self.backend.find_nodes_by_hashes(vfs_id, &source_hashes)
334 + .unwrap_or_default();
335 +
336 + let new_ext = self.backend.sample_extension(&new_hash).unwrap_or_else(|_| "wav".to_string());
337 +
338 + // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit`
339 + // can walk them back. Captured before deletion in Replace mode and
340 + // after creation in Sibling mode.
341 + let mut replace_targets: Vec<(Option<audiofiles_core::NodeId>, String)> = Vec::new();
342 + let mut sibling_node_id: Option<audiofiles_core::NodeId> = None;
343 +
344 + match mode {
345 + super::EditResultMode::Replace => {
346 + // Update each VFS node that references the source hash
347 + for node in &source_nodes {
348 + // Delete old node and create new one with same name/parent
349 + let parent_id = node.node.parent_id;
350 + let name = node.node.name.clone();
351 + replace_targets.push((parent_id, name.clone()));
352 + super::log_backend_err("delete_node (edit VFS update)", self.backend.delete_node(node.node.id));
353 + super::log_backend_err("create_sample_link (edit replace)", self.backend.create_sample_link(vfs_id, parent_id, &name, &new_hash));
354 + }
355 + }
356 + super::EditResultMode::Sibling => {
357 + // Create a sibling node next to the original
358 + if let Some(node) = source_nodes.first() {
359 + let parent_id = node.node.parent_id;
360 + let (stem, _ext) = split_name_ext(&node.node.name);
361 + let sibling_name = format!("{stem}_edited.{new_ext}");
362 + if let Ok(id) = self
363 + .backend
364 + .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash)
365 + {
366 + sibling_node_id = Some(id);
367 + }
368 + }
369 + }
370 + }
371 +
372 + // C-1 part 2: stash the entry only if there's actually something to
373 + // undo (no VFS nodes → nothing happened that needs reversing).
374 + if !replace_targets.is_empty() || sibling_node_id.is_some() {
375 + self.edit.last_undo = Some(super::EditUndoEntry {
376 + op_name: operation.display_name().to_string(),
377 + source_hash: source_hash.clone(),
378 + result_hash: new_hash.clone(),
379 + mode,
380 + vfs_id,
381 + replace_targets,
382 + sibling_node_id,
383 + created_at: std::time::Instant::now(),
384 + });
385 + }
386 +
387 + // 4. Trigger analysis on the new sample
388 + let hashes = vec![(new_hash, new_ext)];
389 + super::log_backend_err("start_analysis (post-edit)", self.backend.start_analysis(hashes, AnalysisConfig::default()));
390 +
391 + // 5. Refresh the file list
392 + self.refresh_contents();
393 + }
394 +
395 + /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks
396 + /// the VFS nodes back to the source hash; Sibling mode deletes the
397 + /// created sibling. The original sample blob is preserved by the
398 + /// content-addressed store so no audio data needs to be restored.
399 + ///
400 + /// Returns silently with no-op if `last_undo` is empty.
401 + pub fn undo_last_edit(&mut self) {
402 + let Some(entry) = self.edit.last_undo.take() else {
403 + return;
404 + };
405 +
406 + match entry.mode {
407 + super::EditResultMode::Replace => {
408 + // Re-find any nodes now pointing at result_hash and clear them
409 + // first — the edit may have produced multiple nodes when the
410 + // sample appeared in multiple places, and we recreate from the
411 + // captured (parent_id, name) list.
412 + let result_hashes = [entry.result_hash.as_str()];
413 + if let Ok(result_nodes) = self
414 + .backend
415 + .find_nodes_by_hashes(entry.vfs_id, &result_hashes)
416 + {
417 + for node in &result_nodes {
418 + super::log_backend_err("delete_node (edit VFS update)", self.backend.delete_node(node.node.id));
419 + }
420 + }
421 + for (parent_id, name) in &entry.replace_targets {
422 + super::log_backend_err(
423 + "create_sample_link (edit undo)",
424 + self.backend.create_sample_link(
425 + entry.vfs_id,
426 + *parent_id,
427 + name,
428 + &entry.source_hash,
429 + ),
430 + );
431 + }
432 + }
433 + super::EditResultMode::Sibling => {
434 + if let Some(id) = entry.sibling_node_id {
435 + super::log_backend_err("delete_node (edit undo sibling)", self.backend.delete_node(id));
436 + }
437 + }
438 + }
439 +
440 + // Drop the matching edit_history row so the audit trail reflects the
441 + // undo. Best-effort — the UI undo affordance already disappeared.
442 + let _ = self
443 + .backend
444 + .delete_edit_history(&entry.source_hash, &entry.result_hash);
445 +
446 + self.status = format!("Reverted {}", entry.op_name);
447 + self.refresh_contents();
448 + }
449 + }
@@ -0,0 +1,205 @@
1 + //! Export workflow: collect items from the VFS/selection, configure the
2 + //! export, and drive the export/cleanup workers. Owns the export- and
3 + //! cleanup-event handling applied in `poll_workers`.
4 +
5 + use super::*;
6 +
7 + impl BrowserState {
8 + /// Begin the export workflow: collect items from the current VFS/selection and show config.
9 + pub fn start_export_flow(&mut self, node_ids: Option<Vec<NodeId>>) {
10 + let Some(vfs_id) = self.current_vfs_id() else {
11 + self.status = "No VFS available".to_string();
12 + return;
13 + };
14 +
15 + let mut items = if let Some(ids) = node_ids {
16 + // Export specific selected items: collect subtrees for each
17 + let mut all_items = Vec::new();
18 + for id in ids {
19 + let node = match self.backend.get_node(id) {
20 + Ok(n) => n,
21 + Err(_) => continue,
22 + };
23 + match node.node_type {
24 + NodeType::Directory => {
25 + if let Ok(sub_items) =
26 + self.backend.collect_export_items(vfs_id, Some(id))
27 + {
28 + all_items.extend(sub_items);
29 + }
30 + }
31 + NodeType::Sample => {
32 + if let Some(hash) = &node.sample_hash {
33 + let ext = self.backend.sample_extension(hash)
34 + .unwrap_or_default();
35 + all_items.push(audiofiles_core::export::ExportItem {
36 + hash: hash.clone(),
37 + ext,
38 + relative_path: std::path::PathBuf::from(&node.name),
39 + name: node.name.clone(),
40 + bpm: None,
41 + musical_key: None,
42 + classification: None,
43 + duration: None,
44 + tags: Vec::new(),
45 + source_path: None,
46 + });
47 + }
48 + }
49 + }
50 + }
51 + all_items
52 + } else if self.collections_ui.active_collection.is_some() {
53 + // Export collection contents
54 + self.nav.contents.iter().filter_map(|node| {
55 + let hash = node.node.sample_hash.as_ref()?;
56 + let ext = self.backend.sample_extension(hash).unwrap_or_default();
57 + Some(audiofiles_core::export::ExportItem {
58 + hash: hash.clone(),
59 + ext,
60 + relative_path: std::path::PathBuf::from(&node.node.name),
61 + name: node.node.name.clone(),
62 + bpm: node.bpm,
63 + musical_key: node.musical_key.clone(),
64 + classification: node.classification.clone(),
65 + duration: node.duration,
66 + tags: node.tags.clone(),
67 + source_path: None,
68 + })
69 + }).collect()
70 + } else {
71 + // Export entire current VFS subtree
72 + self.backend.collect_export_items(vfs_id, self.nav.current_dir)
73 + .unwrap_or_default()
74 + };
75 +
76 + super::log_backend_err("enrich_export_with_tags", self.backend.enrich_export_with_tags(&mut items));
77 +
78 + if items.is_empty() {
79 + self.status = "No samples to export".to_string();
80 + return;
81 + }
82 +
83 + let default_dest = dirs::download_dir()
84 + .or_else(dirs::home_dir)
85 + .unwrap_or_else(|| std::path::PathBuf::from("."))
86 + .join("audiofiles Export");
87 +
88 + let available_profiles = self.backend.list_device_profiles().unwrap_or_default();
89 +
90 + self.import_wf.import_mode = ImportMode::ConfigureExport {
91 + items,
92 + config: audiofiles_core::export::ExportConfig {
93 + format: audiofiles_core::export::ExportFormat::Original,
94 + sample_rate: None,
95 + bit_depth: None,
96 + channels: audiofiles_core::export::ExportChannels::Original,
97 + naming_pattern: None,
98 + flatten: false,
99 + metadata_sidecar: false,
100 + destination: default_dest,
101 + device_profile: None,
102 + naming_rules: None,
103 + max_file_size_bytes: None,
104 + name_overrides: None,
105 + },
106 + available_profiles,
107 + };
108 + }
109 +
110 + /// Spawn the export worker and start processing.
111 + pub fn run_export(&mut self, items: Vec<audiofiles_core::export::ExportItem>, config: audiofiles_core::export::ExportConfig) {
112 + // Stash destination so the cancel-acknowledgement screen can name it
113 + // if the user cancels mid-export (C-3). The Exporting variant doesn't
114 + // carry destination — it's consumed by the worker — but the path is
115 + // still useful in the UI for "files already written remain at <path>".
116 + self.import_wf.last_export_destination = Some(config.destination.clone());
117 + self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
118 + super::log_backend_err("start_export", self.backend.start_export(items, config));
119 +
120 + self.import_wf.import_mode = ImportMode::Exporting {
121 + completed: 0,
122 + total: 0,
123 + current_name: String::new(),
124 + };
125 + }
126 +
127 + /// Cancel the running cleanup and return to idle state.
128 + pub fn cancel_cleanup(&mut self) {
129 + let _ = self.backend.cancel_cleanup();
130 + self.import_wf.import_mode = ImportMode::None;
131 + self.refresh_vfs_list();
132 + self.status = "Cleanup cancelled".to_string();
133 + }
134 +
135 + /// Cancel the running export. Lands in the acknowledgement screen (C-3)
136 + /// when meaningful progress has happened, surfacing the destination folder
137 + /// so the user knows where partial files may sit. Falls through to `None`
138 + /// when no items have been written yet.
139 + pub fn cancel_export(&mut self) {
140 + let progress = match &self.import_wf.import_mode {
141 + ImportMode::Exporting { completed, total, .. } if *total > 0 => {
142 + Some((*completed, *total))
143 + }
144 + _ => None,
145 + };
146 + let _ = self.backend.cancel_export();
147 + self.status = "Export cancelled".to_string();
148 + self.import_wf.import_mode = match progress {
149 + Some((completed, total)) => ImportMode::OperationCancelled {
150 + kind: crate::state::CancelKind::Export,
151 + completed,
152 + total,
153 + destination: self.import_wf.last_export_destination.clone(),
154 + },
155 + None => ImportMode::None,
156 + };
157 + }
158 +
159 + /// Update the Exporting screen as items are written. `poll_workers` handler
160 + /// for `ExportProgress`.
161 + pub(super) fn on_export_progress(&mut self, completed: usize, total: usize, current_name: String) {
162 + self.import_wf.import_mode = ImportMode::Exporting {
163 + completed,
164 + total,
165 + current_name,
166 + };
167 + }
168 +
169 + /// Land on the export-complete screen, surfacing any per-item errors.
170 + /// `poll_workers` handler for `ExportComplete`.
171 + pub(super) fn on_export_complete(&mut self, total: usize, errors: Vec<(String, String)>) {
172 + let error_count = errors.len();
173 + if error_count == 0 {
174 + self.status = format!("Exported {total} files");
175 + } else {
176 + self.status = format!("Exported {total} files ({error_count} errors)");
177 + }
178 + self.import_wf.import_mode = ImportMode::ExportComplete { total, errors };
179 + }
180 +
181 + /// Update the Cleaning screen as the library-delete worker progresses.
182 + /// `poll_workers` handler for `CleanupProgress`.
183 + pub(super) fn on_cleanup_progress(&mut self, completed: usize, total: usize, current_name: String) {
184 + self.import_wf.import_mode = ImportMode::Cleaning {
185 + completed,
186 + total,
187 + current_name,
188 + };
189 + }
190 +
191 + /// Report the result of a library delete and return to idle. `poll_workers`
192 + /// handler for `CleanupComplete`.
193 + pub(super) fn on_cleanup_complete(&mut self, removed: usize, errors: usize) {
194 + self.refresh_vfs_list();
195 + if errors > 0 {
196 + self.status =
197 + format!("Library deleted ({removed} samples removed, {errors} errors)");
198 + } else if removed > 0 {
199 + self.status = format!("Library deleted ({removed} samples removed)");
200 + } else {
201 + self.status = "Library deleted".to_string();
202 + }
203 + self.import_wf.import_mode = ImportMode::None;
204 + }
205 + }
@@ -4,10 +4,6 @@ use tracing::{error, warn};
4 4
5 5 use super::*;
6 6
7 - /// How many analysis results to accumulate before flushing them to the DB in one
8 - /// transaction. Bounds buffered memory while keeping per-result fsyncs rare.
9 - const ANALYSIS_SAVE_BATCH: usize = 64;
10 -
11 7 /// Count audio files in a directory (recursive). Used for the import dry-run preview.
12 8 fn count_audio_files(dir: &Path) -> usize {
13 9 walk_folder_stats(dir).0
@@ -57,24 +53,6 @@ pub struct ImportPreflight {
57 53 }
58 54
59 55 impl BrowserState {
60 - /// Persist buffered analysis results in one transaction, then apply tag rules
61 - /// to the flushed hashes (also one transaction). Rules run after the save so
62 - /// they evaluate against the persisted analysis. No-op when the buffer empty.
63 - fn flush_analysis_saves(&mut self) {
64 - if self.import_wf.analysis_save_buffer.is_empty() {
65 - return;
66 - }
67 - let results = std::mem::take(&mut self.import_wf.analysis_save_buffer);
68 - let hashes: Vec<String> = results.iter().map(|r| r.hash.clone()).collect();
69 - let n = results.len();
70 - if let Err(e) = self.backend.save_analysis_batch(&results) {
71 - // The transaction is atomic (no partial write), but the user should
72 - // know the analysis didn't persist rather than see silent loss.
73 - self.status = format!("Failed to save analysis for {n} samples: {e}");
74 - return;
75 - }
76 - super::log_backend_err("apply_rules_to_samples", self.backend.apply_rules_to_samples(&hashes));
77 - }
78 56
79 57 /// Quick-import a single file or directory via drag-and-drop into the current folder.
80 58 /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated.
@@ -221,103 +199,6 @@ impl BrowserState {
221 199 }
222 200 }
223 201
224 - /// Silently recompute feature vectors for samples that lack a current-version one
225 - /// (libraries imported before the `sample_features` store existed, or after a
226 - /// feature-extractor version bump). Reuses the analysis worker but suppresses the
227 - /// review/progress screens, and yields to user-initiated analysis (which cancels and
228 - /// replaces the worker). Resumable: leftover samples are picked up on the next call.
229 - pub fn start_backfill(&mut self) {
230 - if self.import_wf.backfill_in_progress || !matches!(self.import_wf.import_mode, ImportMode::None) {
231 - return;
232 - }
233 - let needed = self.backend.samples_needing_features().unwrap_or_default();
234 - if needed.is_empty() {
235 - return;
236 - }
237 - let count = needed.len();
238 - self.import_wf.backfill_in_progress = true;
239 - self.status = format!("Backfilling features for {count} samples…");
240 - if self
241 - .backend
242 - .start_analysis(needed, AnalysisConfig::default())
243 - .is_err()
244 - {
245 - self.import_wf.backfill_in_progress = false;
246 - self.status = "Feature backfill could not start".to_string();
247 - }
248 - }
249 -
250 - /// Begin the analysis workflow by showing the configuration screen.
251 - pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) {
252 - if sample_hashes.is_empty() {
253 - return;
254 - }
255 - self.import_wf.import_mode = ImportMode::ConfigureAnalysis {
256 - sample_hashes,
257 - config: AnalysisConfig::default(),
258 - };
259 - }
260 -
261 - /// Spawn the background analysis worker and start processing samples.
262 - pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) {
263 - // This is a foreground, user-initiated analysis (it drives the Analyzing
264 - // screen and populates pending_review_items). If a background Settings
265 - // "Backfill audio features" run was in flight, its flag must not survive
266 - // into this batch: the completion handler routes results by
267 - // `backfill_in_progress`, so a leaked flag would make this import's
268 - // Analyzing screen freeze at 0/total and silently skip tag review. The
269 - // foreground analysis supersedes the backfill. (Mirrors cancel_import's
270 - // quick_import reset — the flag-leak class from fuzz-2026-07-06 B1.)
271 - self.import_wf.backfill_in_progress = false;
272 -
273 - // Stash parameters so the retry button can restart analysis.
274 - self.import_wf.last_analysis_hashes = sample_hashes.clone();
275 - self.import_wf.last_analysis_config = Some(config.clone());
276 -
277 - let total = sample_hashes.len();
278 -
279 - self.import_wf.pending_review_items.clear();
280 - self.import_wf.analysis_errors.clear();
281 - self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
282 - self.import_wf.import_mode = ImportMode::Analyzing {
283 - completed: 0,
284 - total,
285 - current_name: String::new(),
286 - };
287 - self.status = format!("Analyzing {total} samples...");
288 -
289 - super::log_backend_err("start_analysis (import)", self.backend.start_analysis(sample_hashes, config));
290 - }
291 -
292 - /// Cancel the running analysis batch and land in the acknowledgement
293 - /// screen (C-3) so the user sees what was analysed vs what was discarded.
294 - /// Falls through to `None` only when there's no meaningful progress to
295 - /// acknowledge (cancel before any work happened).
296 - pub fn cancel_analysis(&mut self) {
297 - let progress = match &self.import_wf.import_mode {
298 - ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)),
299 - _ => None,
300 - };
301 - let _ = self.backend.cancel_analysis();
302 - // Persist whatever finished before the cancel landed (matches the old
303 - // save-per-result behavior), then clear the in-flight flags so a later
304 - // import isn't silently treated as quick/backfill (they only reset on the
305 - // happy path otherwise — the flag-leak finding).
306 - self.flush_analysis_saves();
307 - self.import_wf.quick_import = false;
308 - self.import_wf.backfill_in_progress = false;
309 - self.import_wf.pending_review_items.clear();
310 - self.status = "Analysis cancelled".to_string();
311 - self.import_wf.import_mode = match progress {
312 - Some((completed, total)) if total > 0 => ImportMode::OperationCancelled {
313 - kind: crate::state::CancelKind::Analysis,
314 - completed,
315 - total,
316 - destination: None,
317 - },
318 - _ => ImportMode::None,
319 - };
320 - }
321 202
322 203 // --- Folder import ---
323 204
@@ -638,113 +519,18 @@ impl BrowserState {
638 519 completed,
639 520 total,
640 521 current_name,
641 - } => {
642 - if self.import_wf.backfill_in_progress {
643 - // Silent: keep the normal browser view, just a status line.
644 - self.status = format!("Backfilling features… {completed}/{total}");
645 - } else {
646 - self.import_wf.import_mode = ImportMode::Analyzing {
647 - completed,
648 - total,
649 - current_name,
650 - };
651 - }
652 - }
522 + } => self.on_analysis_progress(completed, total, current_name),
653 523 BackendEvent::AnalysisSampleDone {
654 524 result,
655 525 suggestions,
656 - } => {
657 - let result = *result;
658 - // Buffer the DB write; flushed in one transaction every
659 - // ANALYSIS_SAVE_BATCH results and on AnalysisBatchComplete.
660 - // (Rules are applied per flushed batch, after the save, so they
661 - // still see the persisted analysis.)
662 - self.import_wf.analysis_save_buffer.push(result.clone());
663 - if self.import_wf.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH {
664 - self.flush_analysis_saves();
665 - }
666 -
667 - // Backfill: persist vectors + apply rules only — no review queue.
668 - if !self.import_wf.backfill_in_progress {
669 - let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone());
670 - let name = self.backend.sample_original_name(&hash)
671 - .unwrap_or_else(|_| hash.to_string());
672 -
673 - self.import_wf.pending_review_items.push(ReviewItem {
674 - hash,
675 - name,
676 - suggestions: suggestions
677 - .into_iter()
678 - .map(|s| SuggestionState {
679 - accepted: true,
680 - suggestion: s,
681 - })
682 - .collect(),
683 - result,
684 - });
685 - }
686 - }
526 + } => self.on_analysis_sample_done(*result, suggestions),
687 527 BackendEvent::AnalysisSampleError { hash, error } => {
688 - let name = self.backend.sample_original_name(&hash)
689 - .unwrap_or_else(|_| hash.clone());
690 - self.import_wf.analysis_errors.push(super::AnalysisFileError { hash, name, error });
528 + self.on_analysis_sample_error(hash, error)
691 529 }
692 530 BackendEvent::AnalysisBatchComplete => {
693 - // Persist any analysis results still buffered, then apply rules,
694 - // before the review/backfill bookkeeping below.
695 - self.flush_analysis_saves();
696 - if self.import_wf.backfill_in_progress {
697 - self.import_wf.backfill_in_progress = false;
698 - let errors = self.import_wf.analysis_errors.len();
699 - self.import_wf.analysis_errors.clear();
700 - self.import_wf.pending_review_items.clear();
701 - self.refresh_contents();
702 - self.status = if errors == 0 {
703 - "Feature backfill complete".to_string()
704 - } else {
705 - format!("Feature backfill complete ({errors} skipped)")
706 - };
531 + if self.on_analysis_batch_complete() {
707 532 return true;
708 533 }
709 - let items = std::mem::take(&mut self.import_wf.pending_review_items);
710 - let error_count = self.import_wf.analysis_errors.len();
711 - if self.import_wf.quick_import {
712 - // Auto-accept all suggestions in quick mode
713 - for item in &items {
714 - for s in &item.suggestions {
715 - super::log_backend_err("add_tag (import suggestion)", self.backend.add_tag(&item.hash, &s.suggestion.tag));
716 - }
717 - }
718 - self.import_wf.quick_import = false;
719 - self.refresh_contents();
720 - self.refresh_vfs_list();
721 - let count = items.len();
722 - self.import_wf.import_mode = ImportMode::None;
723 - self.status = if error_count == 0 {
724 - format!("Quick import complete: {count} samples analyzed")
725 - } else {
726 - format!("Quick import complete: {count} samples analyzed ({error_count} errors)")
727 - };
728 - } else if items.is_empty() {
729 - if self.has_import_errors() {
730 - self.import_wf.import_mode = ImportMode::ReviewErrors;
731 - } else {
732 - self.import_wf.import_mode = ImportMode::None;
733 - self.status = "Analysis complete, no results".to_string();
734 - }
735 - } else {
736 - let count = items.len();
737 - self.import_wf.import_mode = ImportMode::ReviewSuggestions {
738 - items,
739 - current_idx: 0,
740 - sort: crate::state::ReviewSort::ImportOrder,
741 - };
742 - self.status = if error_count == 0 {
743 - format!("Analyzed {count} samples")
744 - } else {
745 - format!("Analyzed {count} samples ({error_count} errors)")
746 - };
747 - }
748 534 }
749 535
750 536 // --- Export events ---
@@ -752,22 +538,9 @@ impl BrowserState {
752 538 completed,
753 539 total,
754 540 current_name,
755 - } => {
756 - self.import_wf.import_mode = ImportMode::Exporting {
757 - completed,
758 - total,
759 - current_name,
760 - };
761 - }
541 + } => self.on_export_progress(completed, total, current_name),
762 542 BackendEvent::ExportComplete { total, errors } => {
763 - let error_count = errors.len();
764 - if error_count == 0 {
765 - self.status = format!("Exported {total} files");
766 - } else {
767 - self.status =
768 - format!("Exported {total} files ({error_count} errors)");
769 - }
770 - self.import_wf.import_mode = ImportMode::ExportComplete { total, errors };
543 + self.on_export_complete(total, errors);
771 544 return true;
772 545 }
773 546
@@ -776,25 +549,9 @@ impl BrowserState {
776 549 completed,
777 550 total,
778 551 current_name,
779 - } => {
780 - self.import_wf.import_mode = ImportMode::Cleaning {
781 - completed,
782 - total,
783 - current_name,
784 - };
785 - }
552 + } => self.on_cleanup_progress(completed, total, current_name),
786 553 BackendEvent::CleanupComplete { removed, errors } => {
787 - self.refresh_vfs_list();
788 - if errors > 0 {
789 - self.status =
790 - format!("Library deleted ({removed} samples removed, {errors} errors)");
791 - } else if removed > 0 {
792 - self.status =
793 - format!("Library deleted ({removed} samples removed)");
794 - } else {
795 - self.status = "Library deleted".to_string();
796 - }
797 - self.import_wf.import_mode = ImportMode::None;
554 + self.on_cleanup_complete(removed, errors);
798 555 return true;
799 556 }
800 557
@@ -988,15 +745,6 @@ impl BrowserState {
988 745 }
989 746 }
990 747
991 - /// Cancel the current analysis and restart it with the same parameters.
992 - pub fn retry_analysis(&mut self) {
993 - self.cancel_analysis();
994 - let hashes = std::mem::take(&mut self.import_wf.last_analysis_hashes);
995 - if let Some(config) = self.import_wf.last_analysis_config.take()
996 - && !hashes.is_empty() {
997 - self.run_analysis(hashes, config);
998 - }
999 - }
1000 748
1001 749 /// Apply user-entered tags to each imported folder's samples, then start analysis.
1002 750 pub fn apply_folder_tags(&mut self) {
@@ -1134,600 +882,4 @@ impl BrowserState {
1134 882 self.refresh_contents();
1135 883 }
1136 884
1137 - // --- Export ---
1138 -
1139 - /// Begin the export workflow: collect items from the current VFS/selection and show config.
1140 - pub fn start_export_flow(&mut self, node_ids: Option<Vec<NodeId>>) {
1141 - let Some(vfs_id) = self.current_vfs_id() else {
1142 - self.status = "No VFS available".to_string();
1143 - return;
1144 - };
1145 -
1146 - let mut items = if let Some(ids) = node_ids {
1147 - // Export specific selected items: collect subtrees for each
1148 - let mut all_items = Vec::new();
1149 - for id in ids {
1150 - let node = match self.backend.get_node(id) {
1151 - Ok(n) => n,
1152 - Err(_) => continue,
1153 - };
1154 - match node.node_type {
1155 - NodeType::Directory => {
1156 - if let Ok(sub_items) =
1157 - self.backend.collect_export_items(vfs_id, Some(id))
1158 - {
1159 - all_items.extend(sub_items);
1160 - }
1161 - }
1162 - NodeType::Sample => {
1163 - if let Some(hash) = &node.sample_hash {
1164 - let ext = self.backend.sample_extension(hash)
1165 - .unwrap_or_default();
1166 - all_items.push(audiofiles_core::export::ExportItem {
1167 - hash: hash.clone(),
1168 - ext,
1169 - relative_path: std::path::PathBuf::from(&node.name),
1170 - name: node.name.clone(),
1171 - bpm: None,
1172 - musical_key: None,
1173 - classification: None,
1174 - duration: None,
1175 - tags: Vec::new(),
1176 - source_path: None,
1177 - });
1178 - }
1179 - }
1180 - }
1181 - }
1182 - all_items
1183 - } else if self.collections_ui.active_collection.is_some() {
1184 - // Export collection contents
1185 - self.nav.contents.iter().filter_map(|node| {
1186 - let hash = node.node.sample_hash.as_ref()?;
1187 - let ext = self.backend.sample_extension(hash).unwrap_or_default();
1188 - Some(audiofiles_core::export::ExportItem {
1189 - hash: hash.clone(),
1190 - ext,
1191 - relative_path: std::path::PathBuf::from(&node.node.name),
1192 - name: node.node.name.clone(),
1193 - bpm: node.bpm,
1194 - musical_key: node.musical_key.clone(),
1195 - classification: node.classification.clone(),
1196 - duration: node.duration,
1197 - tags: node.tags.clone(),
1198 - source_path: None,
1199 - })
1200 - }).collect()
1201 - } else {
1202 - // Export entire current VFS subtree
1203 - self.backend.collect_export_items(vfs_id, self.nav.current_dir)
1204 - .unwrap_or_default()
1205 - };
1206 -
1207 - super::log_backend_err("enrich_export_with_tags", self.backend.enrich_export_with_tags(&mut items));
1208 -
1209 - if items.is_empty() {
1210 - self.status = "No samples to export".to_string();
1211 - return;
1212 - }
1213 -
1214 - let default_dest = dirs::download_dir()
1215 - .or_else(dirs::home_dir)
1216 - .unwrap_or_else(|| std::path::PathBuf::from("."))
1217 - .join("audiofiles Export");
1218 -
1219 - let available_profiles = self.backend.list_device_profiles().unwrap_or_default();
1220 -
1221 - self.import_wf.import_mode = ImportMode::ConfigureExport {
1222 - items,
1223 - config: audiofiles_core::export::ExportConfig {
1224 - format: audiofiles_core::export::ExportFormat::Original,
1225 - sample_rate: None,
1226 - bit_depth: None,
1227 - channels: audiofiles_core::export::ExportChannels::Original,
1228 - naming_pattern: None,
1229 - flatten: false,
1230 - metadata_sidecar: false,
1231 - destination: default_dest,
1232 - device_profile: None,
1233 - naming_rules: None,
1234 - max_file_size_bytes: None,
1235 - name_overrides: None,
1236 - },
1237 - available_profiles,
1238 - };
1239 - }
1240 -
1241 - /// Spawn the export worker and start processing.
1242 - pub fn run_export(&mut self, items: Vec<audiofiles_core::export::ExportItem>, config: audiofiles_core::export::ExportConfig) {
1243 - // Stash destination so the cancel-acknowledgement screen can name it
1244 - // if the user cancels mid-export (C-3). The Exporting variant doesn't
1245 - // carry destination — it's consumed by the worker — but the path is
1246 - // still useful in the UI for "files already written remain at <path>".
1247 - self.import_wf.last_export_destination = Some(config.destination.clone());
1248 - self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
1249 - super::log_backend_err("start_export", self.backend.start_export(items, config));
1250 -
1251 - self.import_wf.import_mode = ImportMode::Exporting {
1252 - completed: 0,
1253 - total: 0,
1254 - current_name: String::new(),
1255 - };
1256 - }
1257 -
1258 - /// Cancel the running cleanup and return to idle state.
1259 - pub fn cancel_cleanup(&mut self) {
1260 - let _ = self.backend.cancel_cleanup();
1261 - self.import_wf.import_mode = ImportMode::None;
1262 - self.refresh_vfs_list();
1263 - self.status = "Cleanup cancelled".to_string();
1264 - }
1265 -
1266 - /// Cancel the running export. Lands in the acknowledgement screen (C-3)
1267 - /// when meaningful progress has happened, surfacing the destination folder
1268 - /// so the user knows where partial files may sit. Falls through to `None`
1269 - /// when no items have been written yet.
1270 - pub fn cancel_export(&mut self) {
1271 - let progress = match &self.import_wf.import_mode {
1272 - ImportMode::Exporting { completed, total, .. } if *total > 0 => {
1273 - Some((*completed, *total))
1274 - }
1275 - _ => None,
1276 - };
1277 - let _ = self.backend.cancel_export();
1278 - self.status = "Export cancelled".to_string();
1279 - self.import_wf.import_mode = match progress {
1280 - Some((completed, total)) => ImportMode::OperationCancelled {
1281 - kind: crate::state::CancelKind::Export,
1282 - completed,
1283 - total,
1284 - destination: self.import_wf.last_export_destination.clone(),
1285 - },
1286 - None => ImportMode::None,
1287 - };
1288 - }
1289 -
1290 - // --- Edit operations (floating editor window) ---
1291 -
1292 - /// Open the floating sample editor for the given hash.
1293 - pub fn open_edit_window(&mut self, hash: &str) {
1294 - // Load analysis for total frames and result mode preference
1295 - let analysis = self.backend.get_analysis(hash).ok().flatten();
1296 - let total_frames = analysis.as_ref()
1297 - .map(|a| (a.duration * a.sample_rate as f64) as usize)
1298 - .unwrap_or(0);
1299 -
1300 - self.edit.hash = Some(hash.to_string());
1301 - self.edit.show_window = true;
1302 -
1303 - // Reset all params to defaults
1304 - self.edit.trim_start = 0.0;
1305 - self.edit.trim_end = 1.0;
1306 - self.edit.total_frames = total_frames;
1307 - self.edit.gain_db = 0.0;
1308 - self.edit.norm_peak = true;
1309 - self.edit.norm_target = -0.1;
1310 - self.edit.fade_in = true;
1311 - self.edit.fade_duration_ms = 100.0;
1312 - self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear;
1313 -
Lines truncated
@@ -77,6 +77,9 @@ use crate::preview::PreviewPlayback;
77 77 mod classifier;
78 78 mod navigation;
79 79 pub mod import_workflow;
80 + mod analysis_workflow;
81 + mod export_workflow;
82 + mod edit_workflow;
80 83 mod bulk_ops;
81 84 mod forge;
82 85 mod library;