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