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