1use std::path::Path;
4use std::sync::mpsc::{self, Receiver, TryRecvError};
5use std::time::{Duration, Instant};
6
7use chrono::Utc;
8use ratatui::layout::Rect;
9use ratatui::widgets::{ListState, TableState};
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::due;
13use crate::form::{CategoryForm, TaskDraft, TaskForm};
14use crate::image::ImageStore;
15use crate::model::{
16 ALL_CATEGORY, Category, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN, MAX_TASK_COUNT,
17 MAX_TITLE_LEN, Task, caseless_key,
18};
19use crate::settings::{LaunchState, Settings};
20use crate::store::{
21 Attachment, CategoryPatch, RelativePosition, Store, StoreData, StoreError, TaskPatch,
22};
23use crate::text_input::TextInput;
24use crate::theme::Theme;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Focus {
28 Sidebar,
29 Tasks,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Mode {
34 Normal,
35 Slash,
37 Search,
39 TaskForm,
41 CategoryForm,
43 Help,
44 Settings,
45 Welcome,
46 WhatsNew,
47}
48
49impl Mode {
50 pub fn is_overlay(self) -> bool {
52 matches!(
53 self,
54 Mode::Help
55 | Mode::Settings
56 | Mode::Welcome
57 | Mode::WhatsNew
58 | Mode::TaskForm
59 | Mode::CategoryForm
60 )
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum MessageKind {
66 Info,
67 Error,
68}
69
70pub struct Message {
71 pub text: String,
72 pub kind: MessageKind,
73 pub until: Instant,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Confirm {
81 DeleteTask(String),
83 DeleteCategory(String),
85 Purge(Vec<String>),
87 DiscardTask(Option<String>),
89 DiscardCategory(Option<String>),
90 Quit,
92}
93
94const CONFIRM_WINDOW: Duration = Duration::from_millis(2000);
96
97const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
99
100const UPDATE_RESULT_DURATION: Duration = Duration::from_secs(10);
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103enum UpdateJobKind {
104 Automatic,
105 Install,
106}
107
108enum UpdateOutcome {
109 Checked(crate::update::CheckResult),
110 UpToDate(crate::update::CheckResult),
111 Installed(crate::update::InstallResult),
112}
113
114enum UpdateEvent {
115 DownloadProgress(crate::update::DownloadProgress),
116 Finished(Result<UpdateOutcome, String>),
117}
118
119struct UpdateJob {
120 rx: Receiver<UpdateEvent>,
121 kind: UpdateJobKind,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub(crate) enum UpdateActivity {
126 Checking,
127 Downloading(crate::update::DownloadProgress),
128}
129
130#[derive(Debug, Default, Clone, Copy)]
132pub struct Areas {
133 pub sidebar: Rect,
134 pub tasks: Rect,
135 pub preview: Rect,
137 pub flag_x: Option<u16>,
142 pub done_x: Option<u16>,
143 pub slash_menu: Rect,
145}
146
147pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Preview"];
148
149#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum TaskListRow {
153 Separator {
154 title: String,
155 },
156 Task(usize),
158}
159
160pub struct App {
161 store: Store,
162 store_revision: u64,
163 pub tasks: Vec<Task>,
164 pub categories: Vec<Category>,
165 pub settings: Settings,
166 pub focus: Focus,
167 pub mode: Mode,
168 pub cat_index: usize,
170 pub task_index: usize,
172 pub cat_state: ListState,
175 pub task_state: TableState,
176 pub view: Vec<usize>,
178 pub list_rows: Vec<TaskListRow>,
181 pub searching: bool,
182 pub search_query: String,
183 pub input: TextInput,
184 pub slash_index: usize,
186 pub form: Option<TaskForm>,
188 pub category_form: Option<CategoryForm>,
190 pub settings_index: usize,
191 pub help_scroll: usize,
193 pub message: Option<Message>,
194 pub pending: Option<(Confirm, Instant)>,
196 pub last_click: Option<(Instant, Focus, usize)>,
198 pub should_quit: bool,
199 pub areas: Areas,
200 pub images: ImageStore,
202 pub(crate) attachments: Vec<Attachment>,
203 typeahead: String,
205 typeahead_at: Option<Instant>,
206 pub dirty: bool,
208 pub data_gen: u64,
210 cat_progress: Vec<(usize, usize)>,
212 pub preview_form: Option<TaskForm>,
214 preview_task_id: Option<String>,
215 preview_gen: u64,
216 task_edit_base: Option<Task>,
220 category_edit_base: Option<Category>,
221 update_job: Option<UpdateJob>,
223 update_notice: Option<String>,
226 update_activity: Option<UpdateActivity>,
228 external_poll_failed: bool,
232}
233
234impl App {
235 pub fn new(version: &str) -> Result<Self, StoreError> {
236 Self::with_store(version, Store::open_default(None)?)
237 }
238
239 pub fn with_store(version: &str, mut store: Store) -> Result<Self, StoreError> {
240 let initial = store.snapshot()?;
243 let (launch, snapshot) = if initial.settings.last_run_version.as_deref() == Some(version) {
244 (LaunchState::Returning, initial)
245 } else {
246 store.update_with_snapshot(|data| Ok(data.settings.record_launch(version)))?
247 };
248 let StoreData {
249 revision,
250 categories: real_cats,
251 tasks,
252 settings,
253 attachments,
254 } = snapshot;
255 let mut categories = vec![Category::all_tasks()];
257 categories.extend(real_cats);
258 let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
259 images.set_attachments(&attachments);
260
261 let mut app = Self {
262 store,
263 store_revision: revision,
264 tasks,
265 categories,
266 settings,
267 focus: Focus::Tasks,
268 mode: match launch {
269 LaunchState::FirstRun => Mode::Welcome,
270 LaunchState::Upgraded => Mode::WhatsNew,
271 LaunchState::Returning => Mode::Normal,
272 },
273 cat_index: 0,
274 task_index: 0,
275 cat_state: ListState::default(),
276 task_state: TableState::default(),
277 view: Vec::new(),
278 list_rows: Vec::new(),
279 searching: false,
280 search_query: String::new(),
281 input: TextInput::default(),
282 slash_index: 0,
283 form: None,
284 category_form: None,
285 settings_index: 0,
286 help_scroll: 0,
287 message: None,
288 pending: None,
289 last_click: None,
290 should_quit: false,
291 areas: Areas::default(),
292 images,
293 attachments,
294 typeahead: String::new(),
295 typeahead_at: None,
296 dirty: true,
297 data_gen: 0,
298 cat_progress: Vec::new(),
299 preview_form: None,
300 preview_task_id: None,
301 preview_gen: 0,
302 task_edit_base: None,
303 category_edit_base: None,
304 update_job: None,
305 update_notice: None,
306 update_activity: None,
307 external_poll_failed: false,
308 };
309 app.rebuild_view();
310 Ok(app)
311 }
312
313 pub fn data_dir(&self) -> &Path {
314 self.store.data_dir()
315 }
316
317 pub fn poll_external_changes(&mut self) -> bool {
321 let revision = match self.store.revision() {
322 Ok(revision) => revision,
323 Err(error) => {
324 return self.report_external_poll_error(format!(
325 "Could not check for external changes: {error}"
326 ));
327 }
328 };
329 if revision == self.store_revision || self.form.is_some() || self.category_form.is_some() {
330 self.external_poll_failed = false;
331 return false;
332 }
333 match self.reload_store() {
334 Ok(()) => {
335 self.external_poll_failed = false;
336 true
337 }
338 Err(error) => self
339 .report_external_poll_error(format!("Could not reload external changes: {error}")),
340 }
341 }
342
343 fn report_external_poll_error(&mut self, message: String) -> bool {
344 if self.external_poll_failed {
345 return false;
346 }
347 self.external_poll_failed = true;
348 self.error(message);
349 true
350 }
351
352 fn reload_store(&mut self) -> Result<(), StoreError> {
353 let selected_category = self.current_category_id().to_string();
354 let selected_task = self.selected_task().map(|task| task.id.clone());
355 let snapshot = self.store.snapshot()?;
356 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
357 Ok(())
358 }
359
360 fn apply_snapshot(
361 &mut self,
362 snapshot: StoreData,
363 selected_category: &str,
364 selected_task: Option<&str>,
365 ) {
366 let StoreData {
367 revision,
368 categories,
369 tasks,
370 settings,
371 attachments,
372 } = snapshot;
373 self.store_revision = revision;
374 self.tasks = tasks;
375 self.settings = settings;
376 self.attachments = attachments;
377 self.images.set_attachments(&self.attachments);
378 self.categories.clear();
379 self.categories.push(Category::all_tasks());
380 self.categories.extend(categories);
381 self.cat_index = self
382 .categories
383 .iter()
384 .position(|category| category.id == selected_category)
385 .unwrap_or(0);
386 self.cat_progress.clear();
387 self.data_gen = self.data_gen.wrapping_add(1);
388 self.invalidate_preview();
389 self.rebuild_view();
390 if let Some(id) = selected_task {
391 self.select_task_by_id(id);
392 }
393 self.dirty = true;
394 }
395
396 fn update_store<R>(
399 &mut self,
400 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
401 ) -> Result<R, StoreError> {
402 let selected_category = self.current_category_id().to_string();
403 let selected_task = self.selected_task().map(|task| task.id.clone());
404 let (result, snapshot) = self.store.update_with_snapshot(operation)?;
405 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
406 Ok(result)
407 }
408
409 fn report_store_error(&mut self, action: &str, error: StoreError) {
410 self.error(format!("{action}: {error}"));
411 }
412
413 pub(crate) fn start_automatic_update_check(&mut self) {
416 let now = Utc::now().timestamp();
417 if self.claim_automatic_update_check_at(now) {
418 self.start_update_worker(UpdateJobKind::Automatic);
419 }
420 }
421
422 fn claim_automatic_update_check_at(&mut self, now: i64) -> bool {
423 if !self.settings.automatic_update_check_due(now) {
424 return false;
425 }
426 self.update_store(|data| Ok(data.settings.take_automatic_update_check(now)))
427 .unwrap_or(false)
428 }
429
430 pub(crate) fn start_update_install(&mut self) {
432 self.update_notice = None;
433 if self
434 .update_job
435 .as_ref()
436 .is_some_and(|job| job.kind == UpdateJobKind::Install)
437 {
438 self.info("Already updating…");
439 return;
440 }
441
442 self.update_job = None;
446 self.start_update_worker(UpdateJobKind::Install);
447 }
448
449 fn start_update_worker(&mut self, kind: UpdateJobKind) {
450 let (tx, rx) = mpsc::channel();
451 let thread_name = match kind {
452 UpdateJobKind::Automatic => "mach-update-check",
453 UpdateJobKind::Install => "mach-update-install",
454 };
455 match std::thread::Builder::new()
456 .name(thread_name.into())
457 .spawn(move || {
458 let result = crate::update::check().and_then(|info| match kind {
459 UpdateJobKind::Automatic => Ok(UpdateOutcome::Checked(info)),
460 UpdateJobKind::Install if info.newer => {
461 crate::update::install_with_progress(&info, |progress| {
462 let _ = tx.send(UpdateEvent::DownloadProgress(progress));
463 })
464 .map(UpdateOutcome::Installed)
465 }
466 UpdateJobKind::Install => Ok(UpdateOutcome::UpToDate(info)),
467 });
468 let _ = tx.send(UpdateEvent::Finished(result));
469 }) {
470 Ok(_) => {
471 self.update_job = Some(UpdateJob { rx, kind });
472 if kind == UpdateJobKind::Install {
473 self.update_activity = Some(UpdateActivity::Checking);
474 self.dirty = true;
475 }
476 }
477 Err(error) if kind == UpdateJobKind::Install => {
478 self.update_activity = None;
479 self.error(format!("Could not start update: {error}"));
480 }
481 Err(_) => {}
482 }
483 }
484
485 pub(crate) fn poll_update(&mut self) -> bool {
487 let mut changed = false;
488 loop {
489 let event = self
490 .update_job
491 .as_ref()
492 .map(|job| (job.kind, job.rx.try_recv()));
493 match event {
494 None => return changed,
495 Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
496 let activity = UpdateActivity::Downloading(progress);
497 if self.update_activity != Some(activity) {
498 self.update_activity = Some(activity);
499 changed = true;
500 }
501 }
502 Some((kind, Ok(UpdateEvent::Finished(result)))) => {
503 self.update_job = None;
504 changed |= self.update_activity.take().is_some();
505 return self.finish_update(kind, result) || changed;
506 }
507 Some((_, Err(TryRecvError::Empty))) => return changed,
508 Some((kind, Err(TryRecvError::Disconnected))) => {
509 self.update_job = None;
510 changed |= self.update_activity.take().is_some();
511 return if kind == UpdateJobKind::Install {
512 self.show_update_message("Update failed".into(), MessageKind::Error);
513 true
514 } else {
515 changed
516 };
517 }
518 }
519 }
520 }
521
522 fn finish_update(
523 &mut self,
524 kind: UpdateJobKind,
525 result: Result<UpdateOutcome, String>,
526 ) -> bool {
527 match result {
528 Ok(UpdateOutcome::Checked(info)) if info.newer => self.set_update_notice(format!(
529 "v{} → v{} available · run /update to install",
530 info.current, info.latest
531 )),
532 Ok(UpdateOutcome::Checked(_)) => false,
533 Ok(UpdateOutcome::UpToDate(info)) => {
534 self.show_update_message(info.summary(), MessageKind::Info);
535 true
536 }
537 Ok(UpdateOutcome::Installed(result)) => {
538 self.set_update_notice(format!("Installed {} · restart mach", result.tag))
539 }
540 Err(error) if kind == UpdateJobKind::Install => {
541 self.show_update_message(error, MessageKind::Error);
542 true
543 }
544 Err(_) => false,
545 }
546 }
547
548 fn set_update_notice(&mut self, text: String) -> bool {
549 let visible = self.message.is_none();
550 self.update_notice = Some(text);
551 if visible {
552 self.dirty = true;
553 }
554 visible
555 }
556
557 fn show_update_message(&mut self, text: String, kind: MessageKind) {
558 self.set_message_until(text, kind, Instant::now() + UPDATE_RESULT_DURATION);
559 }
560
561 pub fn mark_dirty(&mut self) {
562 self.dirty = true;
563 }
564
565 pub fn invalidate_preview(&mut self) {
566 self.preview_form = None;
567 self.preview_task_id = None;
568 self.preview_gen = 0;
569 }
570
571 pub fn ensure_preview(&mut self) {
573 let Some((id, generation)) = self.selected_task().map(|t| (t.id.clone(), self.data_gen))
574 else {
575 self.invalidate_preview();
576 return;
577 };
578 if self.preview_task_id.as_deref() == Some(id.as_str())
579 && self.preview_gen == generation
580 && self.preview_form.is_some()
581 {
582 return;
583 }
584 let Some(task) = self.selected_task().cloned() else {
585 self.invalidate_preview();
586 return;
587 };
588 let mut form = TaskForm::edit(&task);
589 form.set_categories(&self.categories, task.category_id.as_deref());
590 form.set_image_root(self.images.root().to_path_buf());
591 form.set_attachments(&self.attachments);
592 self.preview_form = Some(form);
593 self.preview_task_id = Some(id);
594 self.preview_gen = generation;
595 }
596
597 pub fn theme(&self) -> Theme {
598 Theme::new(&self.settings.selected_color)
599 }
600
601 pub fn current_category_id(&self) -> &str {
604 self.categories
605 .get(self.cat_index)
606 .map(|c| c.id.as_str())
607 .unwrap_or(ALL_CATEGORY)
608 }
609
610 pub fn is_all_view(&self) -> bool {
611 self.current_category_id() == ALL_CATEGORY
612 }
613
614 pub fn category_name(&self, id: &str) -> Option<&str> {
615 self.categories
616 .iter()
617 .find(|c| c.id == id)
618 .map(|c| c.name.as_str())
619 }
620
621 pub fn rebuild_view(&mut self) {
627 let selected_id = self.selected_task().map(|task| task.id.clone());
628 self.dirty = true;
629 if self.cat_progress.len() != self.categories.len() {
630 self.recompute_cat_progress();
631 }
632 let cat_id = self.current_category_id();
633 let all = cat_id == ALL_CATEGORY;
634 let hide_done = self.settings.hide_done;
635 let candidates: Vec<usize> = if self.searching {
636 let q = caseless_key(&self.search_query);
637 self.tasks
638 .iter()
639 .enumerate()
640 .filter(|(_, t)| {
641 !(hide_done && t.done)
642 && (contains_ignore_case(&t.title, &q) || body_contains(t, &q))
643 })
644 .map(|(i, _)| i)
645 .collect()
646 } else {
647 self.tasks
648 .iter()
649 .enumerate()
650 .filter(|(_, t)| {
651 (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
652 })
653 .map(|(i, _)| i)
654 .collect()
655 };
656
657 let multi = all || self.searching;
659 self.view = if multi {
660 self.stack_by_category(&candidates)
661 } else {
662 let mut view = candidates;
663 self.sort_within(&mut view);
664 view
665 };
666 if let Some(id) = selected_id {
667 self.select_task_by_id(&id);
668 } else if self.task_index >= self.view.len() {
669 self.task_index = self.view.len().saturating_sub(1);
670 }
671 self.list_rows = self.build_list_rows(multi);
672 }
673
674 fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
677 if !multi {
678 return (0..self.view.len()).map(TaskListRow::Task).collect();
679 }
680 let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
681 let mut prev: Option<Option<&str>> = None;
682 for (vi, &ti) in self.view.iter().enumerate() {
683 let key = self.tasks[ti].category_id.as_deref();
684 if prev != Some(key) {
685 let title = match key {
686 Some(id) => self.category_name(id).unwrap_or("Unknown").to_string(),
687 None => "Uncategorized".to_string(),
688 };
689 rows.push(TaskListRow::Separator { title });
690 prev = Some(key);
691 }
692 rows.push(TaskListRow::Task(vi));
693 }
694 rows
695 }
696
697 pub fn selected_visual_row(&self) -> Option<usize> {
699 self.list_rows
700 .iter()
701 .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
702 }
703
704 pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
706 match self.list_rows.get(row)? {
707 TaskListRow::Task(i) => Some(*i),
708 TaskListRow::Separator { .. } => None,
709 }
710 }
711
712 fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
714 use std::collections::HashMap;
715 let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
716 for &i in candidates {
717 buckets
718 .entry(self.tasks[i].category_id.as_deref())
719 .or_default()
720 .push(i);
721 }
722 let mut view = Vec::with_capacity(candidates.len());
723 for cat in self.categories.iter().filter(|c| !c.is_all()) {
724 if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
725 self.sort_within(&mut group);
726 view.extend(group);
727 }
728 }
729 let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
731 self.sort_within(&mut rest);
732 view.extend(rest);
733 view
734 }
735
736 fn sort_within(&self, view: &mut [usize]) {
738 match self.settings.sort.as_str() {
739 "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
740 "done" => view.sort_by_key(|i| self.tasks[*i].done),
741 "due" => view.sort_by_cached_key(|i| {
742 let due = &self.tasks[*i].due;
743 (due.is_empty(), due::sort_key(due))
744 }),
745 _ => {} }
747 }
748
749 pub fn task_count(&self) -> usize {
750 self.view.len()
751 }
752
753 pub fn visible_task(&self, pos: usize) -> Option<&Task> {
754 self.view.get(pos).and_then(|index| self.tasks.get(*index))
755 }
756
757 pub fn selected_task(&self) -> Option<&Task> {
758 self.visible_task(self.task_index)
759 }
760
761 pub fn done_count(&self) -> usize {
762 self.view.iter().filter(|i| self.tasks[**i].done).count()
763 }
764
765 pub fn move_task_selection(&mut self, delta: isize) {
768 if self.view.is_empty() {
769 return;
770 }
771 let last = self.view.len() - 1;
772 let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
773 self.select_task(next);
774 }
775
776 pub fn select_task(&mut self, pos: usize) {
777 if pos < self.view.len() && pos != self.task_index {
778 self.task_index = pos;
779 self.cancel_pending();
780 self.clear_typeahead();
781 self.dirty = true;
782 }
783 }
784
785 pub fn select_first_task(&mut self) {
786 self.select_task(0);
787 }
788
789 pub fn select_last_task(&mut self) {
790 self.select_task(self.view.len().saturating_sub(1));
791 }
792
793 pub fn typeahead_jump(&mut self, c: char) {
795 let now = Instant::now();
796 if self
797 .typeahead_at
798 .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
799 {
800 self.typeahead.clear();
801 }
802 let limit = match self.focus {
803 Focus::Tasks => MAX_TITLE_LEN,
804 Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
805 };
806 if self.typeahead.graphemes(true).count() < limit {
807 self.typeahead.push(c);
808 }
809 self.typeahead_at = Some(now);
810
811 match self.focus {
812 Focus::Tasks => {
813 let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
814 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
815 self.task_index = pos;
816 self.cancel_pending();
817 }
818 }
819 Focus::Sidebar => {
820 let names = self.categories.iter().map(|c| c.name.as_str());
821 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
822 && pos != self.cat_index
823 {
824 self.cat_index = pos;
825 self.cancel_pending();
826 self.on_category_changed();
827 }
828 }
829 }
830 }
831
832 pub fn move_category_selection(&mut self, delta: isize) {
833 if self.categories.is_empty() {
834 return;
835 }
836 let last = self.categories.len() - 1;
837 let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
838 self.select_category(next);
839 }
840
841 pub fn navigate_vertical(&mut self, delta: isize) {
843 if delta == 0 {
844 return;
845 }
846 self.cancel_pending();
847 match self.focus {
848 Focus::Tasks => {
849 if self.view.is_empty() {
850 return;
851 }
852 self.move_task_selection(delta);
853 }
854 Focus::Sidebar => {
855 self.move_category_selection(delta);
856 }
857 }
858 }
859
860 pub fn select_category(&mut self, index: usize) {
861 if index < self.categories.len() && index != self.cat_index {
862 self.cat_index = index;
863 self.cancel_pending();
864 self.clear_typeahead();
865 self.on_category_changed();
866 }
867 }
868
869 pub fn select_last_category(&mut self) {
870 self.select_category(self.categories.len().saturating_sub(1));
871 }
872
873 fn on_category_changed(&mut self) {
874 self.searching = false;
875 self.search_query.clear();
876 self.task_index = 0;
877 self.rebuild_view();
878 }
879
880 pub fn toggle_focus(&mut self) {
881 let next = match self.focus {
882 Focus::Sidebar => Focus::Tasks,
883 Focus::Tasks => Focus::Sidebar,
884 };
885 let _ = self.set_focus(next);
886 }
887
888 pub fn set_focus(&mut self, focus: Focus) -> bool {
890 if self.searching && focus == Focus::Sidebar {
891 return false;
892 }
893 if self.focus != focus {
894 self.focus = focus;
895 self.cancel_pending();
896 self.clear_typeahead();
897 self.dirty = true;
898 }
899 true
900 }
901
902 pub fn cancel_pending(&mut self) {
903 if self.pending.take().is_some() && self.message.take().is_some() {
904 self.dirty = true;
905 }
906 }
907
908 fn clear_typeahead(&mut self) {
909 self.typeahead.clear();
910 self.typeahead_at = None;
911 }
912
913 fn recompute_cat_progress(&mut self) {
916 let mut all_done = 0usize;
917 let mut all_total = 0usize;
918 let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
919 for t in &self.tasks {
920 all_total += 1;
921 if t.done {
922 all_done += 1;
923 }
924 if let Some(cid) = t.category_id.as_deref()
925 && let Some(idx) = self.categories.iter().position(|c| c.id == cid)
926 {
927 per[idx].1 += 1;
928 if t.done {
929 per[idx].0 += 1;
930 }
931 }
932 }
933 for (i, cat) in self.categories.iter().enumerate() {
934 if cat.is_all() {
935 per[i] = (all_done, all_total);
936 }
937 }
938 self.cat_progress = per;
939 }
940
941 fn select_task_by_id(&mut self, id: &str) {
943 if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
944 self.task_index = pos;
945 } else if self.task_index >= self.view.len() {
946 self.task_index = self.view.len().saturating_sub(1);
947 }
948 }
949
950 pub fn toggle_done(&mut self, pos: usize) {
951 if let Some(&i) = self.view.get(pos) {
952 let id = self.tasks[i].id.clone();
953 match self.update_store(|data| data.toggle_task_done(&id)) {
954 Ok(_) => self.select_task_by_id(&id),
955 Err(error) => self.report_store_error("Could not update task", error),
956 }
957 }
958 }
959
960 pub fn cycle_importance(&mut self, pos: usize) {
962 if let Some(&i) = self.view.get(pos) {
963 let id = self.tasks[i].id.clone();
964 match self.update_store(|data| {
965 let importance =
966 (data.task(&id)?.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
967 data.set_task_importance(&id, importance)
968 }) {
969 Ok(_) => self.select_task_by_id(&id),
970 Err(error) => self.report_store_error("Could not update task", error),
971 }
972 }
973 }
974
975 pub fn move_task_order(&mut self, delta: isize) -> bool {
979 if delta == 0 || self.settings.sort != "manual" || self.searching {
980 return false;
981 }
982 let Some(current) = self.selected_task().cloned() else {
983 return false;
984 };
985 let target_view = self.task_index as isize + delta.signum();
986 if !(0..self.view.len() as isize).contains(&target_view) {
987 return false;
988 }
989 let Some(target) = self.visible_task(target_view as usize) else {
990 return false;
991 };
992 if target.category_id != current.category_id {
993 return false;
994 }
995 let target_id = target.id.clone();
996 let id = current.id;
997 let position = if delta.is_negative() {
998 RelativePosition::Before
999 } else {
1000 RelativePosition::After
1001 };
1002 match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
1003 Ok(_) => {
1004 self.select_task_by_id(&id);
1005 true
1006 }
1007 Err(error) => {
1008 self.report_store_error("Could not reorder task", error);
1009 false
1010 }
1011 }
1012 }
1013
1014 pub fn open_new_task(&mut self) {
1016 if self.tasks.len() >= MAX_TASK_COUNT {
1017 self.error(format!(
1018 "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1019 ));
1020 return;
1021 }
1022 let mut form = TaskForm::new();
1023 let category = (!self.is_all_view()).then(|| self.current_category_id());
1024 form.set_categories(&self.categories, category);
1025 form.set_image_root(self.images.root().to_path_buf());
1026 form.set_attachments(&self.attachments);
1027 self.task_edit_base = None;
1028 self.form = Some(form);
1029 self.mode = Mode::TaskForm;
1030 }
1031
1032 pub fn open_edit_task(&mut self) {
1034 if let Some(task) = self.selected_task().cloned() {
1035 let mut form = TaskForm::edit(&task);
1036 form.set_categories(&self.categories, task.category_id.as_deref());
1037 form.set_image_root(self.images.root().to_path_buf());
1038 form.set_attachments(&self.attachments);
1039 self.images.prefetch(form.body.images());
1042 self.task_edit_base = Some(task);
1043 self.form = Some(form);
1044 self.mode = Mode::TaskForm;
1045 }
1046 }
1047
1048 pub fn close_form(&mut self) {
1049 self.form = None;
1050 self.task_edit_base = None;
1051 self.mode = Mode::Normal;
1052 self.focus = Focus::Tasks;
1053 self.images.release_form_graphics();
1056 self.images.clear_preview();
1057 self.cancel_pending();
1058 }
1059
1060 pub fn submit_form(&mut self) {
1062 let Some(form) = &mut self.form else { return };
1063 let Some(draft) = form.submit() else { return };
1064 let saved = match form.editing.clone() {
1065 Some(uuid) => self.update_task(&uuid, &draft),
1066 None => self.create_task(&draft).is_some(),
1067 };
1068 if saved {
1069 self.close_form();
1070 }
1071 }
1072
1073 pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1076 let (inline_due, title) = due::parse(draft.title.trim());
1077 if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1078 return None;
1079 }
1080 let due = if draft.due.is_empty() {
1081 &inline_due
1082 } else {
1083 &draft.due
1084 };
1085 let body = draft.body.clone();
1086 let category_id = draft.category_id.clone();
1087 let importance = draft.importance;
1088 let task = match self.update_store(|data| {
1089 data.create_task(title, body, due.to_string(), importance, category_id)
1090 }) {
1091 Ok(task) => task,
1092 Err(error) => {
1093 let message = error.to_string();
1094 if let Some(form) = &mut self.form {
1095 form.error = Some(message.clone());
1096 }
1097 self.report_store_error("Could not create task", error);
1098 return None;
1099 }
1100 };
1101 let id = task.id;
1102 self.searching = false;
1103 self.search_query.clear();
1104 self.rebuild_view();
1105 self.select_task_by_id(&id);
1106 Some(id)
1107 }
1108
1109 pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1110 let (inline_due, title) = due::parse(draft.title.trim());
1111 if title.is_empty() {
1112 return false;
1113 }
1114 let due = if draft.due.is_empty() {
1115 &inline_due
1116 } else {
1117 &draft.due
1118 };
1119 let expected = self.task_edit_base.clone();
1120 let id = id.to_string();
1121 let due = due.to_string();
1122 let patch = match expected.as_ref() {
1123 Some(base) => TaskPatch {
1124 title: (title != base.title).then_some(title),
1125 body: (draft.body != base.body).then(|| draft.body.clone()),
1126 due: (due != base.due).then_some(due),
1127 importance: (draft.importance != base.importance).then_some(draft.importance),
1128 category_id: (draft.category_id != base.category_id)
1129 .then(|| draft.category_id.clone()),
1130 ..TaskPatch::default()
1131 },
1132 None => TaskPatch {
1133 title: Some(title),
1134 body: Some(draft.body.clone()),
1135 due: Some(due),
1136 importance: Some(draft.importance),
1137 category_id: Some(draft.category_id.clone()),
1138 ..TaskPatch::default()
1139 },
1140 };
1141 match self.update_store(|data| {
1142 if let Some(expected) = &expected {
1143 data.edit_task_if_unchanged(expected, patch)
1144 } else {
1145 data.edit_task(&id, patch)
1146 }
1147 }) {
1148 Ok(_) => {
1149 self.select_task_by_id(&id);
1150 true
1151 }
1152 Err(error) => {
1153 let message = edit_error_message(&error);
1154 if let Some(form) = &mut self.form {
1155 form.error = Some(message);
1156 }
1157 self.report_store_error("Could not update task", error);
1158 false
1159 }
1160 }
1161 }
1162
1163 pub fn delete_task(&mut self, pos: usize) {
1164 let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1165 return;
1166 };
1167 self.delete_task_by_id(&id);
1168 }
1169
1170 pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1171 let id = id.to_string();
1172 if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1173 self.report_store_error("Could not delete task", error);
1174 return false;
1175 }
1176 self.cancel_pending();
1177 true
1178 }
1179
1180 pub fn purge(&mut self) -> usize {
1183 let ids = self.purge_candidate_ids();
1184 self.purge_ids(&ids)
1185 }
1186
1187 pub fn purge_candidate_ids(&self) -> Vec<String> {
1189 let everywhere = self.is_all_view();
1190 let category = self.current_category_id();
1191 self.tasks
1192 .iter()
1193 .filter(|task| {
1194 task.done && (everywhere || task.category_id.as_deref() == Some(category))
1195 })
1196 .map(|task| task.id.clone())
1197 .collect()
1198 }
1199
1200 pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1202 let ids = ids.to_vec();
1203 match self.update_store(|data| data.purge_completed_ids(&ids)) {
1204 Ok(removed) => {
1205 self.cancel_pending();
1206 removed.len()
1207 }
1208 Err(error) => {
1209 self.report_store_error("Could not purge completed tasks", error);
1210 0
1211 }
1212 }
1213 }
1214
1215 pub fn toggle_hide_done(&mut self) -> Option<bool> {
1217 match self.update_store(|data| {
1218 data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1219 }) {
1220 Ok(settings) => Some(settings.hide_done),
1221 Err(error) => {
1222 self.report_store_error("Could not update settings", error);
1223 None
1224 }
1225 }
1226 }
1227
1228 pub fn open_new_category(&mut self) {
1232 let real = self.categories.iter().filter(|c| !c.is_all()).count();
1234 if real >= MAX_CATEGORY_COUNT {
1235 self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1236 return;
1237 }
1238 self.category_edit_base = None;
1239 self.category_form = Some(CategoryForm::new());
1240 self.mode = Mode::CategoryForm;
1241 }
1242
1243 pub fn open_edit_category(&mut self) {
1246 if self.is_all_view() {
1247 return;
1248 }
1249 if let Some(category) = self.categories.get(self.cat_index).cloned() {
1250 self.category_form = Some(CategoryForm::edit(&category));
1251 self.category_edit_base = Some(category);
1252 self.mode = Mode::CategoryForm;
1253 }
1254 }
1255
1256 pub fn close_category_form(&mut self) {
1257 self.category_form = None;
1258 self.category_edit_base = None;
1259 self.mode = Mode::Normal;
1260 self.cancel_pending();
1261 }
1262
1263 pub fn submit_category_form(&mut self) {
1264 let existing: Vec<(String, String)> = self
1265 .categories
1266 .iter()
1267 .filter(|category| !category.is_all())
1268 .map(|category| (category.id.clone(), category.name.clone()))
1269 .collect();
1270 let Some(form) = &mut self.category_form else {
1271 return;
1272 };
1273 let Some((name, description)) = form.submit_with(|name, editing| {
1274 let duplicate = existing.iter().any(|(id, existing_name)| {
1275 Some(id.as_str()) != editing
1276 && caseless_key(existing_name.trim()) == caseless_key(name.trim())
1277 });
1278 if duplicate {
1279 Err("A category with that name already exists".to_string())
1280 } else {
1281 Ok(())
1282 }
1283 }) else {
1284 return;
1285 };
1286 let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1287 let editing = form.editing.clone();
1288 let expected = self.category_edit_base.clone();
1289 let saved = match editing {
1290 Some(id) => {
1291 let patch = match expected.as_ref() {
1292 Some(base) => CategoryPatch {
1293 name: (name != base.name).then_some(name),
1294 description: (description != base.description).then_some(description),
1295 },
1296 None => CategoryPatch {
1297 name: Some(name),
1298 description: Some(description),
1299 },
1300 };
1301 match self.update_store(|data| {
1302 if let Some(expected) = &expected {
1303 data.edit_category_if_unchanged(expected, patch)
1304 } else {
1305 data.edit_category(&id, patch)
1306 }
1307 }) {
1308 Ok(_) => true,
1309 Err(error) => {
1310 let message = edit_error_message(&error);
1311 if let Some(form) = &mut self.category_form {
1312 form.error = Some(message);
1313 }
1314 self.report_store_error("Could not update category", error);
1315 false
1316 }
1317 }
1318 }
1319 None => match self.update_store(|data| data.create_category(name, description)) {
1320 Ok(category) => {
1321 self.cat_index = self
1322 .categories
1323 .iter()
1324 .position(|item| item.id == category.id)
1325 .unwrap_or(0);
1326 self.on_category_changed();
1327 true
1328 }
1329 Err(error) => {
1330 let message = error.to_string();
1331 if let Some(form) = &mut self.category_form {
1332 form.error = Some(message);
1333 }
1334 self.report_store_error("Could not create category", error);
1335 false
1336 }
1337 },
1338 };
1339 if saved {
1340 self.close_category_form();
1341 }
1342 }
1343
1344 pub fn delete_category(&mut self) {
1347 if self.is_all_view() {
1348 return;
1349 }
1350 let id = self.current_category_id().to_string();
1351 let _ = self.delete_category_by_id(&id);
1352 }
1353
1354 pub fn delete_category_by_id(&mut self, id: &str) -> bool {
1355 let Some(category) = self.categories.iter().find(|category| category.id == id) else {
1356 return false;
1357 };
1358 if category.is_all() {
1359 return false;
1360 }
1361 let id = id.to_string();
1362 match self.update_store(|data| data.delete_category(&id)) {
1363 Ok(_) => {
1364 self.cancel_pending();
1365 self.cat_index = 0;
1366 self.on_category_changed();
1367 true
1368 }
1369 Err(error) => {
1370 self.report_store_error("Could not delete category", error);
1371 false
1372 }
1373 }
1374 }
1375
1376 pub fn move_category_order(&mut self, delta: isize) -> bool {
1378 if delta == 0 || self.is_all_view() || self.searching {
1379 return false;
1380 }
1381 let target_display = self.cat_index as isize + delta.signum();
1382 if !(1..self.categories.len() as isize).contains(&target_display) {
1383 return false;
1384 }
1385 let id = self.current_category_id().to_string();
1386 let target_id = self.categories[target_display as usize].id.clone();
1387 let position = if delta.is_negative() {
1388 RelativePosition::Before
1389 } else {
1390 RelativePosition::After
1391 };
1392 match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
1393 Ok(_) => {
1394 self.cat_index = self
1395 .categories
1396 .iter()
1397 .position(|category| category.id == id)
1398 .unwrap_or(0);
1399 self.on_category_changed();
1400 true
1401 }
1402 Err(error) => {
1403 self.report_store_error("Could not reorder category", error);
1404 false
1405 }
1406 }
1407 }
1408
1409 pub fn category_progress(&self, id: &str) -> (usize, usize) {
1411 if let Some(idx) = self.categories.iter().position(|c| c.id == id)
1412 && let Some(&p) = self.cat_progress.get(idx)
1413 {
1414 return p;
1415 }
1416 (0, 0)
1417 }
1418
1419 pub fn open_slash(&mut self) {
1423 if self.searching {
1424 self.end_search();
1425 }
1426 self.update_notice = None;
1427 self.mode = Mode::Slash;
1428 self.input = TextInput::new("", 128);
1429 self.slash_index = 0;
1430 self.dirty = true;
1431 }
1432
1433 pub fn start_search(&mut self, query: &str) {
1435 self.mode = Mode::Search;
1436 self.focus = Focus::Tasks;
1437 self.input = TextInput::new(query, MAX_TITLE_LEN);
1438 self.search_query = query.to_string();
1439 self.searching = true;
1440 self.task_index = 0;
1441 self.rebuild_view();
1442 }
1443
1444 pub fn update_search(&mut self) {
1445 self.search_query = self.input.value();
1446 self.searching = true;
1447 self.task_index = 0;
1448 self.rebuild_view();
1449 }
1450
1451 pub fn end_search(&mut self) {
1452 self.searching = false;
1453 self.search_query.clear();
1454 self.task_index = 0;
1455 self.mode = Mode::Normal;
1456 self.rebuild_view();
1457 }
1458
1459 pub fn clamp_slash_index(&mut self) {
1460 let n = crate::slash::matching(&self.input.value()).len();
1461 if n == 0 {
1462 self.slash_index = 0;
1463 } else {
1464 self.slash_index = self.slash_index.min(n - 1);
1465 }
1466 }
1467
1468 pub fn info(&mut self, text: impl Into<String>) {
1471 self.set_message(text.into(), MessageKind::Info, 2000);
1472 }
1473
1474 pub fn error(&mut self, text: impl Into<String>) {
1475 self.set_message(text.into(), MessageKind::Error, 2500);
1476 }
1477
1478 pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
1479 self.message
1480 .as_ref()
1481 .map(|message| (message.text.as_str(), message.kind))
1482 .or_else(|| {
1483 self.update_notice
1484 .as_deref()
1485 .map(|text| (text, MessageKind::Info))
1486 })
1487 }
1488
1489 pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
1490 self.update_activity
1491 }
1492
1493 pub(crate) fn update_work_active(&self) -> bool {
1494 self.update_job
1495 .as_ref()
1496 .is_some_and(|job| job.kind == UpdateJobKind::Install)
1497 }
1498
1499 fn set_message(&mut self, text: String, kind: MessageKind, millis: u64) {
1500 self.set_message_until(text, kind, Instant::now() + Duration::from_millis(millis));
1501 }
1502
1503 fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
1504 self.pending = None;
1508 self.message = Some(Message { text, kind, until });
1509 self.dirty = true;
1510 }
1511
1512 pub fn expire_message(&mut self) -> bool {
1514 if let Some(m) = &self.message
1515 && Instant::now() >= m.until
1516 {
1517 self.pending = None;
1518 self.message = None;
1519 self.dirty = true;
1520 return true;
1521 }
1522 false
1523 }
1524
1525 pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
1527 let until = Instant::now() + CONFIRM_WINDOW;
1528 self.set_message_until(prompt.into(), MessageKind::Info, until);
1529 self.pending = Some((confirm, until));
1530 }
1531
1532 pub fn awaiting(&self, confirm: Confirm) -> bool {
1534 matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
1535 }
1536
1537 pub fn pending_confirmation(&self) -> Option<&Confirm> {
1538 self.pending
1539 .as_ref()
1540 .filter(|(_, until)| Instant::now() < *until)
1541 .map(|(confirm, _)| confirm)
1542 }
1543
1544 pub fn cycle_setting(&mut self, index: usize, delta: isize) {
1548 use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
1549 if index >= SETTINGS_ITEMS.len() {
1550 return;
1551 }
1552 if let Err(error) = self.update_store(|data| {
1553 data.update_settings(|settings| match index {
1554 0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
1555 1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
1556 2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
1557 3 => {
1558 settings.preview_position =
1559 cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
1560 }
1561 _ => {}
1562 })
1563 }) {
1564 self.report_store_error("Could not update settings", error);
1565 }
1566 }
1567
1568 pub fn setting_value(&self, index: usize) -> String {
1569 match index {
1570 0 => crate::settings::sort_label(&self.settings.sort).to_string(),
1571 1 => crate::settings::theme_label(&self.settings.selected_color),
1572 2 => self.settings.date_format.clone(),
1573 3 => {
1574 crate::settings::preview_position_label(&self.settings.preview_position).to_string()
1575 }
1576 _ => String::new(),
1577 }
1578 }
1579}
1580
1581fn contains_ignore_case(haystack: &str, folded_needle: &str) -> bool {
1584 if folded_needle.is_empty() {
1585 return true;
1586 }
1587 if haystack.is_ascii() && folded_needle.is_ascii() {
1588 return haystack
1589 .as_bytes()
1590 .windows(folded_needle.len())
1591 .any(|w| w.eq_ignore_ascii_case(folded_needle.as_bytes()));
1592 }
1593 caseless_key(haystack).contains(folded_needle)
1594}
1595
1596fn body_contains(task: &Task, query: &str) -> bool {
1598 task.body.iter().any(|block| match block {
1599 crate::model::Block::Text { text }
1600 | crate::model::Block::Todo { text, .. }
1601 | crate::model::Block::Bullet { text }
1602 | crate::model::Block::Number { text }
1603 | crate::model::Block::Link { url: text } => contains_ignore_case(text, query),
1604 crate::model::Block::Image { .. } => false,
1605 })
1606}
1607
1608fn edit_error_message(error: &StoreError) -> String {
1609 match error {
1610 StoreError::StaleEntity { .. } => {
1611 format!("{error}; close and reopen the editor to load the latest values")
1612 }
1613 _ => error.to_string(),
1614 }
1615}
1616
1617pub fn truncate_chars(s: &str, max: usize) -> String {
1618 s.graphemes(true).take(max).collect()
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623 use super::*;
1624
1625 fn update_result(newer: bool) -> crate::update::CheckResult {
1626 crate::update::CheckResult {
1627 current: "0.2.0".into(),
1628 latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
1629 tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
1630 newer,
1631 prerelease: false,
1632 release_url: "https://example.test/release".into(),
1633 asset_name: "mach-aarch64-apple-darwin".into(),
1634 asset_url: "https://example.test/binary".into(),
1635 checksums_url: "https://example.test/SHA256SUMS".into(),
1636 }
1637 }
1638
1639 #[test]
1640 fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
1641 let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
1642 .expect("open in-memory store");
1643 let mut app = App::with_store("test", store).expect("build app");
1644 app.mode = Mode::Normal;
1645
1646 for _ in 0..(MAX_TITLE_LEN * 2) {
1647 app.typeahead_jump('x');
1648 }
1649
1650 assert!(
1651 app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
1652 "a held key must not grow the navigation query without bound"
1653 );
1654 }
1655
1656 #[test]
1657 fn automatic_update_claim_is_persisted_across_app_instances() {
1658 let dir = std::env::temp_dir().join(format!(
1659 "mach-update-claim-{}-{}",
1660 std::process::id(),
1661 uuid::Uuid::new_v4()
1662 ));
1663 let now = 1_800_000_000;
1664 let mut first = App::with_store("test", Store::open(&dir).unwrap()).unwrap();
1665
1666 assert!(first.claim_automatic_update_check_at(now));
1667 drop(first);
1668
1669 let mut second = App::with_store("test", Store::open(&dir).unwrap()).unwrap();
1670 assert!(!second.claim_automatic_update_check_at(now));
1671 assert_eq!(second.settings.last_update_check_at, Some(now));
1672 drop(second);
1673 std::fs::remove_dir_all(dir).unwrap();
1674 }
1675
1676 #[test]
1677 fn upgraded_version_shows_whats_new_once() {
1678 let dir = std::env::temp_dir().join(format!(
1679 "mach-whats-new-{}-{}",
1680 std::process::id(),
1681 uuid::Uuid::new_v4()
1682 ));
1683 let mut store = Store::open(&dir).unwrap();
1684 store
1685 .update(|data| {
1686 data.settings.last_run_version = Some("0.1.9".into());
1687 Ok(())
1688 })
1689 .unwrap();
1690
1691 let first = App::with_store("0.2.0", store).unwrap();
1692 assert_eq!(first.mode, Mode::WhatsNew);
1693 drop(first);
1694
1695 let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
1696 assert_eq!(second.mode, Mode::Normal);
1697 drop(second);
1698 std::fs::remove_dir_all(dir).unwrap();
1699 }
1700
1701 #[test]
1702 fn tui_update_install_success_requests_restart() {
1703 let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
1704 let mut app = App::with_store("test", store).unwrap();
1705 let (tx, rx) = mpsc::channel();
1706 app.update_job = Some(UpdateJob {
1707 rx,
1708 kind: UpdateJobKind::Install,
1709 });
1710 app.update_activity = Some(UpdateActivity::Checking);
1711 tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Installed(
1712 crate::update::InstallResult {
1713 destination: "/tmp/mach-bin/mach".into(),
1714 tag: "v0.3.0".into(),
1715 },
1716 ))))
1717 .unwrap();
1718
1719 assert!(app.poll_update());
1720 assert_eq!(
1721 app.status_message().map(|(text, _)| text),
1722 Some("Installed v0.3.0 · restart mach")
1723 );
1724 assert!(app.update_activity().is_none());
1725
1726 assert!(!app.expire_message());
1727 assert_eq!(
1728 app.status_message().map(|(text, _)| text),
1729 Some("Installed v0.3.0 · restart mach")
1730 );
1731
1732 app.open_slash();
1733 assert!(app.status_message().is_none());
1734 }
1735
1736 #[test]
1737 fn update_download_progress_is_applied_before_the_final_result() {
1738 let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
1739 let mut app = App::with_store("test", store).unwrap();
1740 let (tx, rx) = mpsc::channel();
1741 app.update_job = Some(UpdateJob {
1742 rx,
1743 kind: UpdateJobKind::Install,
1744 });
1745 app.update_activity = Some(UpdateActivity::Checking);
1746 tx.send(UpdateEvent::DownloadProgress(
1747 crate::update::DownloadProgress {
1748 downloaded: 512,
1749 total: Some(1024),
1750 },
1751 ))
1752 .unwrap();
1753
1754 assert!(app.poll_update());
1755 assert_eq!(
1756 app.update_activity(),
1757 Some(UpdateActivity::Downloading(
1758 crate::update::DownloadProgress {
1759 downloaded: 512,
1760 total: Some(1024),
1761 }
1762 ))
1763 );
1764 }
1765
1766 #[test]
1767 fn tui_update_install_error_keeps_the_recovery_command() {
1768 let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
1769 let mut app = App::with_store("test", store).unwrap();
1770 let (tx, rx) = mpsc::channel();
1771 app.update_job = Some(UpdateJob {
1772 rx,
1773 kind: UpdateJobKind::Install,
1774 });
1775 tx.send(UpdateEvent::Finished(Err(
1776 "this mach executable is managed by Cargo; run cargo install --locked mach-tui".into(),
1777 )))
1778 .unwrap();
1779
1780 assert!(app.poll_update());
1781 let message = app.message.as_ref().expect("visible install error");
1782 assert_eq!(message.kind, MessageKind::Error);
1783 assert!(message.text.contains("cargo install --locked mach-tui"));
1784 }
1785
1786 #[test]
1787 fn automatic_update_results_are_silent_unless_a_new_version_exists() {
1788 let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
1789 let mut app = App::with_store("test", store).unwrap();
1790 let (tx, rx) = mpsc::channel();
1791 app.update_job = Some(UpdateJob {
1792 rx,
1793 kind: UpdateJobKind::Automatic,
1794 });
1795 tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Checked(
1796 update_result(false),
1797 ))))
1798 .unwrap();
1799
1800 assert!(!app.poll_update());
1801 assert!(app.message.is_none());
1802
1803 let (tx, rx) = mpsc::channel();
1804 app.update_job = Some(UpdateJob {
1805 rx,
1806 kind: UpdateJobKind::Automatic,
1807 });
1808 tx.send(UpdateEvent::Finished(Err("offline".into())))
1809 .unwrap();
1810
1811 assert!(!app.poll_update());
1812 assert!(app.message.is_none());
1813 }
1814
1815 #[test]
1816 fn automatic_update_notice_waits_for_an_active_confirmation() {
1817 let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
1818 let mut app = App::with_store("test", store).unwrap();
1819 app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
1820 let (tx, rx) = mpsc::channel();
1821 app.update_job = Some(UpdateJob {
1822 rx,
1823 kind: UpdateJobKind::Automatic,
1824 });
1825 tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Checked(
1826 update_result(true),
1827 ))))
1828 .unwrap();
1829
1830 assert!(!app.poll_update());
1831 assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
1832 assert_eq!(
1833 app.message.as_ref().map(|message| message.text.as_str()),
1834 Some("Press Ctrl+C again to quit")
1835 );
1836
1837 app.cancel_pending();
1838 assert!(app.status_message().is_some_and(|(text, _)| {
1839 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
1840 }));
1841
1842 app.info("Temporary action result");
1843 assert_eq!(
1844 app.status_message().map(|(text, _)| text),
1845 Some("Temporary action result")
1846 );
1847 app.message.as_mut().unwrap().until = Instant::now();
1848 assert!(app.expire_message());
1849 assert!(app.status_message().is_some_and(|(text, _)| {
1850 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
1851 }));
1852
1853 app.open_slash();
1854 assert!(app.status_message().is_none());
1855 }
1856}