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