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