max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+355 insertions,
-3 deletions
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | use crate::state::{BrowserState, ImportMode}; | |
| 6 | 6 | use crate::ui::{ | |
| 7 | 7 | detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel, | |
| 8 | - | import_screens, instrument_panel, overlays, sidebar, theme, toolbar, | |
| 8 | + | import_screens, instrument_panel, layout_strip, overlays, sidebar, theme, toolbar, | |
| 9 | 9 | }; | |
| 10 | 10 | use audiofiles_core::vfs::NodeType; | |
| 11 | 11 | ||
| @@ -200,6 +200,15 @@ | |||
| 200 | 200 | footer::draw_footer(ui, &ctx, state); | |
| 201 | 201 | }); | |
| 202 | 202 | ||
| 203 | + | // Blob-layout migration strip. Declared after the footer so it stacks *above* | |
| 204 | + | // it, and only while a migration is actually running, so the app has no extra | |
| 205 | + | // furniture in the normal case. | |
| 206 | + | if state.layout_migration.is_some() { | |
| 207 | + | egui::Panel::bottom("layout_migration_strip").show(ui, |ui| { | |
| 208 | + | layout_strip::draw_layout_strip(ui, state); | |
| 209 | + | }); | |
| 210 | + | } | |
| 211 | + | ||
| 203 | 212 | // Floating MIDI/instrument window | |
| 204 | 213 | if state.preview.show_midi_window { | |
| 205 | 214 | instrument_panel::draw_midi_window(&ctx, state); |
| @@ -115,6 +115,22 @@ | |||
| 115 | 115 | errors: usize, | |
| 116 | 116 | }, | |
| 117 | 117 | ||
| 118 | + | // Blob-layout migration events (flat store root -> hash-prefix shards) | |
| 119 | + | LayoutProgress { | |
| 120 | + | completed: usize, | |
| 121 | + | total: usize, | |
| 122 | + | }, | |
| 123 | + | LayoutComplete { | |
| 124 | + | /// Blobs relocated into a shard directory. | |
| 125 | + | moved: usize, | |
| 126 | + | /// Blobs that could not be relocated. | |
| 127 | + | errors: usize, | |
| 128 | + | /// Stopped early on request. The vault stays resolvable and resumable. | |
| 129 | + | cancelled: bool, | |
| 130 | + | /// The vault is fully sharded now. | |
| 131 | + | completed: bool, | |
| 132 | + | }, | |
| 133 | + | ||
| 118 | 134 | // Loose-files maintenance events | |
| 119 | 135 | LooseFilesIntegrity { | |
| 120 | 136 | missing: usize, | |
| @@ -910,6 +926,13 @@ | |||
| 910 | 926 | /// Cancel a running cleanup. | |
| 911 | 927 | fn cancel_cleanup(&self) -> BackendResult<()>; | |
| 912 | 928 | ||
| 929 | + | /// Cancel a running blob-layout migration. | |
| 930 | + | /// | |
| 931 | + | /// There is no matching `start_`: the migration auto-starts at vault open when | |
| 932 | + | /// flat blobs are present, and the sweep is resumable, so cancelling only | |
| 933 | + | /// defers the remainder to the next open rather than abandoning it. | |
| 934 | + | fn cancel_layout_migration(&self) -> BackendResult<()>; | |
| 935 | + | ||
| 913 | 936 | /// Record an edit in the edit_history table. | |
| 914 | 937 | fn record_edit_history( | |
| 915 | 938 | &self, |
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | ||
| 9 | 9 | use super::{ | |
| 10 | 10 | AnalysisConfig, Arc, BrowserState, FolderTagEntry, ImportMode, ImportStrategy, | |
| 11 | - | ImportStrategyDesc, ImportedFolder, | |
| 11 | + | ImportStrategyDesc, ImportedFolder, LayoutMigrationStatus, | |
| 12 | 12 | }; | |
| 13 | 13 | use crate::backend::BackendEvent; | |
| 14 | 14 | ||
| @@ -289,6 +289,9 @@ | |||
| 289 | 289 | BackendEvent::CleanupProgress { .. } | BackendEvent::CleanupComplete { .. } => { | |
| 290 | 290 | self.handle_cleanup_event(event) | |
| 291 | 291 | } | |
| 292 | + | BackendEvent::LayoutProgress { .. } | BackendEvent::LayoutComplete { .. } => { | |
| 293 | + | self.handle_layout_event(&event) | |
| 294 | + | } | |
| 292 | 295 | BackendEvent::LooseFilesIntegrity { .. } | |
| 293 | 296 | | BackendEvent::LooseFilesRelocated { .. } | |
| 294 | 297 | | BackendEvent::LooseFilesPurged { .. } | |
| @@ -500,6 +503,42 @@ | |||
| 500 | 503 | false | |
| 501 | 504 | } | |
| 502 | 505 | ||
| 506 | + | /// Blob-layout migration events: progress and completion. | |
| 507 | + | /// | |
| 508 | + | /// Never returns `true`. The terminal handlers end the poll batch so they can | |
| 509 | + | /// refresh library state, but relocating a blob changes no row and no query | |
| 510 | + | /// result, so there is nothing to refresh and no reason to cut the batch short. | |
| 511 | + | /// Takes a reference, unlike its sibling handlers: every field of these two | |
| 512 | + | /// events is `Copy`, so there is nothing to consume. | |
| 513 | + | pub(super) fn handle_layout_event(&mut self, event: &BackendEvent) -> bool { | |
| 514 | + | match *event { | |
| 515 | + | BackendEvent::LayoutProgress { completed, total } => { | |
| 516 | + | self.layout_migration = Some(LayoutMigrationStatus { completed, total }); | |
| 517 | + | } | |
| 518 | + | BackendEvent::LayoutComplete { | |
| 519 | + | moved, | |
| 520 | + | errors, | |
| 521 | + | cancelled, | |
| 522 | + | completed, | |
| 523 | + | } => { | |
| 524 | + | self.layout_migration = None; | |
| 525 | + | if errors > 0 { | |
| 526 | + | self.status = format!( | |
| 527 | + | "Storage migration finished with {errors} problem(s), {moved} file(s) moved (see the log)" | |
| 528 | + | ); | |
| 529 | + | } else if cancelled { | |
| 530 | + | self.status = format!( | |
| 531 | + | "Storage migration paused after {moved} file(s), resumes on next open" | |
| 532 | + | ); | |
| 533 | + | } else if completed && moved > 0 { | |
| 534 | + | self.status = format!("Storage migration complete ({moved} file(s) moved)"); | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | _ => unreachable!("handle_layout_event: unrelated event"), | |
| 538 | + | } | |
| 539 | + | false | |
| 540 | + | } | |
| 541 | + | ||
| 503 | 542 | /// Loose-files maintenance events: integrity, relocate, purge, failures. | |
| 504 | 543 | fn handle_loose_files_event(&mut self, event: BackendEvent) -> bool { | |
| 505 | 544 | match event { |
| @@ -137,6 +137,29 @@ | |||
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | + | /// Progress of the background blob-layout migration. | |
| 141 | + | /// | |
| 142 | + | /// `total` is the blob count enumerated at the start of the current pass, not the | |
| 143 | + | /// whole library: the sweep is resumable, so a resumed pass counts only what is | |
| 144 | + | /// left. That makes the bar honest about the work in front of it rather than | |
| 145 | + | /// showing a fraction of a total the user cannot see. | |
| 146 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 147 | + | pub struct LayoutMigrationStatus { | |
| 148 | + | pub completed: usize, | |
| 149 | + | pub total: usize, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | impl LayoutMigrationStatus { | |
| 153 | + | /// Completion as 0.0..=1.0, guarding the empty-pass division. | |
| 154 | + | #[must_use] | |
| 155 | + | pub fn fraction(self) -> f32 { | |
| 156 | + | if self.total == 0 { | |
| 157 | + | return 0.0; | |
| 158 | + | } | |
| 159 | + | (self.completed as f32 / self.total as f32).clamp(0.0, 1.0) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 140 | 163 | /// GUI-thread-only state, passed as egui user_state T. | |
| 141 | 164 | pub struct BrowserState { | |
| 142 | 165 | pub data_dir: PathBuf, | |
| @@ -148,6 +171,14 @@ | |||
| 148 | 171 | /// applied on the GUI thread via `draw_browser`'s poll. | |
| 149 | 172 | pub dialogs: crate::ui::dialog::DialogManager, | |
| 150 | 173 | pub status: String, | |
| 174 | + | /// Progress of the background blob-layout migration, or `None` when none is | |
| 175 | + | /// running. Drives a cancellable strip above the footer. | |
| 176 | + | /// | |
| 177 | + | /// Deliberately not an `ImportMode` like the cleanup screen: the migration | |
| 178 | + | /// auto-starts at vault open, so a modal or full-screen mode would seize the | |
| 179 | + | /// app for something the user did not ask for and does not need to watch. The | |
| 180 | + | /// library stays fully usable while it runs, because reads resolve both layouts. | |
| 181 | + | pub layout_migration: Option<LayoutMigrationStatus>, | |
| 151 | 182 | /// When the current `status` message was posted. Drives the footer's | |
| 152 | 183 | /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means | |
| 153 | 184 | /// the status was set without going through `post_status` (legacy direct | |
| @@ -426,6 +457,7 @@ | |||
| 426 | 457 | }, | |
| 427 | 458 | dialogs: crate::ui::dialog::DialogManager::default(), | |
| 428 | 459 | status: String::new(), | |
| 460 | + | layout_migration: None, | |
| 429 | 461 | status_set_at: None, | |
| 430 | 462 | detail: DetailUiState { | |
| 431 | 463 | detail_visible, |
| @@ -3221,3 +3221,121 @@ | |||
| 3221 | 3221 | ); | |
| 3222 | 3222 | } | |
| 3223 | 3223 | } | |
| 3224 | + | ||
| 3225 | + | /// Blob-layout migration strip: the state the auto-started sweep drives. | |
| 3226 | + | mod layout_migration_strip { | |
| 3227 | + | use super::*; | |
| 3228 | + | use crate::backend::BackendEvent; | |
| 3229 | + | ||
| 3230 | + | #[test] | |
| 3231 | + | fn layout_migration_fraction_guards_an_empty_pass() { | |
| 3232 | + | // A pass that enumerated nothing must not divide by zero, and a resumed | |
| 3233 | + | // pass must not report over 100% if a count ever disagrees. | |
| 3234 | + | assert!( | |
| 3235 | + | (LayoutMigrationStatus { | |
| 3236 | + | completed: 0, | |
| 3237 | + | total: 0 | |
| 3238 | + | } | |
| 3239 | + | .fraction() | |
| 3240 | + | - 0.0) | |
| 3241 | + | .abs() | |
| 3242 | + | < f32::EPSILON | |
| 3243 | + | ); | |
| 3244 | + | assert!( | |
| 3245 | + | (LayoutMigrationStatus { | |
| 3246 | + | completed: 5, | |
| 3247 | + | total: 10 | |
| 3248 | + | } | |
| 3249 | + | .fraction() | |
| 3250 | + | - 0.5) | |
| 3251 | + | .abs() | |
| 3252 | + | < f32::EPSILON | |
| 3253 | + | ); | |
| 3254 | + | assert!( | |
| 3255 | + | (LayoutMigrationStatus { | |
| 3256 | + | completed: 99, | |
| 3257 | + | total: 10 | |
| 3258 | + | } | |
| 3259 | + | .fraction() | |
| 3260 | + | - 1.0) | |
| 3261 | + | .abs() | |
| 3262 | + | < f32::EPSILON | |
| 3263 | + | ); | |
| 3264 | + | } | |
| 3265 | + | ||
| 3266 | + | #[test] | |
| 3267 | + | fn layout_progress_shows_the_strip_and_completion_clears_it() { | |
| 3268 | + | let (mut state, _dir) = make_state(); | |
| 3269 | + | assert!( | |
| 3270 | + | state.layout_migration.is_none(), | |
| 3271 | + | "no strip when nothing runs" | |
| 3272 | + | ); | |
| 3273 | + | ||
| 3274 | + | let stop = state.handle_layout_event(&BackendEvent::LayoutProgress { | |
| 3275 | + | completed: 256, | |
| 3276 | + | total: 1024, | |
| 3277 | + | }); | |
| 3278 | + | assert!( | |
| 3279 | + | !stop, | |
| 3280 | + | "a relocation changes no row, so it must not cut the poll batch" | |
| 3281 | + | ); | |
| 3282 | + | assert_eq!( | |
| 3283 | + | state.layout_migration, | |
| 3284 | + | Some(LayoutMigrationStatus { | |
| 3285 | + | completed: 256, | |
| 3286 | + | total: 1024 | |
| 3287 | + | }) | |
| 3288 | + | ); | |
| 3289 | + | ||
| 3290 | + | state.handle_layout_event(&BackendEvent::LayoutComplete { | |
| 3291 | + | moved: 1024, | |
| 3292 | + | errors: 0, | |
| 3293 | + | cancelled: false, | |
| 3294 | + | completed: true, | |
| 3295 | + | }); | |
| 3296 | + | assert!( | |
| 3297 | + | state.layout_migration.is_none(), | |
| 3298 | + | "strip clears on completion" | |
| 3299 | + | ); | |
| 3300 | + | assert!( | |
| 3301 | + | state.status.contains("1024"), | |
| 3302 | + | "the outcome is reported, got: {}", | |
| 3303 | + | state.status | |
| 3304 | + | ); | |
| 3305 | + | } | |
| 3306 | + | ||
| 3307 | + | #[test] | |
| 3308 | + | fn cancelled_layout_migration_says_it_will_resume() { | |
| 3309 | + | let (mut state, _dir) = make_state(); | |
| 3310 | + | state.handle_layout_event(&BackendEvent::LayoutComplete { | |
| 3311 | + | moved: 40, | |
| 3312 | + | errors: 0, | |
| 3313 | + | cancelled: true, | |
| 3314 | + | completed: false, | |
| 3315 | + | }); | |
| 3316 | + | assert!(state.layout_migration.is_none()); | |
| 3317 | + | // A pause is not a failure and must not read as one: the remaining work is | |
| 3318 | + | // picked up on the next open, and the message has to say so. | |
| 3319 | + | assert!( | |
| 3320 | + | state.status.contains("resumes"), | |
| 3321 | + | "a paused migration must promise resumption, got: {}", | |
| 3322 | + | state.status | |
| 3323 | + | ); | |
| 3324 | + | } | |
| 3325 | + | ||
| 3326 | + | #[test] | |
| 3327 | + | fn layout_migration_errors_are_reported_not_swallowed() { | |
| 3328 | + | let (mut state, _dir) = make_state(); | |
| 3329 | + | state.handle_layout_event(&BackendEvent::LayoutComplete { | |
| 3330 | + | moved: 3, | |
| 3331 | + | errors: 2, | |
| 3332 | + | cancelled: false, | |
| 3333 | + | completed: false, | |
| 3334 | + | }); | |
| 3335 | + | assert!( | |
| 3336 | + | state.status.contains('2'), | |
| 3337 | + | "the error count must reach the user, got: {}", | |
| 3338 | + | state.status | |
| 3339 | + | ); | |
| 3340 | + | } | |
| 3341 | + | } |
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | pub mod forge_panel; | |
| 14 | 14 | pub mod import_screens; | |
| 15 | 15 | pub mod instrument_panel; | |
| 16 | + | pub mod layout_strip; | |
| 16 | 17 | pub mod overlays; | |
| 17 | 18 | pub mod settings_panel; | |
| 18 | 19 | pub mod sidebar; |
| @@ -66,6 +66,7 @@ | |||
| 66 | 66 | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 67 | 67 | export_worker: Mutex<Option<ExportHandle>>, | |
| 68 | 68 | cleanup_worker: Mutex<Option<CleanupHandle>>, | |
| 69 | + | layout_worker: Mutex<Option<crate::layout_migration::LayoutHandle>>, | |
| 69 | 70 | loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>, | |
| 70 | 71 | edit_worker: Mutex<Option<EditWorkerHandle>>, | |
| 71 | 72 | forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>, | |
| @@ -97,7 +98,12 @@ | |||
| 97 | 98 | Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"), | |
| 98 | 99 | Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"), | |
| 99 | 100 | } | |
| 100 | - | Self { | |
| 101 | + | // Does this vault still hold blobs in the pre-2026-07-29 flat layout? One | |
| 102 | + | // `read_dir` (see `migration_pending`), so it is cheap enough to ask on | |
| 103 | + | // every open, and it reads the filesystem rather than trusting the recorded | |
| 104 | + | // layout so an interrupted sweep is picked back up. | |
| 105 | + | let layout_pending = crate::layout_migration::migration_pending(&db, store.root()); | |
| 106 | + | let backend = Self { | |
| 101 | 107 | db: Mutex::new(db), | |
| 102 | 108 | store, | |
| 103 | 109 | data_dir, | |
| @@ -105,6 +111,7 @@ | |||
| 105 | 111 | analysis_worker: Mutex::new(None), | |
| 106 | 112 | export_worker: Mutex::new(None), | |
| 107 | 113 | cleanup_worker: Mutex::new(None), | |
| 114 | + | layout_worker: Mutex::new(None), | |
| 108 | 115 | loose_files_worker: Mutex::new(None), | |
| 109 | 116 | edit_worker: Mutex::new(None), | |
| 110 | 117 | forge_worker: Mutex::new(None), | |
| @@ -123,7 +130,38 @@ | |||
| 123 | 130 | ); | |
| 124 | 131 | audiofiles_rhai::registry::PluginRegistry::new() | |
| 125 | 132 | }), | |
| 133 | + | }; | |
| 134 | + | // Auto-start, no prompt. The migration is pure relocation of | |
| 135 | + | // content-addressed data with nothing for the user to decide, and it only | |
| 136 | + | // gets slower the longer a library grows, so asking would be a question | |
| 137 | + | // whose right answer is always yes. It runs on its own thread and reports | |
| 138 | + | // through a cancellable progress strip, so it stays visible and | |
| 139 | + | // interruptible without blocking the app. | |
| 140 | + | if layout_pending { | |
| 141 | + | match backend.spawn_layout_migration() { | |
| 142 | + | Ok(()) => tracing::info!("blob layout migration started"), | |
| 143 | + | Err(e) => tracing::warn!("blob layout migration failed to start: {e}"), | |
| 144 | + | } | |
| 126 | 145 | } | |
| 146 | + | backend | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Spawn (or restart) the layout-migration worker and set it going. | |
| 150 | + | /// | |
| 151 | + | /// Replaces any existing worker, cancelling it first, so two sweeps can never | |
| 152 | + | /// race over the same blobs. | |
| 153 | + | fn spawn_layout_migration(&self) -> BackendResult<()> { | |
| 154 | + | use crate::layout_migration::LayoutCommand; | |
| 155 | + | if let Some(worker) = self.layout_worker.lock().take() { | |
| 156 | + | worker.send(LayoutCommand::Cancel); | |
| 157 | + | } | |
| 158 | + | let db_path = self.data_dir.join("audiofiles.db"); | |
| 159 | + | let handle = | |
| 160 | + | crate::layout_migration::spawn_layout_worker(db_path, self.store.root().to_path_buf()) | |
| 161 | + | .map_err(|e| BackendError::Other(format!("failed to spawn layout worker: {e}")))?; | |
| 162 | + | handle.send(LayoutCommand::Migrate); | |
| 163 | + | *self.layout_worker.lock() = Some(handle); | |
| 164 | + | Ok(()) | |
| 127 | 165 | } | |
| 128 | 166 | ||
| 129 | 167 | /// Access the store (needed for preview decode path in BrowserState). |
| @@ -204,6 +204,13 @@ | |||
| 204 | 204 | Ok(()) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | + | fn cancel_layout_migration(&self) -> BackendResult<()> { | |
| 208 | + | if let Some(worker) = self.layout_worker.lock().take() { | |
| 209 | + | worker.send(crate::layout_migration::LayoutCommand::Cancel); | |
| 210 | + | } | |
| 211 | + | Ok(()) | |
| 212 | + | } | |
| 213 | + | ||
| 207 | 214 | fn record_edit_history( | |
| 208 | 215 | &self, | |
| 209 | 216 | source_hash: &str, | |
| @@ -387,6 +394,29 @@ | |||
| 387 | 394 | } | |
| 388 | 395 | } | |
| 389 | 396 | ||
| 397 | + | if let Some(ref worker) = *self.layout_worker.lock() { | |
| 398 | + | use crate::layout_migration::LayoutEvent as Lay; | |
| 399 | + | while let Some(event) = worker.try_recv() { | |
| 400 | + | events.push(match event { | |
| 401 | + | Lay::Progress { completed, total } => { | |
| 402 | + | BackendEvent::LayoutProgress { completed, total } | |
| 403 | + | } | |
| 404 | + | Lay::Complete { | |
| 405 | + | moved, | |
| 406 | + | errors, | |
| 407 | + | cancelled, | |
| 408 | + | completed, | |
| 409 | + | .. | |
| 410 | + | } => BackendEvent::LayoutComplete { | |
| 411 | + | moved, | |
| 412 | + | errors, | |
| 413 | + | cancelled, | |
| 414 | + | completed, | |
| 415 | + | }, | |
| 416 | + | }); | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 390 | 420 | // Poll loose-files worker | |
| 391 | 421 | if let Some(ref worker) = *self.loose_files_worker.lock() { | |
| 392 | 422 | use crate::loose_files_worker::LooseFilesEvent as Lfe; |
| @@ -1,0 +1,62 @@ | |||
| 1 | + | //! Blob-layout migration strip: a thin, cancellable progress row above the footer. | |
| 2 | + | //! | |
| 3 | + | //! Shown only while the background migration is relocating blobs from the legacy | |
| 4 | + | //! flat store root into hash-prefix shards. Deliberately a strip and not a modal or | |
| 5 | + | //! a full-screen mode: the migration auto-starts at vault open rather than being | |
| 6 | + | //! asked for, and the library stays completely usable while it runs, because reads | |
| 7 | + | //! resolve both layouts. Seizing the window for it would be the wrong trade. | |
| 8 | + | //! | |
| 9 | + | //! Cancelling is honest here. The sweep is resumable and records nothing until a | |
| 10 | + | //! pass verifies the root is clean, so stopping defers the remainder to the next | |
| 11 | + | //! open instead of abandoning or half-applying it. | |
| 12 | + | ||
| 13 | + | use egui; | |
| 14 | + | ||
| 15 | + | use super::theme; | |
| 16 | + | use crate::state::BrowserState; | |
| 17 | + | ||
| 18 | + | /// Draw the migration strip. No-op when no migration is running. | |
| 19 | + | pub fn draw_layout_strip(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 20 | + | let Some(progress) = state.layout_migration else { | |
| 21 | + | return; | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | ui.add_space(theme::space::hair()); | |
| 25 | + | ui.horizontal(|ui| { | |
| 26 | + | ui.label( | |
| 27 | + | egui::RichText::new("Optimising storage layout") | |
| 28 | + | .small() | |
| 29 | + | .color(theme::content_secondary()), | |
| 30 | + | ); | |
| 31 | + | ui.label( | |
| 32 | + | egui::RichText::new(format!("{} / {} files", progress.completed, progress.total)) | |
| 33 | + | .small() | |
| 34 | + | .color(theme::content_muted()), | |
| 35 | + | ); | |
| 36 | + | ||
| 37 | + | // The button is laid out first from the right so the bar takes the | |
| 38 | + | // remaining width instead of pushing the button off the row. | |
| 39 | + | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { | |
| 40 | + | if ui | |
| 41 | + | .small_button("Pause") | |
| 42 | + | .on_hover_text("Stop for now. Resumes the next time this vault opens.") | |
| 43 | + | .clicked() | |
| 44 | + | { | |
| 45 | + | if let Err(e) = state.backend.cancel_layout_migration() { | |
| 46 | + | tracing::warn!("failed to cancel layout migration: {e}"); | |
| 47 | + | } | |
| 48 | + | // Clear the strip immediately rather than waiting for the | |
| 49 | + | // worker's terminal event: the click has to feel like it did | |
| 50 | + | // something, and the Complete event that follows sets the status | |
| 51 | + | // line and would clear this anyway. | |
| 52 | + | state.layout_migration = None; | |
| 53 | + | } | |
| 54 | + | ui.add( | |
| 55 | + | egui::ProgressBar::new(progress.fraction()) | |
| 56 | + | .desired_height(theme::space::peer()) | |
| 57 | + | .show_percentage(), | |
| 58 | + | ); | |
| 59 | + | }); | |
| 60 | + | }); | |
| 61 | + | ui.add_space(theme::space::hair()); | |
| 62 | + | } |