1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::mpsc::{self, Receiver, TryRecvError};
7use std::time::{Duration, Instant};
8
9use chrono::Utc;
10use ratatui::layout::Rect;
11use ratatui::widgets::{ListState, TableState};
12use unicode_segmentation::UnicodeSegmentation;
13
14use crate::due;
15use crate::form::{CategoryForm, TaskDraft, TaskForm};
16use crate::image::ImageStore;
17use crate::model::{
18 ALL_CATEGORY, Category, Label, LabelColor, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN,
19 MAX_LABEL_COUNT, MAX_LABEL_NAME_LEN, MAX_TASK_COUNT, MAX_TITLE_LEN, Task, caseless_contains,
20 caseless_key, category_name_key, task_text_contains,
21};
22use crate::settings::{LaunchState, Settings};
23use crate::store::{
24 Attachment, CategoryPatch, LabelPatch, RelativePosition, Store, StoreData, StoreError,
25 TaskPatch,
26};
27use crate::text_input::TextInput;
28use crate::theme::Theme;
29use crate::update::{CheckFailure, CheckResponse};
30use crate::update_state::{
31 AutomaticClaim, FAILURE_RETRY_SECONDS, LEASE_SECONDS, UpdateLease, UpdateState,
32 UpdateStateStore,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Focus {
37 Sidebar,
38 Tasks,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43 Normal,
44 Slash,
46 Search,
48 TaskForm,
50 CategoryForm,
52 Labels,
54 Help,
55 Settings,
56 Welcome,
57 WhatsNew,
58}
59
60impl Mode {
61 pub fn command_bar_focused(self) -> bool {
63 matches!(self, Mode::Slash | Mode::Search)
64 }
65
66 pub fn is_overlay(self) -> bool {
68 matches!(
69 self,
70 Mode::Help
71 | Mode::Settings
72 | Mode::Welcome
73 | Mode::WhatsNew
74 | Mode::TaskForm
75 | Mode::CategoryForm
76 | Mode::Labels
77 )
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MessageKind {
83 Info,
84 Error,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum MessageLifetime {
89 Brief,
90 Standard,
91 Long,
92}
93
94impl MessageLifetime {
95 const fn duration(self) -> Duration {
96 match self {
97 Self::Brief => Duration::from_secs(2),
98 Self::Standard => Duration::from_secs(4),
99 Self::Long => Duration::from_secs(8),
100 }
101 }
102}
103
104pub struct Message {
105 pub text: String,
106 pub kind: MessageKind,
107 pub until: Instant,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Confirm {
115 DeleteTask(String),
117 DeleteCategory(String),
119 Purge(Vec<String>),
121 DiscardTask(Option<String>),
123 DiscardCategory(Option<String>),
124 DeleteLabel(String),
126 Quit,
128}
129
130const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
132const MAX_SLASH_INPUT_LEN: usize = 4096;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum UpdateJobKind {
137 Automatic,
138 Install,
139}
140
141enum UpdateOutcome {
142 Automatic(CheckResponse),
143 UpToDate {
144 info: crate::update::CheckResult,
145 etag: Option<String>,
146 },
147 Installed {
148 result: crate::update::InstallResult,
149 info: crate::update::CheckResult,
150 etag: Option<String>,
151 },
152 InstallFailed {
153 message: String,
154 info: crate::update::CheckResult,
155 etag: Option<String>,
156 },
157}
158
159enum UpdateEvent {
160 DownloadProgress(crate::update::DownloadProgress),
161 Finished(Box<Result<UpdateOutcome, CheckFailure>>),
162}
163
164struct UpdateJob {
165 rx: Receiver<UpdateEvent>,
166 kind: UpdateJobKind,
167 lease: Option<UpdateLease>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum ArchiveJobKind {
172 Export,
173 Import,
174}
175
176impl ArchiveJobKind {
177 const fn name(self) -> &'static str {
178 match self {
179 Self::Export => "export",
180 Self::Import => "import",
181 }
182 }
183
184 const fn title(self) -> &'static str {
185 match self {
186 Self::Export => "Export",
187 Self::Import => "Import",
188 }
189 }
190}
191
192enum ArchiveOutcome {
193 Export(crate::archive::ExportSummary),
194 Import(crate::archive::ImportSummary),
195}
196
197enum ArchiveRequest {
198 Export,
199 Import(PathBuf),
200}
201
202impl ArchiveRequest {
203 const fn kind(&self) -> ArchiveJobKind {
204 match self {
205 Self::Export => ArchiveJobKind::Export,
206 Self::Import(_) => ArchiveJobKind::Import,
207 }
208 }
209}
210
211enum ArchiveEvent {
212 Progress(crate::archive::ArchiveProgress),
213 Finished(Result<ArchiveOutcome, crate::archive::ArchiveError>),
214}
215
216struct ArchiveJob {
217 rx: Receiver<ArchiveEvent>,
218 handle: std::thread::JoinHandle<()>,
219 kind: ArchiveJobKind,
220 control: Arc<crate::archive::ArchiveControl>,
221 progress: crate::archive::ArchiveProgress,
222 cancel_requested: bool,
223}
224
225struct UpdateNotice {
226 text: String,
227 available_version: Option<String>,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub(crate) enum UpdateActivity {
232 Checking,
233 Downloading(crate::update::DownloadProgress),
234}
235
236#[derive(Debug, Default, Clone)]
238pub struct Areas {
239 pub sidebar: Rect,
240 pub tasks: Rect,
241 pub command_bar: Rect,
243 pub preview: Rect,
245 pub flag_x: Option<u16>,
250 pub done_x: Option<u16>,
251 pub slash_menu: Rect,
253 pub slash_menu_start: usize,
255 pub label_hits: Vec<(usize, Rect)>,
257 pub label_name_input: Rect,
259 pub label_color_hits: Vec<(LabelColor, Rect)>,
260}
261
262#[derive(Debug, Clone)]
263pub struct LabelEditor {
264 pub editing_id: Option<String>,
265 pub name: TextInput,
266 pub color: LabelColor,
267 pub color_focused: bool,
268}
269
270impl LabelEditor {
271 fn new(editing_id: Option<String>, name: &str, color: LabelColor) -> Self {
272 Self {
273 editing_id,
274 name: TextInput::new(name, MAX_LABEL_NAME_LEN),
275 color,
276 color_focused: false,
277 }
278 }
279
280 pub(crate) fn move_color(&mut self, delta: isize) {
281 let len = LabelColor::SWATCHES.len();
282 let position = LabelColor::SWATCHES
283 .iter()
284 .position(|color| *color == self.color);
285 let next = match position {
286 Some(position) => (position as isize + delta).rem_euclid(len as isize) as usize,
287 None if delta.is_negative() => len - 1,
288 None => 0,
289 };
290 self.color = LabelColor::SWATCHES[next];
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub(crate) enum ClickTarget {
296 Sidebar,
297 Tasks,
298 Labels,
299}
300
301pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Task preview"];
302
303#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum TaskListRow {
307 Separator {
308 title: String,
309 },
310 Task(usize),
312}
313
314pub struct App {
315 store: Store,
316 version: String,
317 store_revision: u64,
318 pub tasks: Vec<Task>,
319 pub categories: Vec<Category>,
320 pub labels: Vec<Label>,
321 pub settings: Settings,
322 pub focus: Focus,
323 pub mode: Mode,
324 pub cat_index: usize,
326 pub task_index: usize,
328 pub cat_state: ListState,
331 pub task_state: TableState,
332 pub view: Vec<usize>,
334 pub list_rows: Vec<TaskListRow>,
337 pub searching: bool,
338 pub search_query: String,
339 pub input: TextInput,
340 pub slash_index: usize,
342 pub form: Option<TaskForm>,
344 pub category_form: Option<CategoryForm>,
346 pub label_index: usize,
348 pub label_editor: Option<LabelEditor>,
350 pub label_error: Option<String>,
351 labels_return_to_form: bool,
352 pub settings_index: usize,
353 pub help_scroll: usize,
355 pub message: Option<Message>,
356 pub pending: Option<(Confirm, Instant)>,
358 pub(crate) last_click: Option<(Instant, ClickTarget, usize)>,
360 pub should_quit: bool,
361 pub areas: Areas,
362 pub images: ImageStore,
364 pub(crate) attachments: Vec<Attachment>,
365 typeahead: String,
367 typeahead_at: Option<Instant>,
368 pub dirty: bool,
370 pub data_gen: u64,
372 cat_progress: Vec<(usize, usize)>,
374 pub preview_form: Option<TaskForm>,
376 preview_task_id: Option<String>,
377 preview_gen: u64,
378 task_edit_base: Option<Task>,
382 category_edit_base: Option<Category>,
383 update_job: Option<UpdateJob>,
385 archive_job: Option<ArchiveJob>,
388 quit_after_archive: bool,
391 update_state: Option<UpdateStateStore>,
393 next_update_state_poll_at: i64,
395 update_notice: Option<UpdateNotice>,
398 dismissed_update_version: Option<String>,
401 update_activity: Option<UpdateActivity>,
403 external_poll_failed: bool,
407}
408
409impl App {
410 pub fn new(version: &str) -> Result<Self, StoreError> {
411 Self::with_store_and_update_state(
412 version,
413 Store::open_default(None)?,
414 UpdateStateStore::open_default(),
415 )
416 }
417
418 pub fn with_store(version: &str, store: Store) -> Result<Self, StoreError> {
419 Self::with_store_and_update_state(version, store, UpdateStateStore::open_in_memory())
420 }
421
422 pub(crate) fn with_store_and_update_state(
423 version: &str,
424 store: Store,
425 update_state: Result<UpdateStateStore, StoreError>,
426 ) -> Result<Self, StoreError> {
427 let initial = store.snapshot()?;
428 let launch = if initial.settings.last_run_version.as_deref() == Some(version) {
432 LaunchState::Returning
433 } else {
434 let mut settings = initial.settings.clone();
435 settings.record_launch(version)
436 };
437 let snapshot = initial;
438 let StoreData {
439 revision,
440 categories: real_cats,
441 labels,
442 tasks,
443 settings,
444 attachments,
445 } = snapshot;
446 let mut categories = vec![Category::all_tasks()];
448 categories.extend(real_cats);
449 let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
450 images.set_attachments(&attachments);
451 let (update_state, update_state_error) = match update_state {
452 Ok(store) => (Some(store), None),
453 Err(error) => (None, Some(error.to_string())),
454 };
455
456 let mut app = Self {
457 store,
458 version: version.to_string(),
459 store_revision: revision,
460 tasks,
461 categories,
462 labels,
463 settings,
464 focus: Focus::Tasks,
465 mode: match launch {
466 LaunchState::FirstRun => Mode::Welcome,
467 LaunchState::Upgraded => Mode::WhatsNew,
468 LaunchState::Returning => Mode::Normal,
469 },
470 cat_index: 0,
471 task_index: 0,
472 cat_state: ListState::default(),
473 task_state: TableState::default(),
474 view: Vec::new(),
475 list_rows: Vec::new(),
476 searching: false,
477 search_query: String::new(),
478 input: TextInput::default(),
479 slash_index: 0,
480 form: None,
481 category_form: None,
482 label_index: 0,
483 label_editor: None,
484 label_error: None,
485 labels_return_to_form: false,
486 settings_index: 0,
487 help_scroll: 0,
488 message: None,
489 pending: None,
490 last_click: None,
491 should_quit: false,
492 areas: Areas::default(),
493 images,
494 attachments,
495 typeahead: String::new(),
496 typeahead_at: None,
497 dirty: true,
498 data_gen: 0,
499 cat_progress: Vec::new(),
500 preview_form: None,
501 preview_task_id: None,
502 preview_gen: 0,
503 task_edit_base: None,
504 category_edit_base: None,
505 update_job: None,
506 archive_job: None,
507 quit_after_archive: false,
508 update_state,
509 next_update_state_poll_at: 0,
510 update_notice: None,
511 dismissed_update_version: None,
512 update_activity: None,
513 external_poll_failed: false,
514 };
515 app.rebuild_view();
516 if let Some(error) = update_state_error {
517 app.error(format!("Automatic update checks unavailable: {error}"));
518 } else {
519 app.refresh_update_state(Utc::now().timestamp());
520 }
521 Ok(app)
522 }
523
524 pub(crate) fn record_launch(&mut self) -> Result<(), StoreError> {
529 let version = self.version.clone();
530 let launch = if self.settings.last_run_version.as_deref() == Some(version.as_str()) {
531 LaunchState::Returning
532 } else {
533 self.update_store(|data| Ok(data.settings.record_launch(&version)))?
534 };
535 self.mode = match launch {
536 LaunchState::FirstRun => Mode::Welcome,
537 LaunchState::Upgraded => Mode::WhatsNew,
538 LaunchState::Returning => Mode::Normal,
539 };
540 self.dirty = true;
541 Ok(())
542 }
543
544 pub fn data_dir(&self) -> &Path {
545 self.store.data_dir()
546 }
547
548 pub fn poll_external_changes(&mut self) -> bool {
552 let revision = match self.store.revision() {
553 Ok(revision) => revision,
554 Err(error) => {
555 return self.report_external_poll_error(format!(
556 "Could not check for external changes: {error}"
557 ));
558 }
559 };
560 if revision == self.store_revision
561 || self.form.is_some()
562 || self.category_form.is_some()
563 || self.mode == Mode::Labels
564 {
565 self.external_poll_failed = false;
566 return false;
567 }
568 match self.reload_store() {
569 Ok(()) => {
570 self.external_poll_failed = false;
571 true
572 }
573 Err(error) => self
574 .report_external_poll_error(format!("Could not reload external changes: {error}")),
575 }
576 }
577
578 fn report_external_poll_error(&mut self, message: String) -> bool {
579 if self.external_poll_failed {
580 return false;
581 }
582 self.external_poll_failed = true;
583 self.error(message);
584 true
585 }
586
587 fn reload_store(&mut self) -> Result<(), StoreError> {
588 let selected_category = self.current_category_id().to_string();
589 let selected_task = self.selected_task().map(|task| task.id.clone());
590 let snapshot = self.store.snapshot()?;
591 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
592 Ok(())
593 }
594
595 fn apply_snapshot(
596 &mut self,
597 snapshot: StoreData,
598 selected_category: &str,
599 selected_task: Option<&str>,
600 ) {
601 let StoreData {
602 revision,
603 categories,
604 labels,
605 tasks,
606 settings,
607 attachments,
608 } = snapshot;
609 self.store_revision = revision;
610 self.tasks = tasks;
611 self.labels = labels;
612 self.settings = settings;
613 self.attachments = attachments;
614 self.images.set_attachments(&self.attachments);
615 self.categories.clear();
616 self.categories.push(Category::all_tasks());
617 self.categories.extend(categories);
618 self.cat_index = self
619 .categories
620 .iter()
621 .position(|category| category.id == selected_category)
622 .unwrap_or(0);
623 self.cat_progress.clear();
624 self.data_gen = self.data_gen.wrapping_add(1);
625 self.invalidate_preview();
626 self.rebuild_view();
627 if let Some(id) = selected_task {
628 self.select_task_by_id(id);
629 }
630 self.dirty = true;
631 }
632
633 pub(crate) fn start_export_archive(&mut self) {
634 self.start_archive_worker(ArchiveRequest::Export);
635 }
636
637 pub(crate) fn start_import_archive(&mut self, path: PathBuf) {
638 self.start_archive_worker(ArchiveRequest::Import(path));
639 }
640
641 fn start_archive_worker(&mut self, request: ArchiveRequest) {
642 if let Some(active) = self.archive_job.as_ref() {
643 self.info(format!("An {} is already running", active.kind.name()));
644 return;
645 }
646
647 let kind = request.kind();
648 let data_dir = self.store.data_dir().to_path_buf();
649 let control = Arc::new(crate::archive::ArchiveControl::new());
650 let worker_control = Arc::clone(&control);
651 let (tx, rx) = mpsc::channel();
652 let thread_name = match kind {
653 ArchiveJobKind::Export => "mach-archive-export",
654 ArchiveJobKind::Import => "mach-archive-import",
655 };
656 let spawn = std::thread::Builder::new()
657 .name(thread_name.into())
658 .spawn(move || {
659 let result = (|| -> Result<ArchiveOutcome, crate::archive::ArchiveError> {
660 let mut store = Store::open(data_dir)?;
661 match request {
662 ArchiveRequest::Export => crate::archive::export_with_progress(
663 &store,
664 None,
665 &worker_control,
666 |progress| {
667 let _ = tx.send(ArchiveEvent::Progress(progress));
668 },
669 )
670 .map(ArchiveOutcome::Export),
671 ArchiveRequest::Import(path) => crate::archive::import_with_progress(
672 &mut store,
673 &path,
674 &worker_control,
675 |progress| {
676 let _ = tx.send(ArchiveEvent::Progress(progress));
677 },
678 )
679 .map(ArchiveOutcome::Import),
680 }
681 })();
682 let _ = tx.send(ArchiveEvent::Finished(result));
683 });
684
685 match spawn {
686 Ok(handle) => {
687 self.archive_job = Some(ArchiveJob {
688 rx,
689 handle,
690 kind,
691 control,
692 progress: crate::archive::ArchiveProgress::Preparing,
693 cancel_requested: false,
694 });
695 self.dirty = true;
696 }
697 Err(error) => self.error(format!("Could not start archive {}: {error}", kind.name())),
698 }
699 }
700
701 pub(crate) fn poll_archive(&mut self) -> bool {
703 let mut changed = false;
704 loop {
705 let event = self.archive_job.as_ref().map(|job| job.rx.try_recv());
706 match event {
707 None | Some(Err(TryRecvError::Empty)) => return changed,
708 Some(Ok(ArchiveEvent::Progress(progress))) => {
709 if let Some(job) = self.archive_job.as_mut()
710 && job.progress != progress
711 {
712 job.progress = progress;
713 changed = true;
714 }
715 }
716 Some(Ok(ArchiveEvent::Finished(result))) => {
717 let job = self
718 .archive_job
719 .take()
720 .expect("archive event requires an active job");
721 let kind = job.kind;
722 let _ = job.handle.join();
723 changed |= self.finish_archive(kind, result);
724 if self.quit_after_archive {
725 self.should_quit = true;
726 }
727 return changed;
728 }
729 Some(Err(TryRecvError::Disconnected)) => {
730 let job = self
731 .archive_job
732 .take()
733 .expect("archive channel requires an active job");
734 let kind = job.kind;
735 let _ = job.handle.join();
736 self.error(format!("{} stopped unexpectedly", kind.title()));
737 if self.quit_after_archive {
738 self.should_quit = true;
739 }
740 return true;
741 }
742 }
743 }
744 }
745
746 fn finish_archive(
747 &mut self,
748 kind: ArchiveJobKind,
749 result: Result<ArchiveOutcome, crate::archive::ArchiveError>,
750 ) -> bool {
751 match result {
752 Ok(ArchiveOutcome::Export(summary)) => {
753 let contents = crate::archive::content_count_text(
754 summary.tasks,
755 summary.categories,
756 summary.labels,
757 summary.images,
758 );
759 self.archive_result(format!("Exported to {} · {contents}", summary.short_path()));
760 }
761 Ok(ArchiveOutcome::Import(summary)) => {
762 if let Err(error) = self.reload_store() {
763 self.error(format!(
764 "Archive imported, but mach could not refresh: {error}"
765 ));
766 return true;
767 }
768 let added = crate::archive::content_count_text(
769 summary.tasks_added,
770 summary.categories_added,
771 summary.labels_added,
772 summary.images_added,
773 );
774 let unchanged = crate::archive::content_count_text(
775 summary.tasks_unchanged,
776 summary.categories_unchanged,
777 summary.labels_unchanged,
778 summary.images_unchanged,
779 );
780 let message = if !summary.changed() {
781 format!("Nothing imported; {unchanged} already present")
782 } else {
783 format!("Imported {added}; {unchanged} already present")
784 };
785 self.archive_result(message);
786 }
787 Err(crate::archive::ArchiveError::Cancelled) => {
788 self.info(format!("{} cancelled", kind.title()));
789 }
790 Err(error) => self.error(format!("Could not {}: {error}", kind.name())),
791 }
792 true
793 }
794
795 pub(crate) fn cancel_archive(&mut self) -> bool {
798 let Some(job) = self.archive_job.as_mut() else {
799 return false;
800 };
801 if !job.control.request_cancel() {
802 let title = job.kind.title();
803 self.info(format!("{title} is finishing and cannot be cancelled"));
804 return true;
805 }
806 if !job.cancel_requested {
807 job.cancel_requested = true;
808 self.dirty = true;
809 }
810 true
811 }
812
813 pub fn request_quit(&mut self) {
814 let Some(job) = self.archive_job.as_mut() else {
815 self.should_quit = true;
816 return;
817 };
818 self.quit_after_archive = true;
819 if job.control.request_cancel() {
820 job.cancel_requested = true;
821 }
822 self.pending = None;
823 self.message = None;
824 self.dirty = true;
825 }
826
827 pub(crate) fn shutdown_archive(&mut self) {
830 if let Some(job) = self.archive_job.take() {
831 let _ = job.control.request_cancel();
832 let _ = job.handle.join();
833 }
834 }
835
836 fn update_store<R>(
839 &mut self,
840 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
841 ) -> Result<R, StoreError> {
842 let selected_category = self.current_category_id().to_string();
843 let selected_task = self.selected_task().map(|task| task.id.clone());
844 let (result, snapshot) = self.store.update_with_snapshot(operation)?;
845 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
846 Ok(result)
847 }
848
849 fn report_store_error(&mut self, action: &str, error: StoreError) {
850 self.error(format!("{action}: {error}"));
851 }
852
853 pub(crate) fn poll_automatic_update_schedule(&mut self) -> bool {
857 self.poll_automatic_update_schedule_at(Utc::now().timestamp())
858 }
859
860 fn poll_automatic_update_schedule_at(&mut self, now: i64) -> bool {
861 if now < self.next_update_state_poll_at {
862 return false;
863 }
864 if self.update_job.is_some() {
865 self.next_update_state_poll_at = now.saturating_add(1);
866 return false;
867 }
868 let claim = match self
869 .update_state
870 .as_mut()
871 .map(|store| store.try_claim_automatic(now))
872 {
873 Some(Ok(claim)) => claim,
874 Some(Err(_)) => {
875 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
876 return false;
877 }
878 None => {
879 self.next_update_state_poll_at = i64::MAX;
880 return false;
881 }
882 };
883 match claim {
884 AutomaticClaim::Claimed(lease) => {
885 self.next_update_state_poll_at = now.saturating_add(LEASE_SECONDS);
886 self.start_update_worker(UpdateJobKind::Automatic, Some(lease));
887 false
888 }
889 AutomaticClaim::Waiting(state) => self.apply_update_state(state, now),
890 }
891 }
892
893 fn refresh_update_state(&mut self, now: i64) -> bool {
894 let state = match self.update_state.as_ref().map(UpdateStateStore::snapshot) {
895 Some(Ok(state)) => state,
896 Some(Err(_)) => {
897 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
898 return false;
899 }
900 None => {
901 self.next_update_state_poll_at = i64::MAX;
902 return false;
903 }
904 };
905 self.apply_update_state(state, now)
906 }
907
908 fn apply_update_state(&mut self, state: UpdateState, now: i64) -> bool {
909 let changed = self.sync_available_update(state.latest_version.as_deref());
910 self.schedule_update_state_poll(&state, now);
911 changed
912 }
913
914 fn schedule_update_state_poll(&mut self, state: &UpdateState, now: i64) {
915 const STATE_REFRESH_SECONDS: i64 = 60;
916 let deadline = if state.automatic_check_due(now) {
917 state
918 .lease_until
919 .filter(|_| state.lease_active(now))
920 .unwrap_or(now)
921 } else {
922 state.next_check_at.unwrap_or(now)
923 };
924 self.next_update_state_poll_at = deadline.min(now.saturating_add(STATE_REFRESH_SECONDS));
925 }
926
927 pub(crate) fn start_update_install(&mut self) {
929 self.update_notice = None;
930 if self
931 .update_job
932 .as_ref()
933 .is_some_and(|job| job.kind == UpdateJobKind::Install)
934 {
935 self.info("Already updating…");
936 return;
937 }
938
939 self.update_job = None;
943 let now = Utc::now().timestamp();
944 let lease = self
945 .update_state
946 .as_mut()
947 .and_then(|store| store.claim_manual(now).ok());
948 self.start_update_worker(UpdateJobKind::Install, lease);
949 }
950
951 fn start_update_worker(&mut self, kind: UpdateJobKind, lease: Option<UpdateLease>) {
952 let (tx, rx) = mpsc::channel();
953 let thread_name = match kind {
954 UpdateJobKind::Automatic => "mach-update-check",
955 UpdateJobKind::Install => "mach-update-install",
956 };
957 let conditional_etag = lease.as_ref().and_then(|lease| lease.etag.clone());
958 match std::thread::Builder::new()
959 .name(thread_name.into())
960 .spawn(move || {
961 let result = (|| -> Result<UpdateOutcome, CheckFailure> {
962 match kind {
963 UpdateJobKind::Automatic => {
964 crate::update::check_with_etag(conditional_etag.as_deref())
965 .map(UpdateOutcome::Automatic)
966 }
967 UpdateJobKind::Install => {
968 let CheckResponse::Modified { value: info, etag } =
969 crate::update::check_with_etag(None)?
970 else {
971 return Err(CheckFailure {
972 message: "GitHub returned 304 without a conditional request"
973 .into(),
974 retry_at: None,
975 });
976 };
977 if !info.newer {
978 return Ok(UpdateOutcome::UpToDate { info, etag });
979 }
980 let install = crate::update::install_with_progress(&info, |progress| {
981 let _ = tx.send(UpdateEvent::DownloadProgress(progress));
982 });
983 Ok(match install {
984 Ok(result) => UpdateOutcome::Installed { result, info, etag },
985 Err(message) => UpdateOutcome::InstallFailed {
986 message,
987 info,
988 etag,
989 },
990 })
991 }
992 }
993 })();
994 let _ = tx.send(UpdateEvent::Finished(Box::new(result)));
995 }) {
996 Ok(_) => {
997 self.update_job = Some(UpdateJob { rx, kind, lease });
998 if kind == UpdateJobKind::Install {
999 self.update_activity = Some(UpdateActivity::Checking);
1000 self.dirty = true;
1001 }
1002 }
1003 Err(error) => {
1004 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
1005 if kind == UpdateJobKind::Install {
1006 self.update_activity = None;
1007 self.error(format!("Could not start update: {error}"));
1008 }
1009 }
1010 }
1011 }
1012
1013 pub(crate) fn poll_update(&mut self) -> bool {
1015 let mut changed = false;
1016 loop {
1017 let event = self
1018 .update_job
1019 .as_ref()
1020 .map(|job| (job.kind, job.rx.try_recv()));
1021 match event {
1022 None | Some((_, Err(TryRecvError::Empty))) => return changed,
1023 Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
1024 let activity = UpdateActivity::Downloading(progress);
1025 if self.update_activity != Some(activity) {
1026 self.update_activity = Some(activity);
1027 changed = true;
1028 }
1029 }
1030 Some((kind, Ok(UpdateEvent::Finished(result)))) => {
1031 let lease = self.update_job.take().and_then(|job| job.lease);
1032 changed |= self.update_activity.take().is_some();
1033 return self.finish_update(kind, lease.as_ref(), *result) || changed;
1034 }
1035 Some((kind, Err(TryRecvError::Disconnected))) => {
1036 let lease = self.update_job.take().and_then(|job| job.lease);
1037 changed |= self.update_activity.take().is_some();
1038 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
1039 return if kind == UpdateJobKind::Install {
1040 self.show_update_message("Update failed".into(), MessageKind::Error);
1041 true
1042 } else {
1043 changed
1044 };
1045 }
1046 }
1047 }
1048 }
1049
1050 fn finish_update(
1051 &mut self,
1052 kind: UpdateJobKind,
1053 lease: Option<&UpdateLease>,
1054 result: Result<UpdateOutcome, CheckFailure>,
1055 ) -> bool {
1056 let now = Utc::now().timestamp();
1057 match result {
1058 Ok(UpdateOutcome::Automatic(CheckResponse::Modified { value: info, etag })) => {
1059 let (committed, changed) =
1060 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1061 if !committed {
1062 return changed;
1063 }
1064 if info.newer {
1065 self.set_available_update_notice(&info.latest) || changed
1066 } else {
1067 self.sync_available_update(None) || changed
1068 }
1069 }
1070 Ok(UpdateOutcome::Automatic(CheckResponse::NotModified)) => {
1071 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
1072 let _ = store.finish_not_modified(lease, now);
1073 }
1074 self.refresh_update_state(now)
1075 }
1076 Ok(UpdateOutcome::UpToDate { info, etag }) => {
1077 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1078 self.show_update_message(info.summary(), MessageKind::Info);
1079 true
1080 }
1081 Ok(UpdateOutcome::Installed { result, info, etag }) => {
1082 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1083 let action = match result.disposition {
1084 crate::update::InstallDisposition::Installed => "Installed",
1085 crate::update::InstallDisposition::AlreadyCurrent => "Already installed",
1086 };
1087 self.set_update_notice(UpdateNotice {
1088 text: format!("{action} {} · restart mach", result.tag),
1089 available_version: None,
1090 })
1091 }
1092 Ok(UpdateOutcome::InstallFailed {
1093 message,
1094 info,
1095 etag,
1096 }) => {
1097 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1098 self.show_update_message(message, MessageKind::Error);
1099 true
1100 }
1101 Err(error) if kind == UpdateJobKind::Install => {
1102 self.finish_update_state_failure(lease, now, error.retry_at);
1103 self.show_update_message(error.message, MessageKind::Error);
1104 true
1105 }
1106 Err(error) => {
1107 self.finish_update_state_failure(lease, now, error.retry_at);
1108 false
1109 }
1110 }
1111 }
1112
1113 fn finish_update_state_modified(
1114 &mut self,
1115 lease: Option<&UpdateLease>,
1116 now: i64,
1117 etag: Option<&str>,
1118 latest_version: &str,
1119 ) -> (bool, bool) {
1120 let committed = match (self.update_state.as_mut(), lease) {
1121 (Some(store), Some(lease)) => store
1122 .finish_modified(lease, now, etag, latest_version)
1123 .unwrap_or(false),
1124 _ => true,
1127 };
1128 (committed, self.refresh_update_state(now))
1129 }
1130
1131 fn finish_update_state_failure(
1132 &mut self,
1133 lease: Option<&UpdateLease>,
1134 now: i64,
1135 retry_at: Option<i64>,
1136 ) {
1137 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
1138 let _ = store.finish_failure(lease, now, retry_at);
1139 }
1140 self.refresh_update_state(now);
1141 }
1142
1143 fn sync_available_update(&mut self, available_version: Option<&str>) -> bool {
1144 let available_version = available_version
1145 .filter(|version| crate::update::is_newer(version, &self.version) == Some(true));
1146 let Some(version) = available_version else {
1147 if self
1148 .update_notice
1149 .as_ref()
1150 .is_some_and(|notice| notice.available_version.is_some())
1151 {
1152 self.update_notice = None;
1153 self.dirty = true;
1154 return true;
1155 }
1156 return false;
1157 };
1158 if self.dismissed_update_version.as_deref() == Some(version)
1159 || self
1160 .update_notice
1161 .as_ref()
1162 .is_some_and(|notice| notice.available_version.is_none())
1163 {
1164 return false;
1165 }
1166 self.set_available_update_notice(version)
1167 }
1168
1169 fn set_available_update_notice(&mut self, latest: &str) -> bool {
1170 if self
1171 .update_notice
1172 .as_ref()
1173 .and_then(|notice| notice.available_version.as_deref())
1174 == Some(latest)
1175 {
1176 return false;
1177 }
1178 self.set_update_notice(UpdateNotice {
1179 text: format!(
1180 "v{} → v{} available · run /update to install",
1181 self.version, latest
1182 ),
1183 available_version: Some(latest.to_string()),
1184 })
1185 }
1186
1187 fn set_update_notice(&mut self, notice: UpdateNotice) -> bool {
1188 let visible = self.message.is_none();
1189 self.update_notice = Some(notice);
1190 if visible {
1191 self.dirty = true;
1192 }
1193 visible
1194 }
1195
1196 fn show_update_message(&mut self, text: String, kind: MessageKind) {
1197 self.set_message(text, kind, MessageLifetime::Long);
1198 }
1199
1200 pub fn mark_dirty(&mut self) {
1201 self.dirty = true;
1202 }
1203
1204 pub fn invalidate_preview(&mut self) {
1205 self.preview_form = None;
1206 self.preview_task_id = None;
1207 self.preview_gen = 0;
1208 }
1209
1210 pub fn ensure_preview(&mut self) {
1212 let Some(task) = self.selected_task() else {
1213 self.invalidate_preview();
1214 return;
1215 };
1216 let id = task.id.clone();
1217 let generation = self.data_gen;
1218 if self.preview_task_id.as_deref() == Some(id.as_str())
1219 && self.preview_gen == generation
1220 && self.preview_form.is_some()
1221 {
1222 return;
1223 }
1224 let task = task.clone();
1225 let mut form =
1226 TaskForm::edit_with_images(&task, self.images.root().to_path_buf(), &self.attachments);
1227 form.set_categories(&self.categories, task.category_id.as_deref());
1228 form.set_labels(&self.labels, &task.label_ids);
1229 self.preview_form = Some(form);
1230 self.preview_task_id = Some(id);
1231 self.preview_gen = generation;
1232 }
1233
1234 pub fn theme(&self) -> Theme {
1235 Theme::new(&self.settings.selected_color)
1236 }
1237
1238 pub fn current_category_id(&self) -> &str {
1241 self.categories
1242 .get(self.cat_index)
1243 .map(|c| c.id.as_str())
1244 .unwrap_or(ALL_CATEGORY)
1245 }
1246
1247 pub fn is_all_view(&self) -> bool {
1248 self.current_category_id() == ALL_CATEGORY
1249 }
1250
1251 pub fn category_name(&self, id: &str) -> Option<&str> {
1252 self.categories
1253 .iter()
1254 .find(|c| c.id == id)
1255 .map(|c| c.name.as_str())
1256 }
1257
1258 pub fn label_name(&self, id: &str) -> Option<&str> {
1259 self.labels
1260 .iter()
1261 .find(|label| label.id == id)
1262 .map(|label| label.name.as_str())
1263 }
1264
1265 pub fn rebuild_view(&mut self) {
1271 let selected_id = self.selected_task().map(|task| task.id.clone());
1272 self.dirty = true;
1273 if self.cat_progress.len() != self.categories.len() {
1274 self.recompute_cat_progress();
1275 }
1276 let cat_id = self.current_category_id();
1277 let all = cat_id == ALL_CATEGORY;
1278 let hide_done = self.settings.hide_done;
1279 let candidates: Vec<usize> = if self.searching {
1280 let q = caseless_key(&self.search_query);
1281 self.tasks
1282 .iter()
1283 .enumerate()
1284 .filter(|(_, t)| {
1285 !(hide_done && t.done)
1286 && (task_text_contains(t, &q) || task_labels_contain(t, &self.labels, &q))
1287 })
1288 .map(|(i, _)| i)
1289 .collect()
1290 } else {
1291 self.tasks
1292 .iter()
1293 .enumerate()
1294 .filter(|(_, t)| {
1295 (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
1296 })
1297 .map(|(i, _)| i)
1298 .collect()
1299 };
1300
1301 let multi = all || self.searching;
1303 self.view = if multi {
1304 self.stack_by_category(&candidates)
1305 } else {
1306 let mut view = candidates;
1307 self.sort_within(&mut view);
1308 view
1309 };
1310 if let Some(id) = selected_id {
1311 self.select_task_by_id(&id);
1312 } else if self.task_index >= self.view.len() {
1313 self.task_index = self.view.len().saturating_sub(1);
1314 }
1315 self.list_rows = self.build_list_rows(multi);
1316 }
1317
1318 fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
1321 if !multi {
1322 return (0..self.view.len()).map(TaskListRow::Task).collect();
1323 }
1324 let category_names: HashMap<_, _> = self
1325 .categories
1326 .iter()
1327 .map(|category| (category.id.as_str(), category.name.as_str()))
1328 .collect();
1329 let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
1330 let mut prev: Option<Option<&str>> = None;
1331 for (vi, &ti) in self.view.iter().enumerate() {
1332 let key = self.tasks[ti].category_id.as_deref();
1333 if prev != Some(key) {
1334 let title = match key {
1335 Some(id) => category_names
1336 .get(id)
1337 .copied()
1338 .unwrap_or("Unknown")
1339 .to_string(),
1340 None => "Uncategorized".to_string(),
1341 };
1342 rows.push(TaskListRow::Separator { title });
1343 prev = Some(key);
1344 }
1345 rows.push(TaskListRow::Task(vi));
1346 }
1347 rows
1348 }
1349
1350 pub fn selected_visual_row(&self) -> Option<usize> {
1352 self.list_rows
1353 .iter()
1354 .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
1355 }
1356
1357 pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
1359 match self.list_rows.get(row)? {
1360 TaskListRow::Task(i) => Some(*i),
1361 TaskListRow::Separator { .. } => None,
1362 }
1363 }
1364
1365 fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
1367 let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
1368 for &i in candidates {
1369 buckets
1370 .entry(self.tasks[i].category_id.as_deref())
1371 .or_default()
1372 .push(i);
1373 }
1374 let mut view = Vec::with_capacity(candidates.len());
1375 for cat in self.categories.iter().filter(|c| !c.is_all()) {
1376 if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
1377 self.sort_within(&mut group);
1378 view.extend(group);
1379 }
1380 }
1381 let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
1383 self.sort_within(&mut rest);
1384 view.extend(rest);
1385 view
1386 }
1387
1388 fn sort_within(&self, view: &mut [usize]) {
1390 match self.settings.sort.as_str() {
1391 "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
1392 "done" => view.sort_by_key(|i| self.tasks[*i].done),
1393 "due" => {
1394 let today = chrono::Local::now().date_naive();
1395 view.sort_by_cached_key(|i| {
1396 let due = &self.tasks[*i].due;
1397 (due.is_empty(), due::sort_key_at(due, today))
1398 });
1399 }
1400 _ => {} }
1402 }
1403
1404 pub fn task_count(&self) -> usize {
1405 self.view.len()
1406 }
1407
1408 pub fn visible_task(&self, pos: usize) -> Option<&Task> {
1409 self.view.get(pos).and_then(|index| self.tasks.get(*index))
1410 }
1411
1412 pub fn selected_task(&self) -> Option<&Task> {
1413 self.visible_task(self.task_index)
1414 }
1415
1416 pub fn done_count(&self) -> usize {
1417 self.view.iter().filter(|i| self.tasks[**i].done).count()
1418 }
1419
1420 pub fn move_task_selection(&mut self, delta: isize) {
1423 if self.view.is_empty() {
1424 return;
1425 }
1426 let last = self.view.len() - 1;
1427 let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
1428 self.select_task(next);
1429 }
1430
1431 pub fn select_task(&mut self, pos: usize) {
1432 if pos < self.view.len() && pos != self.task_index {
1433 self.task_index = pos;
1434 self.cancel_pending();
1435 self.clear_typeahead();
1436 self.dirty = true;
1437 }
1438 }
1439
1440 pub fn select_first_task(&mut self) {
1441 self.select_task(0);
1442 }
1443
1444 pub fn select_last_task(&mut self) {
1445 self.select_task(self.view.len().saturating_sub(1));
1446 }
1447
1448 pub fn typeahead_jump(&mut self, c: char) {
1450 let now = Instant::now();
1451 if self
1452 .typeahead_at
1453 .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
1454 {
1455 self.typeahead.clear();
1456 }
1457 let limit = match self.focus {
1458 Focus::Tasks => MAX_TITLE_LEN,
1459 Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
1460 };
1461 if self.typeahead.graphemes(true).count() < limit {
1462 self.typeahead.push(c);
1463 }
1464 self.typeahead_at = Some(now);
1465
1466 match self.focus {
1467 Focus::Tasks => {
1468 let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
1469 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
1470 self.task_index = pos;
1471 self.cancel_pending();
1472 }
1473 }
1474 Focus::Sidebar => {
1475 let names = self.categories.iter().map(|c| c.name.as_str());
1476 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
1477 && pos != self.cat_index
1478 {
1479 self.cat_index = pos;
1480 self.cancel_pending();
1481 self.on_category_changed();
1482 }
1483 }
1484 }
1485 }
1486
1487 pub fn move_category_selection(&mut self, delta: isize) {
1488 if self.categories.is_empty() {
1489 return;
1490 }
1491 let last = self.categories.len() - 1;
1492 let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
1493 self.select_category(next);
1494 }
1495
1496 pub fn navigate_vertical(&mut self, delta: isize) {
1498 if delta == 0 {
1499 return;
1500 }
1501 self.cancel_pending();
1502 match self.focus {
1503 Focus::Tasks => {
1504 if self.view.is_empty() {
1505 return;
1506 }
1507 self.move_task_selection(delta);
1508 }
1509 Focus::Sidebar => {
1510 self.move_category_selection(delta);
1511 }
1512 }
1513 }
1514
1515 pub fn select_category(&mut self, index: usize) {
1516 if index < self.categories.len() && index != self.cat_index {
1517 self.cat_index = index;
1518 self.cancel_pending();
1519 self.clear_typeahead();
1520 self.on_category_changed();
1521 }
1522 }
1523
1524 pub fn select_last_category(&mut self) {
1525 self.select_category(self.categories.len().saturating_sub(1));
1526 }
1527
1528 fn on_category_changed(&mut self) {
1529 self.searching = false;
1530 self.search_query.clear();
1531 self.task_index = 0;
1532 self.rebuild_view();
1533 }
1534
1535 pub fn toggle_focus(&mut self) {
1536 let next = match self.focus {
1537 Focus::Sidebar => Focus::Tasks,
1538 Focus::Tasks => Focus::Sidebar,
1539 };
1540 let _ = self.set_focus(next);
1541 }
1542
1543 pub fn set_focus(&mut self, focus: Focus) -> bool {
1545 if self.searching && focus == Focus::Sidebar {
1546 return false;
1547 }
1548 if self.focus != focus {
1549 self.focus = focus;
1550 self.cancel_pending();
1551 self.clear_typeahead();
1552 self.dirty = true;
1553 }
1554 true
1555 }
1556
1557 pub fn cancel_pending(&mut self) {
1558 if self.pending.take().is_some() && self.message.take().is_some() {
1559 self.dirty = true;
1560 }
1561 }
1562
1563 fn clear_typeahead(&mut self) {
1564 self.typeahead.clear();
1565 self.typeahead_at = None;
1566 }
1567
1568 fn recompute_cat_progress(&mut self) {
1571 let mut all_done = 0usize;
1572 let mut all_total = 0usize;
1573 let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
1574 let category_indices: HashMap<_, _> = self
1575 .categories
1576 .iter()
1577 .enumerate()
1578 .map(|(index, category)| (category.id.as_str(), index))
1579 .collect();
1580 for t in &self.tasks {
1581 all_total += 1;
1582 if t.done {
1583 all_done += 1;
1584 }
1585 if let Some(cid) = t.category_id.as_deref()
1586 && let Some(&idx) = category_indices.get(cid)
1587 {
1588 per[idx].1 += 1;
1589 if t.done {
1590 per[idx].0 += 1;
1591 }
1592 }
1593 }
1594 for (i, cat) in self.categories.iter().enumerate() {
1595 if cat.is_all() {
1596 per[i] = (all_done, all_total);
1597 }
1598 }
1599 self.cat_progress = per;
1600 }
1601
1602 fn select_task_by_id(&mut self, id: &str) {
1604 if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
1605 self.task_index = pos;
1606 } else if self.task_index >= self.view.len() {
1607 self.task_index = self.view.len().saturating_sub(1);
1608 }
1609 }
1610
1611 pub fn toggle_done(&mut self, pos: usize) {
1612 if let Some(&i) = self.view.get(pos) {
1613 let id = self.tasks[i].id.clone();
1614 match self.update_store(|data| data.toggle_task_done(&id)) {
1615 Ok(_) => self.select_task_by_id(&id),
1616 Err(error) => self.report_store_error("Could not update task", error),
1617 }
1618 }
1619 }
1620
1621 pub fn cycle_importance(&mut self, pos: usize) {
1623 if let Some(&i) = self.view.get(pos) {
1624 let id = self.tasks[i].id.clone();
1625 match self.update_store(|data| {
1626 let importance = crate::model::next_importance(data.task(&id)?.importance);
1627 data.set_task_importance(&id, importance)
1628 }) {
1629 Ok(_) => self.select_task_by_id(&id),
1630 Err(error) => self.report_store_error("Could not update task", error),
1631 }
1632 }
1633 }
1634
1635 pub fn move_task_order(&mut self, delta: isize) -> bool {
1639 if delta == 0 || self.settings.sort != "manual" || self.searching {
1640 return false;
1641 }
1642 let Some(current) = self.selected_task().cloned() else {
1643 return false;
1644 };
1645 let target_view = self.task_index as isize + delta.signum();
1646 if !(0..self.view.len() as isize).contains(&target_view) {
1647 return false;
1648 }
1649 let Some(target) = self.visible_task(target_view as usize) else {
1650 return false;
1651 };
1652 if target.category_id != current.category_id {
1653 return false;
1654 }
1655 let target_id = target.id.clone();
1656 let id = current.id;
1657 let position = if delta.is_negative() {
1658 RelativePosition::Before
1659 } else {
1660 RelativePosition::After
1661 };
1662 match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
1663 Ok(_) => {
1664 self.select_task_by_id(&id);
1665 true
1666 }
1667 Err(error) => {
1668 self.report_store_error("Could not reorder task", error);
1669 false
1670 }
1671 }
1672 }
1673
1674 pub fn open_new_task(&mut self) {
1676 if self.tasks.len() >= MAX_TASK_COUNT {
1677 self.error(format!(
1678 "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1679 ));
1680 return;
1681 }
1682 let mut form =
1683 TaskForm::new_with_images(self.images.root().to_path_buf(), &self.attachments);
1684 let category = (!self.is_all_view()).then(|| self.current_category_id());
1685 form.set_categories(&self.categories, category);
1686 form.set_labels(&self.labels, &[]);
1687 self.task_edit_base = None;
1688 self.form = Some(form);
1689 self.mode = Mode::TaskForm;
1690 }
1691
1692 pub fn open_edit_task(&mut self) {
1694 if let Some(task) = self.selected_task().cloned() {
1695 let mut form = TaskForm::edit_with_images(
1696 &task,
1697 self.images.root().to_path_buf(),
1698 &self.attachments,
1699 );
1700 form.set_categories(&self.categories, task.category_id.as_deref());
1701 form.set_labels(&self.labels, &task.label_ids);
1702 self.images.prefetch(form.description.images());
1705 self.task_edit_base = Some(task);
1706 self.form = Some(form);
1707 self.mode = Mode::TaskForm;
1708 }
1709 }
1710
1711 pub fn close_form(&mut self) {
1712 self.form = None;
1713 self.task_edit_base = None;
1714 self.mode = Mode::Normal;
1715 self.focus = Focus::Tasks;
1716 self.images.release_form_graphics();
1719 self.images.clear_preview();
1720 self.cancel_pending();
1721 }
1722
1723 pub fn submit_form(&mut self) {
1725 let Some(form) = &mut self.form else { return };
1726 let Some(draft) = form.submit() else { return };
1727 let saved = match form.editing.clone() {
1728 Some(uuid) => self.update_task(&uuid, &draft),
1729 None => self.create_task(&draft).is_some(),
1730 };
1731 if saved {
1732 self.close_form();
1733 }
1734 }
1735
1736 pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1739 let (title, due) = draft.resolved_title_and_due();
1740 if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1741 return None;
1742 }
1743 let description = draft.description.clone();
1744 let category_id = draft.category_id.clone();
1745 let label_ids = draft.label_ids.clone();
1746 let importance = draft.importance;
1747 let task = match self.update_store(|data| {
1748 let task = data.create_task(title, description, due, importance, category_id)?;
1749 data.set_task_labels(&task.id, label_ids)
1750 }) {
1751 Ok(task) => task,
1752 Err(error) => {
1753 let message = error.to_string();
1754 if let Some(form) = &mut self.form {
1755 form.error = Some(message.clone());
1756 }
1757 self.report_store_error("Could not create task", error);
1758 return None;
1759 }
1760 };
1761 let id = task.id;
1762 self.searching = false;
1763 self.search_query.clear();
1764 self.rebuild_view();
1765 self.select_task_by_id(&id);
1766 Some(id)
1767 }
1768
1769 pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1770 let (title, due) = draft.resolved_title_and_due();
1771 if title.is_empty() {
1772 return false;
1773 }
1774 let expected = self.task_edit_base.clone();
1775 let id = id.to_string();
1776 let patch = match expected.as_ref() {
1777 Some(base) => TaskPatch {
1778 title: (title != base.title).then_some(title),
1779 description: (draft.description != base.description)
1780 .then(|| draft.description.clone()),
1781 due: (due != base.due).then_some(due),
1782 importance: (draft.importance != base.importance).then_some(draft.importance),
1783 category_id: (draft.category_id != base.category_id)
1784 .then(|| draft.category_id.clone()),
1785 label_ids: (draft.label_ids != base.label_ids).then(|| draft.label_ids.clone()),
1786 ..TaskPatch::default()
1787 },
1788 None => TaskPatch {
1789 title: Some(title),
1790 description: Some(draft.description.clone()),
1791 due: Some(due),
1792 importance: Some(draft.importance),
1793 category_id: Some(draft.category_id.clone()),
1794 label_ids: Some(draft.label_ids.clone()),
1795 ..TaskPatch::default()
1796 },
1797 };
1798 match self.update_store(|data| {
1799 if let Some(expected) = &expected {
1800 data.edit_task_if_unchanged(expected, patch)
1801 } else {
1802 data.edit_task(&id, patch)
1803 }
1804 }) {
1805 Ok(_) => {
1806 self.select_task_by_id(&id);
1807 true
1808 }
1809 Err(error) => {
1810 let message = edit_error_message(&error);
1811 if let Some(form) = &mut self.form {
1812 form.error = Some(message);
1813 }
1814 self.report_store_error("Could not update task", error);
1815 false
1816 }
1817 }
1818 }
1819
1820 pub fn delete_task(&mut self, pos: usize) {
1821 let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1822 return;
1823 };
1824 self.delete_task_by_id(&id);
1825 }
1826
1827 pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1828 let id = id.to_string();
1829 if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1830 self.report_store_error("Could not delete task", error);
1831 return false;
1832 }
1833 self.cancel_pending();
1834 true
1835 }
1836
1837 pub fn purge(&mut self) -> usize {
1840 let ids = self.purge_candidate_ids();
1841 self.purge_ids(&ids)
1842 }
1843
1844 pub fn purge_candidate_ids(&self) -> Vec<String> {
1846 let everywhere = self.is_all_view();
1847 let category = self.current_category_id();
1848 self.tasks
1849 .iter()
1850 .filter(|task| {
1851 task.done && (everywhere || task.category_id.as_deref() == Some(category))
1852 })
1853 .map(|task| task.id.clone())
1854 .collect()
1855 }
1856
1857 pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1859 let ids = ids.to_vec();
1860 match self.update_store(|data| data.purge_completed_ids(&ids)) {
1861 Ok(removed) => {
1862 self.cancel_pending();
1863 removed.len()
1864 }
1865 Err(error) => {
1866 self.report_store_error("Could not purge completed tasks", error);
1867 0
1868 }
1869 }
1870 }
1871
1872 pub fn toggle_hide_done(&mut self) -> Option<bool> {
1874 match self.update_store(|data| {
1875 data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1876 }) {
1877 Ok(settings) => Some(settings.hide_done),
1878 Err(error) => {
1879 self.report_store_error("Could not update settings", error);
1880 None
1881 }
1882 }
1883 }
1884
1885 pub fn open_new_category(&mut self) {
1889 let real = self.categories.iter().filter(|c| !c.is_all()).count();
1891 if real >= MAX_CATEGORY_COUNT {
1892 self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1893 return;
1894 }
1895 self.category_edit_base = None;
1896 self.category_form = Some(CategoryForm::new());
1897 self.mode = Mode::CategoryForm;
1898 }
1899
1900 pub fn open_edit_category(&mut self) {
1903 if self.is_all_view() {
1904 return;
1905 }
1906 if let Some(category) = self.categories.get(self.cat_index).cloned() {
1907 self.category_form = Some(CategoryForm::edit(&category));
1908 self.category_edit_base = Some(category);
1909 self.mode = Mode::CategoryForm;
1910 }
1911 }
1912
1913 pub fn close_category_form(&mut self) {
1914 self.category_form = None;
1915 self.category_edit_base = None;
1916 self.mode = Mode::Normal;
1917 self.cancel_pending();
1918 }
1919
1920 pub fn submit_category_form(&mut self) {
1921 let existing: Vec<(String, String)> = self
1922 .categories
1923 .iter()
1924 .filter(|category| !category.is_all())
1925 .map(|category| (category.id.clone(), category.name.clone()))
1926 .collect();
1927 let Some(form) = &mut self.category_form else {
1928 return;
1929 };
1930 let Some((name, description)) = form.submit_with(|name, editing| {
1931 let duplicate = existing.iter().any(|(id, existing_name)| {
1932 Some(id.as_str()) != editing
1933 && category_name_key(existing_name) == category_name_key(name)
1934 });
1935 if duplicate {
1936 Err("A category with that name already exists".to_string())
1937 } else {
1938 Ok(())
1939 }
1940 }) else {
1941 return;
1942 };
1943 let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1944 let editing = form.editing.clone();
1945 let expected = self.category_edit_base.clone();
1946 let saved = match editing {
1947 Some(id) => {
1948 let patch = match expected.as_ref() {
1949 Some(base) => CategoryPatch {
1950 name: (name != base.name).then_some(name),
1951 description: (description != base.description).then_some(description),
1952 },
1953 None => CategoryPatch {
1954 name: Some(name),
1955 description: Some(description),
1956 },
1957 };
1958 match self.update_store(|data| {
1959 if let Some(expected) = &expected {
1960 data.edit_category_if_unchanged(expected, patch)
1961 } else {
1962 data.edit_category(&id, patch)
1963 }
1964 }) {
1965 Ok(_) => true,
1966 Err(error) => {
1967 let message = edit_error_message(&error);
1968 if let Some(form) = &mut self.category_form {
1969 form.error = Some(message);
1970 }
1971 self.report_store_error("Could not update category", error);
1972 false
1973 }
1974 }
1975 }
1976 None => match self.update_store(|data| data.create_category(name, description)) {
1977 Ok(category) => {
1978 self.cat_index = self
1979 .categories
1980 .iter()
1981 .position(|item| item.id == category.id)
1982 .unwrap_or(0);
1983 self.on_category_changed();
1984 true
1985 }
1986 Err(error) => {
1987 let message = error.to_string();
1988 if let Some(form) = &mut self.category_form {
1989 form.error = Some(message);
1990 }
1991 self.report_store_error("Could not create category", error);
1992 false
1993 }
1994 },
1995 };
1996 if saved {
1997 self.close_category_form();
1998 }
1999 }
2000
2001 pub fn delete_category(&mut self) {
2004 if self.is_all_view() {
2005 return;
2006 }
2007 let id = self.current_category_id().to_string();
2008 let _ = self.delete_category_by_id(&id);
2009 }
2010
2011 pub fn delete_category_by_id(&mut self, id: &str) -> bool {
2012 let Some(category) = self.categories.iter().find(|category| category.id == id) else {
2013 return false;
2014 };
2015 if category.is_all() {
2016 return false;
2017 }
2018 let id = id.to_string();
2019 match self.update_store(|data| data.delete_category(&id)) {
2020 Ok(_) => {
2021 self.cancel_pending();
2022 self.cat_index = 0;
2023 self.on_category_changed();
2024 true
2025 }
2026 Err(error) => {
2027 self.report_store_error("Could not delete category", error);
2028 false
2029 }
2030 }
2031 }
2032
2033 pub fn open_labels(&mut self) {
2036 self.labels_return_to_form = false;
2037 self.open_labels_manager();
2038 }
2039
2040 pub fn open_labels_from_form(&mut self) {
2041 if let Some(form) = &mut self.form {
2042 form.close_label_picker();
2043 }
2044 self.labels_return_to_form = true;
2045 self.open_labels_manager();
2046 }
2047
2048 fn open_labels_manager(&mut self) {
2049 self.mode = Mode::Labels;
2050 self.label_index = self.label_index.min(self.labels.len().saturating_sub(1));
2051 self.label_editor = None;
2052 self.label_error = None;
2053 self.cancel_pending();
2054 self.dirty = true;
2055 }
2056
2057 pub fn close_labels(&mut self) {
2058 if self.labels_return_to_form && self.form.is_some() {
2059 if let Some(form) = &mut self.form {
2060 form.refresh_labels(&self.labels);
2061 }
2062 self.mode = Mode::TaskForm;
2063 } else {
2064 self.mode = Mode::Normal;
2065 }
2066 self.labels_return_to_form = false;
2067 self.label_editor = None;
2068 self.label_error = None;
2069 self.cancel_pending();
2070 self.dirty = true;
2071 }
2072
2073 pub fn move_label_selection(&mut self, delta: isize) {
2074 if self.labels.is_empty() {
2075 return;
2076 }
2077 let last = self.labels.len() - 1;
2078 self.label_index = (self.label_index as isize + delta).clamp(0, last as isize) as usize;
2079 self.cancel_pending();
2080 self.dirty = true;
2081 }
2082
2083 pub fn begin_new_label(&mut self) {
2084 if self.labels.len() >= MAX_LABEL_COUNT {
2085 self.label_error = Some(format!("At most {MAX_LABEL_COUNT} labels"));
2086 return;
2087 }
2088 self.label_editor = Some(LabelEditor::new(
2089 None,
2090 "",
2091 LabelColor::least_used(&self.labels),
2092 ));
2093 self.label_error = None;
2094 self.cancel_pending();
2095 }
2096
2097 pub fn begin_rename_label(&mut self) {
2098 let Some(label) = self.labels.get(self.label_index) else {
2099 return;
2100 };
2101 self.label_editor = Some(LabelEditor::new(
2102 Some(label.id.clone()),
2103 &label.name,
2104 label.color,
2105 ));
2106 self.label_error = None;
2107 self.cancel_pending();
2108 }
2109
2110 pub fn cancel_label_editor(&mut self) {
2111 self.label_editor = None;
2112 self.label_error = None;
2113 self.dirty = true;
2114 }
2115
2116 pub fn submit_label_editor(&mut self) {
2117 let Some(editor) = &self.label_editor else {
2118 return;
2119 };
2120 let editing = editor.editing_id.clone();
2121 let name = editor.name.value();
2122 let color = editor.color;
2123 let result = match editing {
2124 Some(id) => self.update_store(|data| {
2125 data.edit_label(
2126 &id,
2127 LabelPatch {
2128 name: Some(name),
2129 color: Some(color),
2130 },
2131 )
2132 }),
2133 None => self.update_store(|data| data.create_label_with_color(name, color)),
2134 };
2135 match result {
2136 Ok(label) => {
2137 self.label_index = self
2138 .labels
2139 .iter()
2140 .position(|item| item.id == label.id)
2141 .unwrap_or_default();
2142 self.label_editor = None;
2143 self.label_error = None;
2144 }
2145 Err(error) => {
2146 self.label_error = Some(error.to_string());
2147 self.dirty = true;
2148 }
2149 }
2150 }
2151
2152 pub fn selected_label(&self) -> Option<&Label> {
2153 self.labels.get(self.label_index)
2154 }
2155
2156 pub fn delete_label_by_id(&mut self, id: &str) -> bool {
2157 let id = id.to_string();
2158 match self.update_store(|data| data.delete_label(&id)) {
2159 Ok(_) => {
2160 self.label_index = self.label_index.min(self.labels.len().saturating_sub(1));
2161 self.cancel_pending();
2162 true
2163 }
2164 Err(error) => {
2165 self.report_store_error("Could not delete label", error);
2166 false
2167 }
2168 }
2169 }
2170
2171 pub fn create_label(&mut self, name: &str) -> Result<String, String> {
2172 self.update_store(|data| data.create_label(name))
2173 .map(|label| label.id)
2174 .map_err(|error| error.to_string())
2175 }
2176
2177 pub fn set_task_labels(&mut self, task_id: &str, label_ids: Vec<String>) -> Result<(), String> {
2178 let id = task_id.to_string();
2179 self.update_store(|data| data.set_task_labels(&id, label_ids))
2180 .map(|_| ())
2181 .map_err(|error| error.to_string())
2182 }
2183
2184 pub fn move_category_order(&mut self, delta: isize) -> bool {
2186 if delta == 0 || self.is_all_view() || self.searching {
2187 return false;
2188 }
2189 let target_display = self.cat_index as isize + delta.signum();
2190 if !(1..self.categories.len() as isize).contains(&target_display) {
2191 return false;
2192 }
2193 let id = self.current_category_id().to_string();
2194 let target_id = self.categories[target_display as usize].id.clone();
2195 let position = if delta.is_negative() {
2196 RelativePosition::Before
2197 } else {
2198 RelativePosition::After
2199 };
2200 match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
2201 Ok(_) => {
2202 self.cat_index = self
2203 .categories
2204 .iter()
2205 .position(|category| category.id == id)
2206 .unwrap_or(0);
2207 self.on_category_changed();
2208 true
2209 }
2210 Err(error) => {
2211 self.report_store_error("Could not reorder category", error);
2212 false
2213 }
2214 }
2215 }
2216
2217 pub fn category_progress(&self, id: &str) -> (usize, usize) {
2219 if let Some(idx) = self.categories.iter().position(|c| c.id == id)
2220 && let Some(&p) = self.cat_progress.get(idx)
2221 {
2222 return p;
2223 }
2224 (0, 0)
2225 }
2226
2227 pub(crate) fn category_progress_at(&self, index: usize) -> (usize, usize) {
2228 self.cat_progress.get(index).copied().unwrap_or((0, 0))
2229 }
2230
2231 pub fn open_slash(&mut self) {
2235 if self.searching {
2236 self.end_search();
2237 }
2238 if let Some(notice) = self.update_notice.take()
2239 && let Some(version) = notice.available_version
2240 {
2241 self.dismissed_update_version = Some(version);
2242 }
2243 self.mode = Mode::Slash;
2244 self.input = TextInput::new("", MAX_SLASH_INPUT_LEN);
2245 self.slash_index = 0;
2246 self.dirty = true;
2247 }
2248
2249 pub fn start_search(&mut self, query: &str) {
2251 self.mode = Mode::Search;
2252 self.focus = Focus::Tasks;
2253 self.input = TextInput::new(query, MAX_TITLE_LEN);
2254 self.search_query = query.to_string();
2255 self.searching = true;
2256 self.task_index = 0;
2257 self.rebuild_view();
2258 }
2259
2260 pub fn update_search(&mut self) {
2261 self.search_query = self.input.value();
2262 self.searching = true;
2263 self.task_index = 0;
2264 self.rebuild_view();
2265 }
2266
2267 pub fn resume_search(&mut self) {
2270 if !self.searching {
2271 return;
2272 }
2273 self.mode = Mode::Search;
2274 self.input = TextInput::new(&self.search_query, MAX_TITLE_LEN);
2275 self.dirty = true;
2276 }
2277
2278 pub fn end_search(&mut self) {
2279 self.searching = false;
2280 self.search_query.clear();
2281 self.task_index = 0;
2282 self.mode = Mode::Normal;
2283 self.rebuild_view();
2284 }
2285
2286 pub fn clamp_slash_index(&mut self) {
2287 let n = crate::slash::matching(&self.input.value()).len();
2288 if n == 0 {
2289 self.slash_index = 0;
2290 } else {
2291 self.slash_index = self.slash_index.min(n - 1);
2292 }
2293 }
2294
2295 pub fn info(&mut self, text: impl Into<String>) {
2298 self.set_message(text.into(), MessageKind::Info, MessageLifetime::Brief);
2299 }
2300
2301 pub(crate) fn archive_result(&mut self, text: impl Into<String>) {
2302 self.set_message(text.into(), MessageKind::Info, MessageLifetime::Long);
2303 }
2304
2305 pub fn error(&mut self, text: impl Into<String>) {
2306 self.set_message(text.into(), MessageKind::Error, MessageLifetime::Standard);
2307 }
2308
2309 pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
2310 self.message
2311 .as_ref()
2312 .map(|message| (message.text.as_str(), message.kind))
2313 .or_else(|| {
2314 self.update_notice
2315 .as_ref()
2316 .map(|notice| (notice.text.as_str(), MessageKind::Info))
2317 })
2318 }
2319
2320 pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
2321 self.update_activity
2322 }
2323
2324 pub(crate) fn archive_activity_text(&self) -> Option<String> {
2325 let job = self.archive_job.as_ref()?;
2326 if self.quit_after_archive {
2327 let action = if job.cancel_requested {
2328 "Cancelling"
2329 } else {
2330 "Finishing"
2331 };
2332 return Some(format!("{action} {} before quit…", job.kind.name()));
2333 }
2334 if job.cancel_requested {
2335 return Some(format!("Cancelling {}…", job.kind.name()));
2336 }
2337 let text = match job.progress {
2338 crate::archive::ArchiveProgress::Preparing => {
2339 format!("Preparing {}… · Esc cancels", job.kind.name())
2340 }
2341 crate::archive::ArchiveProgress::Attachments { completed, total } if total > 0 => {
2342 let action = match job.kind {
2343 ArchiveJobKind::Export => "Exporting",
2344 ArchiveJobKind::Import => "Importing",
2345 };
2346 format!("{action} images {completed}/{total}… · Esc cancels")
2347 }
2348 crate::archive::ArchiveProgress::Attachments { .. } => {
2349 let action = match job.kind {
2350 ArchiveJobKind::Export => "Writing export",
2351 ArchiveJobKind::Import => "Reading import",
2352 };
2353 format!("{action}… · Esc cancels")
2354 }
2355 crate::archive::ArchiveProgress::Finalizing => {
2356 format!("Finishing {}…", job.kind.name())
2357 }
2358 };
2359 Some(text)
2360 }
2361
2362 pub(crate) fn background_work_active(&self) -> bool {
2363 self.archive_job.is_some()
2364 || self
2365 .update_job
2366 .as_ref()
2367 .is_some_and(|job| job.kind == UpdateJobKind::Install)
2368 }
2369
2370 fn set_message(&mut self, text: String, kind: MessageKind, lifetime: MessageLifetime) {
2371 self.set_message_until(text, kind, Instant::now() + lifetime.duration());
2372 }
2373
2374 fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
2375 self.pending = None;
2379 self.message = Some(Message { text, kind, until });
2380 self.dirty = true;
2381 }
2382
2383 pub fn expire_message(&mut self) -> bool {
2385 if let Some(m) = &self.message
2386 && Instant::now() >= m.until
2387 {
2388 self.pending = None;
2389 self.message = None;
2390 self.dirty = true;
2391 return true;
2392 }
2393 false
2394 }
2395
2396 pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
2398 let until = Instant::now() + MessageLifetime::Brief.duration();
2399 self.set_message_until(prompt.into(), MessageKind::Info, until);
2400 self.pending = Some((confirm, until));
2401 }
2402
2403 pub fn awaiting(&self, confirm: Confirm) -> bool {
2405 matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
2406 }
2407
2408 pub fn pending_confirmation(&self) -> Option<&Confirm> {
2409 self.pending
2410 .as_ref()
2411 .filter(|(_, until)| Instant::now() < *until)
2412 .map(|(confirm, _)| confirm)
2413 }
2414
2415 pub fn cycle_setting(&mut self, index: usize, delta: isize) {
2419 use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
2420 if index >= SETTINGS_ITEMS.len() {
2421 return;
2422 }
2423 if let Err(error) = self.update_store(|data| {
2424 data.update_settings(|settings| match index {
2425 0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
2426 1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
2427 2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
2428 3 => {
2429 settings.preview_position =
2430 cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
2431 }
2432 _ => {}
2433 })
2434 }) {
2435 self.report_store_error("Could not update settings", error);
2436 }
2437 }
2438
2439 pub fn setting_value(&self, index: usize) -> String {
2440 match index {
2441 0 => crate::settings::sort_label(&self.settings.sort).to_string(),
2442 1 => crate::settings::theme_label(&self.settings.selected_color),
2443 2 => self.settings.date_format.clone(),
2444 3 => {
2445 crate::settings::preview_position_label(&self.settings.preview_position).to_string()
2446 }
2447 _ => String::new(),
2448 }
2449 }
2450}
2451
2452fn task_labels_contain(task: &Task, labels: &[Label], query: &str) -> bool {
2453 task.label_ids.iter().any(|id| {
2454 labels
2455 .iter()
2456 .find(|label| label.id == *id)
2457 .is_some_and(|label| caseless_contains(&label.name, query))
2458 })
2459}
2460
2461fn edit_error_message(error: &StoreError) -> String {
2462 match error {
2463 StoreError::StaleEntity { .. } => {
2464 format!("{error}; close and reopen the editor to load the latest values")
2465 }
2466 _ => error.to_string(),
2467 }
2468}
2469
2470pub fn truncate_chars(s: &str, max: usize) -> String {
2471 s.graphemes(true).take(max).collect()
2472}
2473
2474#[cfg(test)]
2475mod tests {
2476 use super::*;
2477
2478 fn exported_task_archive(root: &Path, title: &str) -> PathBuf {
2479 let mut source = Store::open(root.join("source")).expect("open archive source");
2480 source
2481 .update(|data| {
2482 data.tasks.push(Task::new(title, 0, None, ""));
2483 Ok(())
2484 })
2485 .expect("create archived task");
2486 let path = root.join("tasks.mach");
2487 crate::archive::export(&source, Some(&path)).expect("export task archive");
2488 path
2489 }
2490
2491 fn wait_for_archive(app: &mut App) {
2492 let deadline = Instant::now() + Duration::from_secs(5);
2493 while app.archive_job.is_some() && Instant::now() < deadline {
2494 app.poll_archive();
2495 std::thread::sleep(Duration::from_millis(10));
2496 }
2497 assert!(app.archive_job.is_none(), "archive worker did not finish");
2498 }
2499
2500 fn assert_message_lifetime(app: &App, expected: Duration) {
2501 let remaining = app
2502 .message
2503 .as_ref()
2504 .expect("temporary message")
2505 .until
2506 .saturating_duration_since(Instant::now());
2507 assert!(remaining <= expected, "{remaining:?} exceeds {expected:?}");
2508 assert!(
2509 remaining >= expected.saturating_sub(Duration::from_millis(100)),
2510 "{remaining:?} is shorter than {expected:?}"
2511 );
2512 }
2513
2514 fn update_result(newer: bool) -> crate::update::CheckResult {
2515 crate::update::CheckResult {
2516 current: "0.2.0".into(),
2517 latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
2518 tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
2519 newer,
2520 prerelease: false,
2521 release_url: "https://example.test/release".into(),
2522 asset_name: "mach-aarch64-apple-darwin".into(),
2523 asset_url: "https://example.test/binary".into(),
2524 checksums_url: "https://example.test/SHA256SUMS".into(),
2525 }
2526 }
2527
2528 fn automatic_outcome(newer: bool) -> UpdateOutcome {
2529 UpdateOutcome::Automatic(CheckResponse::Modified {
2530 value: update_result(newer),
2531 etag: None,
2532 })
2533 }
2534
2535 fn update_failure(message: &str) -> CheckFailure {
2536 CheckFailure {
2537 message: message.into(),
2538 retry_at: None,
2539 }
2540 }
2541
2542 fn finished(result: Result<UpdateOutcome, CheckFailure>) -> UpdateEvent {
2543 UpdateEvent::Finished(Box::new(result))
2544 }
2545
2546 fn claim_automatic_lease(app: &mut App, now: i64) -> UpdateLease {
2547 let AutomaticClaim::Claimed(lease) = app
2548 .update_state
2549 .as_mut()
2550 .expect("test update state")
2551 .try_claim_automatic(now)
2552 .unwrap()
2553 else {
2554 panic!("automatic update should be due");
2555 };
2556 lease
2557 }
2558
2559 #[test]
2560 fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
2561 let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
2562 .expect("open in-memory store");
2563 let mut app = App::with_store("test", store).expect("build app");
2564 app.mode = Mode::Normal;
2565
2566 for _ in 0..(MAX_TITLE_LEN * 2) {
2567 app.typeahead_jump('x');
2568 }
2569
2570 assert!(
2571 app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
2572 "a held key must not grow the navigation query without bound"
2573 );
2574 }
2575
2576 #[test]
2577 fn transient_messages_use_three_shared_lifetimes() {
2578 let store = Store::open_in_memory_with_paths("/tmp/mach-message-lifetime-test")
2579 .expect("open in-memory store");
2580 let mut app = App::with_store("test", store).expect("build app");
2581
2582 app.info("brief info");
2583 assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2584
2585 app.ask_confirm(Confirm::Quit, "brief confirmation");
2586 assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2587
2588 app.ask_confirm(Confirm::DiscardTask(None), "brief discard confirmation");
2589 assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2590
2591 app.error("standard error");
2592 assert_message_lifetime(&app, MessageLifetime::Standard.duration());
2593
2594 app.archive_result("long archive result");
2595 assert_message_lifetime(&app, MessageLifetime::Long.duration());
2596
2597 app.show_update_message("long update result".into(), MessageKind::Info);
2598 assert_message_lifetime(&app, MessageLifetime::Long.duration());
2599 }
2600
2601 #[test]
2602 fn background_import_reloads_the_completed_store() {
2603 let root = std::env::temp_dir().join(format!(
2604 "mach-background-import-{}-{}",
2605 std::process::id(),
2606 uuid::Uuid::new_v4()
2607 ));
2608 let archive = exported_task_archive(&root, "imported in the background");
2609 let store = Store::open(root.join("destination")).expect("open archive destination");
2610 let mut app = App::with_store("test", store).expect("build destination app");
2611
2612 app.start_import_archive(archive);
2613 assert!(app.background_work_active());
2614 wait_for_archive(&mut app);
2615
2616 assert_eq!(app.tasks.len(), 1);
2617 assert_eq!(app.tasks[0].title, "imported in the background");
2618 assert!(
2619 app.message
2620 .as_ref()
2621 .is_some_and(|message| message.text.contains("Imported 1 task"))
2622 );
2623
2624 drop(app);
2625 std::fs::remove_dir_all(&root).expect("remove archive test directory");
2626 }
2627
2628 #[test]
2629 fn cancelling_a_background_import_prevents_its_commit() {
2630 let root = std::env::temp_dir().join(format!(
2631 "mach-cancel-import-{}-{}",
2632 std::process::id(),
2633 uuid::Uuid::new_v4()
2634 ));
2635 let archive = exported_task_archive(&root, "must not be imported");
2636 let destination = root.join("destination");
2637 let store = Store::open(&destination).expect("open archive destination");
2638 let lock = rusqlite::Connection::open(destination.join("mach.db"))
2639 .expect("open destination database lock");
2640 lock.execute_batch("BEGIN IMMEDIATE")
2641 .expect("hold destination write lock");
2642 let mut app = App::with_store("test", store).expect("build destination app");
2643
2644 app.start_import_archive(archive);
2645 assert!(app.cancel_archive());
2646 lock.execute_batch("ROLLBACK")
2647 .expect("release destination write lock");
2648 wait_for_archive(&mut app);
2649
2650 assert!(app.tasks.is_empty());
2651 assert_eq!(
2652 app.message.as_ref().map(|message| message.text.as_str()),
2653 Some("Import cancelled")
2654 );
2655
2656 drop(lock);
2657 drop(app);
2658 std::fs::remove_dir_all(&root).expect("remove archive test directory");
2659 }
2660
2661 #[test]
2662 fn quit_waits_for_background_archive_cleanup() {
2663 let root = std::env::temp_dir().join(format!(
2664 "mach-quit-during-import-{}-{}",
2665 std::process::id(),
2666 uuid::Uuid::new_v4()
2667 ));
2668 let archive = exported_task_archive(&root, "must not outlive mach");
2669 let destination = root.join("destination");
2670 let store = Store::open(&destination).expect("open archive destination");
2671 let lock = rusqlite::Connection::open(destination.join("mach.db"))
2672 .expect("open destination database lock");
2673 lock.execute_batch("BEGIN IMMEDIATE")
2674 .expect("hold destination write lock");
2675 let mut app = App::with_store("test", store).expect("build destination app");
2676
2677 app.start_import_archive(archive);
2678 app.request_quit();
2679 assert!(!app.should_quit, "quit must wait for archive cleanup");
2680 lock.execute_batch("ROLLBACK")
2681 .expect("release destination write lock");
2682 wait_for_archive(&mut app);
2683
2684 assert!(app.should_quit);
2685 assert!(app.tasks.is_empty());
2686
2687 drop(lock);
2688 drop(app);
2689 std::fs::remove_dir_all(&root).expect("remove archive test directory");
2690 }
2691
2692 #[test]
2693 fn automatic_update_claim_is_shared_across_task_stores() {
2694 let root = std::env::temp_dir().join(format!(
2695 "mach-update-claim-{}-{}",
2696 std::process::id(),
2697 uuid::Uuid::new_v4()
2698 ));
2699 let now = 1_800_000_000;
2700 let state_path = root.join("global").join("update.db");
2701 let mut first = App::with_store_and_update_state(
2702 "test",
2703 Store::open(root.join("one")).unwrap(),
2704 UpdateStateStore::open(&state_path),
2705 )
2706 .unwrap();
2707
2708 assert!(matches!(
2709 first
2710 .update_state
2711 .as_mut()
2712 .unwrap()
2713 .try_claim_automatic(now)
2714 .unwrap(),
2715 AutomaticClaim::Claimed(_)
2716 ));
2717 drop(first);
2718
2719 let mut second = App::with_store_and_update_state(
2720 "test",
2721 Store::open(root.join("two")).unwrap(),
2722 UpdateStateStore::open(&state_path),
2723 )
2724 .unwrap();
2725 assert!(matches!(
2726 second
2727 .update_state
2728 .as_mut()
2729 .unwrap()
2730 .try_claim_automatic(now)
2731 .unwrap(),
2732 AutomaticClaim::Waiting(_)
2733 ));
2734 drop(second);
2735 std::fs::remove_dir_all(root).unwrap();
2736 }
2737
2738 #[test]
2739 fn failed_automatic_update_check_retries_before_the_daily_interval() {
2740 let store = Store::open_in_memory_with_paths("/tmp/mach-update-retry-test").unwrap();
2741 let mut app = App::with_store("test", store).unwrap();
2742 let now = Utc::now().timestamp();
2743
2744 let lease = claim_automatic_lease(&mut app, now);
2745 let (tx, rx) = mpsc::channel();
2746 app.update_job = Some(UpdateJob {
2747 rx,
2748 kind: UpdateJobKind::Automatic,
2749 lease: Some(lease),
2750 });
2751 tx.send(finished(Err(update_failure("offline")))).unwrap();
2752
2753 assert!(!app.poll_update());
2754 let retry_at = app
2755 .update_state
2756 .as_ref()
2757 .unwrap()
2758 .snapshot()
2759 .unwrap()
2760 .next_check_at
2761 .expect("failed check schedules a retry");
2762 assert!(retry_at < now + 24 * 60 * 60);
2763 assert!(matches!(
2764 app.update_state
2765 .as_mut()
2766 .unwrap()
2767 .try_claim_automatic(retry_at)
2768 .unwrap(),
2769 AutomaticClaim::Claimed(_)
2770 ));
2771 }
2772
2773 #[test]
2774 fn available_update_notice_survives_reopening_the_app() {
2775 let dir = std::env::temp_dir().join(format!(
2776 "mach-update-notice-{}-{}",
2777 std::process::id(),
2778 uuid::Uuid::new_v4()
2779 ));
2780 let state_path = dir.join("global-update.db");
2781 let mut app = App::with_store_and_update_state(
2782 "0.2.0",
2783 Store::open(dir.join("tasks")).unwrap(),
2784 UpdateStateStore::open(&state_path),
2785 )
2786 .unwrap();
2787 let lease = claim_automatic_lease(&mut app, Utc::now().timestamp());
2788 let (tx, rx) = mpsc::channel();
2789 app.update_job = Some(UpdateJob {
2790 rx,
2791 kind: UpdateJobKind::Automatic,
2792 lease: Some(lease),
2793 });
2794 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2795
2796 assert!(app.poll_update());
2797 drop(app);
2798
2799 let reopened = App::with_store_and_update_state(
2800 "0.2.0",
2801 Store::open(dir.join("tasks")).unwrap(),
2802 UpdateStateStore::open(&state_path),
2803 )
2804 .unwrap();
2805 assert!(
2806 reopened
2807 .status_message()
2808 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2809 );
2810 drop(reopened);
2811 std::fs::remove_dir_all(dir).unwrap();
2812 }
2813
2814 #[test]
2815 fn available_update_notice_reaches_an_already_running_instance() {
2816 let root = std::env::temp_dir().join(format!(
2817 "mach-running-update-notice-{}-{}",
2818 std::process::id(),
2819 uuid::Uuid::new_v4()
2820 ));
2821 let state_path = root.join("global-update.db");
2822 let mut checker = App::with_store_and_update_state(
2823 "0.2.0",
2824 Store::open(root.join("tasks-one")).unwrap(),
2825 UpdateStateStore::open(&state_path),
2826 )
2827 .unwrap();
2828 let mut observer = App::with_store_and_update_state(
2829 "0.2.0",
2830 Store::open(root.join("tasks-two")).unwrap(),
2831 UpdateStateStore::open(&state_path),
2832 )
2833 .unwrap();
2834 let now = Utc::now().timestamp();
2835 let lease = claim_automatic_lease(&mut checker, now);
2836 let (tx, rx) = mpsc::channel();
2837 checker.update_job = Some(UpdateJob {
2838 rx,
2839 kind: UpdateJobKind::Automatic,
2840 lease: Some(lease),
2841 });
2842 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2843
2844 assert!(checker.poll_update());
2845 assert!(observer.poll_automatic_update_schedule_at(now + 1));
2846 assert!(
2847 observer
2848 .status_message()
2849 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2850 );
2851 drop(checker);
2852 drop(observer);
2853 std::fs::remove_dir_all(root).unwrap();
2854 }
2855
2856 #[test]
2857 fn cached_latest_release_is_compared_per_running_binary() {
2858 let root = std::env::temp_dir().join(format!(
2859 "mach-multiple-binaries-{}-{}",
2860 std::process::id(),
2861 uuid::Uuid::new_v4()
2862 ));
2863 let state_path = root.join("global-update.db");
2864 let now = 1_800_000_000;
2865 let mut state = UpdateStateStore::open(&state_path).unwrap();
2866 let AutomaticClaim::Claimed(lease) = state.try_claim_automatic(now).unwrap() else {
2867 panic!("first check should be due");
2868 };
2869 state.finish_modified(&lease, now, None, "0.3.0").unwrap();
2870 drop(state);
2871
2872 let current = App::with_store_and_update_state(
2873 "0.3.0",
2874 Store::open(root.join("current-tasks")).unwrap(),
2875 UpdateStateStore::open(&state_path),
2876 )
2877 .unwrap();
2878 assert!(current.status_message().is_none());
2879 drop(current);
2880
2881 let older = App::with_store_and_update_state(
2882 "0.2.0",
2883 Store::open(root.join("older-tasks")).unwrap(),
2884 UpdateStateStore::open(&state_path),
2885 )
2886 .unwrap();
2887 assert!(
2888 older
2889 .status_message()
2890 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2891 );
2892 drop(older);
2893 std::fs::remove_dir_all(root).unwrap();
2894 }
2895
2896 #[test]
2897 fn upgraded_version_shows_whats_new_once() {
2898 let dir = std::env::temp_dir().join(format!(
2899 "mach-whats-new-{}-{}",
2900 std::process::id(),
2901 uuid::Uuid::new_v4()
2902 ));
2903 let mut store = Store::open(&dir).unwrap();
2904 store
2905 .update(|data| {
2906 data.settings.last_run_version = Some("0.1.9".into());
2907 Ok(())
2908 })
2909 .unwrap();
2910
2911 let mut first = App::with_store("0.2.0", store).unwrap();
2912 assert_eq!(first.mode, Mode::WhatsNew);
2913 first.record_launch().unwrap();
2914 drop(first);
2915
2916 let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
2917 assert_eq!(second.mode, Mode::Normal);
2918 drop(second);
2919 std::fs::remove_dir_all(dir).unwrap();
2920 }
2921
2922 #[test]
2923 fn recording_launch_reclassifies_a_concurrent_provisional_welcome() {
2924 let dir = std::env::temp_dir().join(format!(
2925 "mach-concurrent-launch-{}-{}",
2926 std::process::id(),
2927 uuid::Uuid::new_v4()
2928 ));
2929 let mut first = App::with_store("0.4.0", Store::open(&dir).unwrap()).unwrap();
2930 let mut second = App::with_store("0.4.0", Store::open(&dir).unwrap()).unwrap();
2931 assert_eq!(first.mode, Mode::Welcome);
2932 assert_eq!(second.mode, Mode::Welcome);
2933
2934 first.record_launch().unwrap();
2935 second.record_launch().unwrap();
2936
2937 assert_eq!(first.mode, Mode::Welcome);
2938 assert_eq!(second.mode, Mode::Normal);
2939 drop(first);
2940 drop(second);
2941 std::fs::remove_dir_all(dir).unwrap();
2942 }
2943
2944 #[test]
2945 fn tui_update_install_success_requests_restart() {
2946 let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
2947 let mut app = App::with_store("test", store).unwrap();
2948 let (tx, rx) = mpsc::channel();
2949 app.update_job = Some(UpdateJob {
2950 rx,
2951 kind: UpdateJobKind::Install,
2952 lease: None,
2953 });
2954 app.update_activity = Some(UpdateActivity::Checking);
2955 tx.send(finished(Ok(UpdateOutcome::Installed {
2956 result: crate::update::InstallResult {
2957 destination: "/tmp/mach-bin/mach".into(),
2958 tag: "v0.3.0".into(),
2959 disposition: crate::update::InstallDisposition::Installed,
2960 },
2961 info: update_result(true),
2962 etag: None,
2963 })))
2964 .unwrap();
2965
2966 assert!(app.poll_update());
2967 assert_eq!(
2968 app.status_message().map(|(text, _)| text),
2969 Some("Installed v0.3.0 · restart mach")
2970 );
2971 assert!(app.update_activity().is_none());
2972
2973 assert!(!app.expire_message());
2974 assert_eq!(
2975 app.status_message().map(|(text, _)| text),
2976 Some("Installed v0.3.0 · restart mach")
2977 );
2978
2979 app.open_slash();
2980 assert!(app.status_message().is_none());
2981 }
2982
2983 #[test]
2984 fn tui_update_reports_a_concurrently_installed_release_truthfully() {
2985 let store = Store::open_in_memory_with_paths("/tmp/mach-install-race-test").unwrap();
2986 let mut app = App::with_store("test", store).unwrap();
2987 let (tx, rx) = mpsc::channel();
2988 app.update_job = Some(UpdateJob {
2989 rx,
2990 kind: UpdateJobKind::Install,
2991 lease: None,
2992 });
2993 app.update_activity = Some(UpdateActivity::Checking);
2994 tx.send(finished(Ok(UpdateOutcome::Installed {
2995 result: crate::update::InstallResult {
2996 destination: "/tmp/mach-bin/mach".into(),
2997 tag: "v0.3.1".into(),
2998 disposition: crate::update::InstallDisposition::AlreadyCurrent,
2999 },
3000 info: update_result(true),
3001 etag: None,
3002 })))
3003 .unwrap();
3004
3005 assert!(app.poll_update());
3006 assert_eq!(
3007 app.status_message().map(|(text, _)| text),
3008 Some("Already installed v0.3.1 · restart mach")
3009 );
3010 }
3011
3012 #[test]
3013 fn update_download_progress_is_applied_before_the_final_result() {
3014 let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
3015 let mut app = App::with_store("test", store).unwrap();
3016 let (tx, rx) = mpsc::channel();
3017 app.update_job = Some(UpdateJob {
3018 rx,
3019 kind: UpdateJobKind::Install,
3020 lease: None,
3021 });
3022 app.update_activity = Some(UpdateActivity::Checking);
3023 tx.send(UpdateEvent::DownloadProgress(
3024 crate::update::DownloadProgress {
3025 downloaded: 512,
3026 total: Some(1024),
3027 },
3028 ))
3029 .unwrap();
3030
3031 assert!(app.poll_update());
3032 assert_eq!(
3033 app.update_activity(),
3034 Some(UpdateActivity::Downloading(
3035 crate::update::DownloadProgress {
3036 downloaded: 512,
3037 total: Some(1024),
3038 }
3039 ))
3040 );
3041 }
3042
3043 #[test]
3044 fn tui_update_install_error_keeps_the_recovery_command() {
3045 let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
3046 let mut app = App::with_store("test", store).unwrap();
3047 let (tx, rx) = mpsc::channel();
3048 app.update_job = Some(UpdateJob {
3049 rx,
3050 kind: UpdateJobKind::Install,
3051 lease: None,
3052 });
3053 tx.send(finished(Ok(UpdateOutcome::InstallFailed {
3054 message:
3055 "this mach executable is managed by Cargo; run cargo install --locked mach-tui"
3056 .into(),
3057 info: update_result(true),
3058 etag: None,
3059 })))
3060 .unwrap();
3061
3062 assert!(app.poll_update());
3063 let message = app.message.as_ref().expect("visible install error");
3064 assert_eq!(message.kind, MessageKind::Error);
3065 assert!(message.text.contains("cargo install --locked mach-tui"));
3066 }
3067
3068 #[test]
3069 fn automatic_update_results_are_silent_unless_a_new_version_exists() {
3070 let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
3071 let mut app = App::with_store("0.2.0", store).unwrap();
3072 let (tx, rx) = mpsc::channel();
3073 app.update_job = Some(UpdateJob {
3074 rx,
3075 kind: UpdateJobKind::Automatic,
3076 lease: None,
3077 });
3078 tx.send(finished(Ok(automatic_outcome(false)))).unwrap();
3079
3080 assert!(!app.poll_update());
3081 assert!(app.message.is_none());
3082
3083 let (tx, rx) = mpsc::channel();
3084 app.update_job = Some(UpdateJob {
3085 rx,
3086 kind: UpdateJobKind::Automatic,
3087 lease: None,
3088 });
3089 tx.send(finished(Err(update_failure("offline")))).unwrap();
3090
3091 assert!(!app.poll_update());
3092 assert!(app.message.is_none());
3093 }
3094
3095 #[test]
3096 fn automatic_update_notice_waits_for_an_active_confirmation() {
3097 let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
3098 let mut app = App::with_store("0.2.0", store).unwrap();
3099 app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
3100 let (tx, rx) = mpsc::channel();
3101 app.update_job = Some(UpdateJob {
3102 rx,
3103 kind: UpdateJobKind::Automatic,
3104 lease: None,
3105 });
3106 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
3107
3108 assert!(!app.poll_update());
3109 assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
3110 assert_eq!(
3111 app.message.as_ref().map(|message| message.text.as_str()),
3112 Some("Press Ctrl+C again to quit")
3113 );
3114
3115 app.cancel_pending();
3116 assert!(app.status_message().is_some_and(|(text, _)| {
3117 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
3118 }));
3119
3120 app.info("Temporary action result");
3121 assert_eq!(
3122 app.status_message().map(|(text, _)| text),
3123 Some("Temporary action result")
3124 );
3125 app.message.as_mut().unwrap().until = Instant::now();
3126 assert!(app.expire_message());
3127 assert!(app.status_message().is_some_and(|(text, _)| {
3128 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
3129 }));
3130
3131 app.open_slash();
3132 assert!(app.status_message().is_none());
3133 }
3134}