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;
25use crate::update::{CheckFailure, CheckResponse};
26use crate::update_state::{
27 AutomaticClaim, FAILURE_RETRY_SECONDS, LEASE_SECONDS, UpdateLease, UpdateState,
28 UpdateStateStore,
29};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Focus {
33 Sidebar,
34 Tasks,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Mode {
39 Normal,
40 Slash,
42 Search,
44 TaskForm,
46 CategoryForm,
48 Help,
49 Settings,
50 Welcome,
51 WhatsNew,
52}
53
54impl Mode {
55 pub fn command_bar_focused(self) -> bool {
57 matches!(self, Mode::Slash | Mode::Search)
58 }
59
60 pub fn is_overlay(self) -> bool {
62 matches!(
63 self,
64 Mode::Help
65 | Mode::Settings
66 | Mode::Welcome
67 | Mode::WhatsNew
68 | Mode::TaskForm
69 | Mode::CategoryForm
70 )
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum MessageKind {
76 Info,
77 Error,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum MessageLifetime {
82 Brief,
83 Standard,
84 Long,
85}
86
87impl MessageLifetime {
88 const fn duration(self) -> Duration {
89 match self {
90 Self::Brief => Duration::from_secs(2),
91 Self::Standard => Duration::from_secs(4),
92 Self::Long => Duration::from_secs(8),
93 }
94 }
95}
96
97pub struct Message {
98 pub text: String,
99 pub kind: MessageKind,
100 pub until: Instant,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum Confirm {
108 DeleteTask(String),
110 DeleteCategory(String),
112 Purge(Vec<String>),
114 DiscardTask(Option<String>),
116 DiscardCategory(Option<String>),
117 Quit,
119}
120
121const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
123const MAX_SLASH_INPUT_LEN: usize = 4096;
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127enum UpdateJobKind {
128 Automatic,
129 Install,
130}
131
132enum UpdateOutcome {
133 Automatic(CheckResponse),
134 UpToDate {
135 info: crate::update::CheckResult,
136 etag: Option<String>,
137 },
138 Installed {
139 result: crate::update::InstallResult,
140 info: crate::update::CheckResult,
141 etag: Option<String>,
142 },
143 InstallFailed {
144 message: String,
145 info: crate::update::CheckResult,
146 etag: Option<String>,
147 },
148}
149
150enum UpdateEvent {
151 DownloadProgress(crate::update::DownloadProgress),
152 Finished(Box<Result<UpdateOutcome, CheckFailure>>),
153}
154
155struct UpdateJob {
156 rx: Receiver<UpdateEvent>,
157 kind: UpdateJobKind,
158 lease: Option<UpdateLease>,
159}
160
161struct UpdateNotice {
162 text: String,
163 available_version: Option<String>,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub(crate) enum UpdateActivity {
168 Checking,
169 Downloading(crate::update::DownloadProgress),
170}
171
172#[derive(Debug, Default, Clone, Copy)]
174pub struct Areas {
175 pub sidebar: Rect,
176 pub tasks: Rect,
177 pub command_bar: Rect,
179 pub preview: Rect,
181 pub flag_x: Option<u16>,
186 pub done_x: Option<u16>,
187 pub slash_menu: Rect,
189 pub slash_menu_start: usize,
191}
192
193pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Task preview"];
194
195#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum TaskListRow {
199 Separator {
200 title: String,
201 },
202 Task(usize),
204}
205
206pub struct App {
207 store: Store,
208 version: String,
209 store_revision: u64,
210 pub tasks: Vec<Task>,
211 pub categories: Vec<Category>,
212 pub settings: Settings,
213 pub focus: Focus,
214 pub mode: Mode,
215 pub cat_index: usize,
217 pub task_index: usize,
219 pub cat_state: ListState,
222 pub task_state: TableState,
223 pub view: Vec<usize>,
225 pub list_rows: Vec<TaskListRow>,
228 pub searching: bool,
229 pub search_query: String,
230 pub input: TextInput,
231 pub slash_index: usize,
233 pub form: Option<TaskForm>,
235 pub category_form: Option<CategoryForm>,
237 pub settings_index: usize,
238 pub help_scroll: usize,
240 pub message: Option<Message>,
241 pub pending: Option<(Confirm, Instant)>,
243 pub last_click: Option<(Instant, Focus, usize)>,
245 pub should_quit: bool,
246 pub areas: Areas,
247 pub images: ImageStore,
249 pub(crate) attachments: Vec<Attachment>,
250 typeahead: String,
252 typeahead_at: Option<Instant>,
253 pub dirty: bool,
255 pub data_gen: u64,
257 cat_progress: Vec<(usize, usize)>,
259 pub preview_form: Option<TaskForm>,
261 preview_task_id: Option<String>,
262 preview_gen: u64,
263 task_edit_base: Option<Task>,
267 category_edit_base: Option<Category>,
268 update_job: Option<UpdateJob>,
270 update_state: Option<UpdateStateStore>,
272 next_update_state_poll_at: i64,
274 update_notice: Option<UpdateNotice>,
277 dismissed_update_version: Option<String>,
280 update_activity: Option<UpdateActivity>,
282 external_poll_failed: bool,
286}
287
288impl App {
289 pub fn new(version: &str) -> Result<Self, StoreError> {
290 Self::with_store_and_update_state(
291 version,
292 Store::open_default(None)?,
293 UpdateStateStore::open_default(),
294 )
295 }
296
297 pub fn with_store(version: &str, store: Store) -> Result<Self, StoreError> {
298 Self::with_store_and_update_state(version, store, UpdateStateStore::open_in_memory())
299 }
300
301 pub(crate) fn with_store_and_update_state(
302 version: &str,
303 mut store: Store,
304 update_state: Result<UpdateStateStore, StoreError>,
305 ) -> Result<Self, StoreError> {
306 let initial = store.snapshot()?;
309 let (launch, snapshot) = if initial.settings.last_run_version.as_deref() == Some(version) {
310 (LaunchState::Returning, initial)
311 } else {
312 store.update_with_snapshot(|data| Ok(data.settings.record_launch(version)))?
313 };
314 let StoreData {
315 revision,
316 categories: real_cats,
317 tasks,
318 settings,
319 attachments,
320 } = snapshot;
321 let mut categories = vec![Category::all_tasks()];
323 categories.extend(real_cats);
324 let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
325 images.set_attachments(&attachments);
326 let (update_state, update_state_error) = match update_state {
327 Ok(store) => (Some(store), None),
328 Err(error) => (None, Some(error.to_string())),
329 };
330
331 let mut app = Self {
332 store,
333 version: version.to_string(),
334 store_revision: revision,
335 tasks,
336 categories,
337 settings,
338 focus: Focus::Tasks,
339 mode: match launch {
340 LaunchState::FirstRun => Mode::Welcome,
341 LaunchState::Upgraded => Mode::WhatsNew,
342 LaunchState::Returning => Mode::Normal,
343 },
344 cat_index: 0,
345 task_index: 0,
346 cat_state: ListState::default(),
347 task_state: TableState::default(),
348 view: Vec::new(),
349 list_rows: Vec::new(),
350 searching: false,
351 search_query: String::new(),
352 input: TextInput::default(),
353 slash_index: 0,
354 form: None,
355 category_form: None,
356 settings_index: 0,
357 help_scroll: 0,
358 message: None,
359 pending: None,
360 last_click: None,
361 should_quit: false,
362 areas: Areas::default(),
363 images,
364 attachments,
365 typeahead: String::new(),
366 typeahead_at: None,
367 dirty: true,
368 data_gen: 0,
369 cat_progress: Vec::new(),
370 preview_form: None,
371 preview_task_id: None,
372 preview_gen: 0,
373 task_edit_base: None,
374 category_edit_base: None,
375 update_job: None,
376 update_state,
377 next_update_state_poll_at: 0,
378 update_notice: None,
379 dismissed_update_version: None,
380 update_activity: None,
381 external_poll_failed: false,
382 };
383 app.rebuild_view();
384 if let Some(error) = update_state_error {
385 app.error(format!("Automatic update checks unavailable: {error}"));
386 } else {
387 app.refresh_update_state(Utc::now().timestamp());
388 }
389 Ok(app)
390 }
391
392 pub fn data_dir(&self) -> &Path {
393 self.store.data_dir()
394 }
395
396 pub fn poll_external_changes(&mut self) -> bool {
400 let revision = match self.store.revision() {
401 Ok(revision) => revision,
402 Err(error) => {
403 return self.report_external_poll_error(format!(
404 "Could not check for external changes: {error}"
405 ));
406 }
407 };
408 if revision == self.store_revision || self.form.is_some() || self.category_form.is_some() {
409 self.external_poll_failed = false;
410 return false;
411 }
412 match self.reload_store() {
413 Ok(()) => {
414 self.external_poll_failed = false;
415 true
416 }
417 Err(error) => self
418 .report_external_poll_error(format!("Could not reload external changes: {error}")),
419 }
420 }
421
422 fn report_external_poll_error(&mut self, message: String) -> bool {
423 if self.external_poll_failed {
424 return false;
425 }
426 self.external_poll_failed = true;
427 self.error(message);
428 true
429 }
430
431 fn reload_store(&mut self) -> Result<(), StoreError> {
432 let selected_category = self.current_category_id().to_string();
433 let selected_task = self.selected_task().map(|task| task.id.clone());
434 let snapshot = self.store.snapshot()?;
435 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
436 Ok(())
437 }
438
439 fn apply_snapshot(
440 &mut self,
441 snapshot: StoreData,
442 selected_category: &str,
443 selected_task: Option<&str>,
444 ) {
445 let StoreData {
446 revision,
447 categories,
448 tasks,
449 settings,
450 attachments,
451 } = snapshot;
452 self.store_revision = revision;
453 self.tasks = tasks;
454 self.settings = settings;
455 self.attachments = attachments;
456 self.images.set_attachments(&self.attachments);
457 self.categories.clear();
458 self.categories.push(Category::all_tasks());
459 self.categories.extend(categories);
460 self.cat_index = self
461 .categories
462 .iter()
463 .position(|category| category.id == selected_category)
464 .unwrap_or(0);
465 self.cat_progress.clear();
466 self.data_gen = self.data_gen.wrapping_add(1);
467 self.invalidate_preview();
468 self.rebuild_view();
469 if let Some(id) = selected_task {
470 self.select_task_by_id(id);
471 }
472 self.dirty = true;
473 }
474
475 pub(crate) fn export_archive(
476 &self,
477 ) -> Result<crate::archive::ExportSummary, crate::archive::ArchiveError> {
478 crate::archive::export(&self.store, None)
479 }
480
481 pub(crate) fn import_archive(
482 &mut self,
483 path: &Path,
484 ) -> Result<crate::archive::ImportSummary, crate::archive::ArchiveError> {
485 let selected_category = self.current_category_id().to_string();
486 let selected_task = self.selected_task().map(|task| task.id.clone());
487 let outcome = crate::archive::import(&mut self.store, path)?;
488 self.apply_snapshot(
489 outcome.snapshot,
490 &selected_category,
491 selected_task.as_deref(),
492 );
493 Ok(outcome.summary)
494 }
495
496 fn update_store<R>(
499 &mut self,
500 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
501 ) -> Result<R, StoreError> {
502 let selected_category = self.current_category_id().to_string();
503 let selected_task = self.selected_task().map(|task| task.id.clone());
504 let (result, snapshot) = self.store.update_with_snapshot(operation)?;
505 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
506 Ok(result)
507 }
508
509 fn report_store_error(&mut self, action: &str, error: StoreError) {
510 self.error(format!("{action}: {error}"));
511 }
512
513 pub(crate) fn poll_automatic_update_schedule(&mut self) -> bool {
517 self.poll_automatic_update_schedule_at(Utc::now().timestamp())
518 }
519
520 fn poll_automatic_update_schedule_at(&mut self, now: i64) -> bool {
521 if now < self.next_update_state_poll_at {
522 return false;
523 }
524 if self.update_job.is_some() {
525 self.next_update_state_poll_at = now.saturating_add(1);
526 return false;
527 }
528 let claim = match self
529 .update_state
530 .as_mut()
531 .map(|store| store.try_claim_automatic(now))
532 {
533 Some(Ok(claim)) => claim,
534 Some(Err(_)) => {
535 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
536 return false;
537 }
538 None => {
539 self.next_update_state_poll_at = i64::MAX;
540 return false;
541 }
542 };
543 match claim {
544 AutomaticClaim::Claimed(lease) => {
545 self.next_update_state_poll_at = now.saturating_add(LEASE_SECONDS);
546 self.start_update_worker(UpdateJobKind::Automatic, Some(lease));
547 false
548 }
549 AutomaticClaim::Waiting(state) => self.apply_update_state(state, now),
550 }
551 }
552
553 fn refresh_update_state(&mut self, now: i64) -> bool {
554 let state = match self.update_state.as_ref().map(UpdateStateStore::snapshot) {
555 Some(Ok(state)) => state,
556 Some(Err(_)) => {
557 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
558 return false;
559 }
560 None => {
561 self.next_update_state_poll_at = i64::MAX;
562 return false;
563 }
564 };
565 self.apply_update_state(state, now)
566 }
567
568 fn apply_update_state(&mut self, state: UpdateState, now: i64) -> bool {
569 let changed = self.sync_available_update(state.latest_version.as_deref());
570 self.schedule_update_state_poll(&state, now);
571 changed
572 }
573
574 fn schedule_update_state_poll(&mut self, state: &UpdateState, now: i64) {
575 const STATE_REFRESH_SECONDS: i64 = 60;
576 let deadline = if state.automatic_check_due(now) {
577 state
578 .lease_until
579 .filter(|_| state.lease_active(now))
580 .unwrap_or(now)
581 } else {
582 state.next_check_at.unwrap_or(now)
583 };
584 self.next_update_state_poll_at = deadline.min(now.saturating_add(STATE_REFRESH_SECONDS));
585 }
586
587 pub(crate) fn start_update_install(&mut self) {
589 self.update_notice = None;
590 if self
591 .update_job
592 .as_ref()
593 .is_some_and(|job| job.kind == UpdateJobKind::Install)
594 {
595 self.info("Already updating…");
596 return;
597 }
598
599 self.update_job = None;
603 let now = Utc::now().timestamp();
604 let lease = self
605 .update_state
606 .as_mut()
607 .and_then(|store| store.claim_manual(now).ok());
608 self.start_update_worker(UpdateJobKind::Install, lease);
609 }
610
611 fn start_update_worker(&mut self, kind: UpdateJobKind, lease: Option<UpdateLease>) {
612 let (tx, rx) = mpsc::channel();
613 let thread_name = match kind {
614 UpdateJobKind::Automatic => "mach-update-check",
615 UpdateJobKind::Install => "mach-update-install",
616 };
617 let conditional_etag = lease.as_ref().and_then(|lease| lease.etag.clone());
618 match std::thread::Builder::new()
619 .name(thread_name.into())
620 .spawn(move || {
621 let result = (|| -> Result<UpdateOutcome, CheckFailure> {
622 match kind {
623 UpdateJobKind::Automatic => {
624 crate::update::check_with_etag(conditional_etag.as_deref())
625 .map(UpdateOutcome::Automatic)
626 }
627 UpdateJobKind::Install => {
628 let CheckResponse::Modified { info, etag } =
629 crate::update::check_with_etag(None)?
630 else {
631 return Err(CheckFailure {
632 message: "GitHub returned 304 without a conditional request"
633 .into(),
634 retry_at: None,
635 });
636 };
637 if !info.newer {
638 return Ok(UpdateOutcome::UpToDate { info, etag });
639 }
640 let install = crate::update::install_with_progress(&info, |progress| {
641 let _ = tx.send(UpdateEvent::DownloadProgress(progress));
642 });
643 Ok(match install {
644 Ok(result) => UpdateOutcome::Installed { result, info, etag },
645 Err(message) => UpdateOutcome::InstallFailed {
646 message,
647 info,
648 etag,
649 },
650 })
651 }
652 }
653 })();
654 let _ = tx.send(UpdateEvent::Finished(Box::new(result)));
655 }) {
656 Ok(_) => {
657 self.update_job = Some(UpdateJob { rx, kind, lease });
658 if kind == UpdateJobKind::Install {
659 self.update_activity = Some(UpdateActivity::Checking);
660 self.dirty = true;
661 }
662 }
663 Err(error) => {
664 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
665 if kind == UpdateJobKind::Install {
666 self.update_activity = None;
667 self.error(format!("Could not start update: {error}"));
668 }
669 }
670 }
671 }
672
673 pub(crate) fn poll_update(&mut self) -> bool {
675 let mut changed = false;
676 loop {
677 let event = self
678 .update_job
679 .as_ref()
680 .map(|job| (job.kind, job.rx.try_recv()));
681 match event {
682 None => return changed,
683 Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
684 let activity = UpdateActivity::Downloading(progress);
685 if self.update_activity != Some(activity) {
686 self.update_activity = Some(activity);
687 changed = true;
688 }
689 }
690 Some((kind, Ok(UpdateEvent::Finished(result)))) => {
691 let lease = self.update_job.take().and_then(|job| job.lease);
692 changed |= self.update_activity.take().is_some();
693 return self.finish_update(kind, lease.as_ref(), *result) || changed;
694 }
695 Some((_, Err(TryRecvError::Empty))) => return changed,
696 Some((kind, Err(TryRecvError::Disconnected))) => {
697 let lease = self.update_job.take().and_then(|job| job.lease);
698 changed |= self.update_activity.take().is_some();
699 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
700 return if kind == UpdateJobKind::Install {
701 self.show_update_message("Update failed".into(), MessageKind::Error);
702 true
703 } else {
704 changed
705 };
706 }
707 }
708 }
709 }
710
711 fn finish_update(
712 &mut self,
713 kind: UpdateJobKind,
714 lease: Option<&UpdateLease>,
715 result: Result<UpdateOutcome, CheckFailure>,
716 ) -> bool {
717 let now = Utc::now().timestamp();
718 match result {
719 Ok(UpdateOutcome::Automatic(CheckResponse::Modified { info, etag })) => {
720 let (committed, changed) =
721 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
722 if !committed {
723 return changed;
724 }
725 if info.newer {
726 self.set_available_update_notice(&info.latest) || changed
727 } else {
728 self.sync_available_update(None) || changed
729 }
730 }
731 Ok(UpdateOutcome::Automatic(CheckResponse::NotModified)) => {
732 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
733 let _ = store.finish_not_modified(lease, now);
734 }
735 self.refresh_update_state(now)
736 }
737 Ok(UpdateOutcome::UpToDate { info, etag }) => {
738 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
739 self.show_update_message(info.summary(), MessageKind::Info);
740 true
741 }
742 Ok(UpdateOutcome::Installed { result, info, etag }) => {
743 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
744 self.set_update_notice(UpdateNotice {
745 text: format!("Installed {} · restart mach", result.tag),
746 available_version: None,
747 })
748 }
749 Ok(UpdateOutcome::InstallFailed {
750 message,
751 info,
752 etag,
753 }) => {
754 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
755 self.show_update_message(message, MessageKind::Error);
756 true
757 }
758 Err(error) if kind == UpdateJobKind::Install => {
759 self.finish_update_state_failure(lease, now, error.retry_at);
760 self.show_update_message(error.message, MessageKind::Error);
761 true
762 }
763 Err(error) => {
764 self.finish_update_state_failure(lease, now, error.retry_at);
765 false
766 }
767 }
768 }
769
770 fn finish_update_state_modified(
771 &mut self,
772 lease: Option<&UpdateLease>,
773 now: i64,
774 etag: Option<&str>,
775 latest_version: &str,
776 ) -> (bool, bool) {
777 let committed = match (self.update_state.as_mut(), lease) {
778 (Some(store), Some(lease)) => store
779 .finish_modified(lease, now, etag, latest_version)
780 .unwrap_or(false),
781 _ => true,
784 };
785 (committed, self.refresh_update_state(now))
786 }
787
788 fn finish_update_state_failure(
789 &mut self,
790 lease: Option<&UpdateLease>,
791 now: i64,
792 retry_at: Option<i64>,
793 ) {
794 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
795 let _ = store.finish_failure(lease, now, retry_at);
796 }
797 self.refresh_update_state(now);
798 }
799
800 fn sync_available_update(&mut self, available_version: Option<&str>) -> bool {
801 let available_version = available_version
802 .filter(|version| crate::update::is_newer(version, &self.version) == Some(true));
803 let Some(version) = available_version else {
804 if self
805 .update_notice
806 .as_ref()
807 .is_some_and(|notice| notice.available_version.is_some())
808 {
809 self.update_notice = None;
810 self.dirty = true;
811 return true;
812 }
813 return false;
814 };
815 if self.dismissed_update_version.as_deref() == Some(version)
816 || self
817 .update_notice
818 .as_ref()
819 .is_some_and(|notice| notice.available_version.is_none())
820 {
821 return false;
822 }
823 self.set_available_update_notice(version)
824 }
825
826 fn set_available_update_notice(&mut self, latest: &str) -> bool {
827 if self
828 .update_notice
829 .as_ref()
830 .and_then(|notice| notice.available_version.as_deref())
831 == Some(latest)
832 {
833 return false;
834 }
835 self.set_update_notice(UpdateNotice {
836 text: format!(
837 "v{} → v{} available · run /update to install",
838 self.version, latest
839 ),
840 available_version: Some(latest.to_string()),
841 })
842 }
843
844 fn set_update_notice(&mut self, notice: UpdateNotice) -> bool {
845 let visible = self.message.is_none();
846 self.update_notice = Some(notice);
847 if visible {
848 self.dirty = true;
849 }
850 visible
851 }
852
853 fn show_update_message(&mut self, text: String, kind: MessageKind) {
854 self.set_message(text, kind, MessageLifetime::Long);
855 }
856
857 pub fn mark_dirty(&mut self) {
858 self.dirty = true;
859 }
860
861 pub fn invalidate_preview(&mut self) {
862 self.preview_form = None;
863 self.preview_task_id = None;
864 self.preview_gen = 0;
865 }
866
867 pub fn ensure_preview(&mut self) {
869 let Some((id, generation)) = self.selected_task().map(|t| (t.id.clone(), self.data_gen))
870 else {
871 self.invalidate_preview();
872 return;
873 };
874 if self.preview_task_id.as_deref() == Some(id.as_str())
875 && self.preview_gen == generation
876 && self.preview_form.is_some()
877 {
878 return;
879 }
880 let Some(task) = self.selected_task().cloned() else {
881 self.invalidate_preview();
882 return;
883 };
884 let mut form = TaskForm::edit(&task);
885 form.set_categories(&self.categories, task.category_id.as_deref());
886 form.set_image_root(self.images.root().to_path_buf());
887 form.set_attachments(&self.attachments);
888 self.preview_form = Some(form);
889 self.preview_task_id = Some(id);
890 self.preview_gen = generation;
891 }
892
893 pub fn theme(&self) -> Theme {
894 Theme::new(&self.settings.selected_color)
895 }
896
897 pub fn current_category_id(&self) -> &str {
900 self.categories
901 .get(self.cat_index)
902 .map(|c| c.id.as_str())
903 .unwrap_or(ALL_CATEGORY)
904 }
905
906 pub fn is_all_view(&self) -> bool {
907 self.current_category_id() == ALL_CATEGORY
908 }
909
910 pub fn category_name(&self, id: &str) -> Option<&str> {
911 self.categories
912 .iter()
913 .find(|c| c.id == id)
914 .map(|c| c.name.as_str())
915 }
916
917 pub fn rebuild_view(&mut self) {
923 let selected_id = self.selected_task().map(|task| task.id.clone());
924 self.dirty = true;
925 if self.cat_progress.len() != self.categories.len() {
926 self.recompute_cat_progress();
927 }
928 let cat_id = self.current_category_id();
929 let all = cat_id == ALL_CATEGORY;
930 let hide_done = self.settings.hide_done;
931 let candidates: Vec<usize> = if self.searching {
932 let q = caseless_key(&self.search_query);
933 self.tasks
934 .iter()
935 .enumerate()
936 .filter(|(_, t)| {
937 !(hide_done && t.done)
938 && (contains_ignore_case(&t.title, &q) || body_contains(t, &q))
939 })
940 .map(|(i, _)| i)
941 .collect()
942 } else {
943 self.tasks
944 .iter()
945 .enumerate()
946 .filter(|(_, t)| {
947 (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
948 })
949 .map(|(i, _)| i)
950 .collect()
951 };
952
953 let multi = all || self.searching;
955 self.view = if multi {
956 self.stack_by_category(&candidates)
957 } else {
958 let mut view = candidates;
959 self.sort_within(&mut view);
960 view
961 };
962 if let Some(id) = selected_id {
963 self.select_task_by_id(&id);
964 } else if self.task_index >= self.view.len() {
965 self.task_index = self.view.len().saturating_sub(1);
966 }
967 self.list_rows = self.build_list_rows(multi);
968 }
969
970 fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
973 if !multi {
974 return (0..self.view.len()).map(TaskListRow::Task).collect();
975 }
976 let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
977 let mut prev: Option<Option<&str>> = None;
978 for (vi, &ti) in self.view.iter().enumerate() {
979 let key = self.tasks[ti].category_id.as_deref();
980 if prev != Some(key) {
981 let title = match key {
982 Some(id) => self.category_name(id).unwrap_or("Unknown").to_string(),
983 None => "Uncategorized".to_string(),
984 };
985 rows.push(TaskListRow::Separator { title });
986 prev = Some(key);
987 }
988 rows.push(TaskListRow::Task(vi));
989 }
990 rows
991 }
992
993 pub fn selected_visual_row(&self) -> Option<usize> {
995 self.list_rows
996 .iter()
997 .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
998 }
999
1000 pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
1002 match self.list_rows.get(row)? {
1003 TaskListRow::Task(i) => Some(*i),
1004 TaskListRow::Separator { .. } => None,
1005 }
1006 }
1007
1008 fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
1010 use std::collections::HashMap;
1011 let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
1012 for &i in candidates {
1013 buckets
1014 .entry(self.tasks[i].category_id.as_deref())
1015 .or_default()
1016 .push(i);
1017 }
1018 let mut view = Vec::with_capacity(candidates.len());
1019 for cat in self.categories.iter().filter(|c| !c.is_all()) {
1020 if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
1021 self.sort_within(&mut group);
1022 view.extend(group);
1023 }
1024 }
1025 let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
1027 self.sort_within(&mut rest);
1028 view.extend(rest);
1029 view
1030 }
1031
1032 fn sort_within(&self, view: &mut [usize]) {
1034 match self.settings.sort.as_str() {
1035 "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
1036 "done" => view.sort_by_key(|i| self.tasks[*i].done),
1037 "due" => view.sort_by_cached_key(|i| {
1038 let due = &self.tasks[*i].due;
1039 (due.is_empty(), due::sort_key(due))
1040 }),
1041 _ => {} }
1043 }
1044
1045 pub fn task_count(&self) -> usize {
1046 self.view.len()
1047 }
1048
1049 pub fn visible_task(&self, pos: usize) -> Option<&Task> {
1050 self.view.get(pos).and_then(|index| self.tasks.get(*index))
1051 }
1052
1053 pub fn selected_task(&self) -> Option<&Task> {
1054 self.visible_task(self.task_index)
1055 }
1056
1057 pub fn done_count(&self) -> usize {
1058 self.view.iter().filter(|i| self.tasks[**i].done).count()
1059 }
1060
1061 pub fn move_task_selection(&mut self, delta: isize) {
1064 if self.view.is_empty() {
1065 return;
1066 }
1067 let last = self.view.len() - 1;
1068 let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
1069 self.select_task(next);
1070 }
1071
1072 pub fn select_task(&mut self, pos: usize) {
1073 if pos < self.view.len() && pos != self.task_index {
1074 self.task_index = pos;
1075 self.cancel_pending();
1076 self.clear_typeahead();
1077 self.dirty = true;
1078 }
1079 }
1080
1081 pub fn select_first_task(&mut self) {
1082 self.select_task(0);
1083 }
1084
1085 pub fn select_last_task(&mut self) {
1086 self.select_task(self.view.len().saturating_sub(1));
1087 }
1088
1089 pub fn typeahead_jump(&mut self, c: char) {
1091 let now = Instant::now();
1092 if self
1093 .typeahead_at
1094 .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
1095 {
1096 self.typeahead.clear();
1097 }
1098 let limit = match self.focus {
1099 Focus::Tasks => MAX_TITLE_LEN,
1100 Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
1101 };
1102 if self.typeahead.graphemes(true).count() < limit {
1103 self.typeahead.push(c);
1104 }
1105 self.typeahead_at = Some(now);
1106
1107 match self.focus {
1108 Focus::Tasks => {
1109 let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
1110 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
1111 self.task_index = pos;
1112 self.cancel_pending();
1113 }
1114 }
1115 Focus::Sidebar => {
1116 let names = self.categories.iter().map(|c| c.name.as_str());
1117 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
1118 && pos != self.cat_index
1119 {
1120 self.cat_index = pos;
1121 self.cancel_pending();
1122 self.on_category_changed();
1123 }
1124 }
1125 }
1126 }
1127
1128 pub fn move_category_selection(&mut self, delta: isize) {
1129 if self.categories.is_empty() {
1130 return;
1131 }
1132 let last = self.categories.len() - 1;
1133 let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
1134 self.select_category(next);
1135 }
1136
1137 pub fn navigate_vertical(&mut self, delta: isize) {
1139 if delta == 0 {
1140 return;
1141 }
1142 self.cancel_pending();
1143 match self.focus {
1144 Focus::Tasks => {
1145 if self.view.is_empty() {
1146 return;
1147 }
1148 self.move_task_selection(delta);
1149 }
1150 Focus::Sidebar => {
1151 self.move_category_selection(delta);
1152 }
1153 }
1154 }
1155
1156 pub fn select_category(&mut self, index: usize) {
1157 if index < self.categories.len() && index != self.cat_index {
1158 self.cat_index = index;
1159 self.cancel_pending();
1160 self.clear_typeahead();
1161 self.on_category_changed();
1162 }
1163 }
1164
1165 pub fn select_last_category(&mut self) {
1166 self.select_category(self.categories.len().saturating_sub(1));
1167 }
1168
1169 fn on_category_changed(&mut self) {
1170 self.searching = false;
1171 self.search_query.clear();
1172 self.task_index = 0;
1173 self.rebuild_view();
1174 }
1175
1176 pub fn toggle_focus(&mut self) {
1177 let next = match self.focus {
1178 Focus::Sidebar => Focus::Tasks,
1179 Focus::Tasks => Focus::Sidebar,
1180 };
1181 let _ = self.set_focus(next);
1182 }
1183
1184 pub fn set_focus(&mut self, focus: Focus) -> bool {
1186 if self.searching && focus == Focus::Sidebar {
1187 return false;
1188 }
1189 if self.focus != focus {
1190 self.focus = focus;
1191 self.cancel_pending();
1192 self.clear_typeahead();
1193 self.dirty = true;
1194 }
1195 true
1196 }
1197
1198 pub fn cancel_pending(&mut self) {
1199 if self.pending.take().is_some() && self.message.take().is_some() {
1200 self.dirty = true;
1201 }
1202 }
1203
1204 fn clear_typeahead(&mut self) {
1205 self.typeahead.clear();
1206 self.typeahead_at = None;
1207 }
1208
1209 fn recompute_cat_progress(&mut self) {
1212 let mut all_done = 0usize;
1213 let mut all_total = 0usize;
1214 let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
1215 for t in &self.tasks {
1216 all_total += 1;
1217 if t.done {
1218 all_done += 1;
1219 }
1220 if let Some(cid) = t.category_id.as_deref()
1221 && let Some(idx) = self.categories.iter().position(|c| c.id == cid)
1222 {
1223 per[idx].1 += 1;
1224 if t.done {
1225 per[idx].0 += 1;
1226 }
1227 }
1228 }
1229 for (i, cat) in self.categories.iter().enumerate() {
1230 if cat.is_all() {
1231 per[i] = (all_done, all_total);
1232 }
1233 }
1234 self.cat_progress = per;
1235 }
1236
1237 fn select_task_by_id(&mut self, id: &str) {
1239 if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
1240 self.task_index = pos;
1241 } else if self.task_index >= self.view.len() {
1242 self.task_index = self.view.len().saturating_sub(1);
1243 }
1244 }
1245
1246 pub fn toggle_done(&mut self, pos: usize) {
1247 if let Some(&i) = self.view.get(pos) {
1248 let id = self.tasks[i].id.clone();
1249 match self.update_store(|data| data.toggle_task_done(&id)) {
1250 Ok(_) => self.select_task_by_id(&id),
1251 Err(error) => self.report_store_error("Could not update task", error),
1252 }
1253 }
1254 }
1255
1256 pub fn cycle_importance(&mut self, pos: usize) {
1258 if let Some(&i) = self.view.get(pos) {
1259 let id = self.tasks[i].id.clone();
1260 match self.update_store(|data| {
1261 let importance =
1262 (data.task(&id)?.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
1263 data.set_task_importance(&id, importance)
1264 }) {
1265 Ok(_) => self.select_task_by_id(&id),
1266 Err(error) => self.report_store_error("Could not update task", error),
1267 }
1268 }
1269 }
1270
1271 pub fn move_task_order(&mut self, delta: isize) -> bool {
1275 if delta == 0 || self.settings.sort != "manual" || self.searching {
1276 return false;
1277 }
1278 let Some(current) = self.selected_task().cloned() else {
1279 return false;
1280 };
1281 let target_view = self.task_index as isize + delta.signum();
1282 if !(0..self.view.len() as isize).contains(&target_view) {
1283 return false;
1284 }
1285 let Some(target) = self.visible_task(target_view as usize) else {
1286 return false;
1287 };
1288 if target.category_id != current.category_id {
1289 return false;
1290 }
1291 let target_id = target.id.clone();
1292 let id = current.id;
1293 let position = if delta.is_negative() {
1294 RelativePosition::Before
1295 } else {
1296 RelativePosition::After
1297 };
1298 match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
1299 Ok(_) => {
1300 self.select_task_by_id(&id);
1301 true
1302 }
1303 Err(error) => {
1304 self.report_store_error("Could not reorder task", error);
1305 false
1306 }
1307 }
1308 }
1309
1310 pub fn open_new_task(&mut self) {
1312 if self.tasks.len() >= MAX_TASK_COUNT {
1313 self.error(format!(
1314 "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1315 ));
1316 return;
1317 }
1318 let mut form = TaskForm::new();
1319 let category = (!self.is_all_view()).then(|| self.current_category_id());
1320 form.set_categories(&self.categories, category);
1321 form.set_image_root(self.images.root().to_path_buf());
1322 form.set_attachments(&self.attachments);
1323 self.task_edit_base = None;
1324 self.form = Some(form);
1325 self.mode = Mode::TaskForm;
1326 }
1327
1328 pub fn open_edit_task(&mut self) {
1330 if let Some(task) = self.selected_task().cloned() {
1331 let mut form = TaskForm::edit(&task);
1332 form.set_categories(&self.categories, task.category_id.as_deref());
1333 form.set_image_root(self.images.root().to_path_buf());
1334 form.set_attachments(&self.attachments);
1335 self.images.prefetch(form.body.images());
1338 self.task_edit_base = Some(task);
1339 self.form = Some(form);
1340 self.mode = Mode::TaskForm;
1341 }
1342 }
1343
1344 pub fn close_form(&mut self) {
1345 self.form = None;
1346 self.task_edit_base = None;
1347 self.mode = Mode::Normal;
1348 self.focus = Focus::Tasks;
1349 self.images.release_form_graphics();
1352 self.images.clear_preview();
1353 self.cancel_pending();
1354 }
1355
1356 pub fn submit_form(&mut self) {
1358 let Some(form) = &mut self.form else { return };
1359 let Some(draft) = form.submit() else { return };
1360 let saved = match form.editing.clone() {
1361 Some(uuid) => self.update_task(&uuid, &draft),
1362 None => self.create_task(&draft).is_some(),
1363 };
1364 if saved {
1365 self.close_form();
1366 }
1367 }
1368
1369 pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1372 let (inline_due, title) = due::parse(draft.title.trim());
1373 if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1374 return None;
1375 }
1376 let due = if draft.due.is_empty() {
1377 &inline_due
1378 } else {
1379 &draft.due
1380 };
1381 let body = draft.body.clone();
1382 let category_id = draft.category_id.clone();
1383 let importance = draft.importance;
1384 let task = match self.update_store(|data| {
1385 data.create_task(title, body, due.to_string(), importance, category_id)
1386 }) {
1387 Ok(task) => task,
1388 Err(error) => {
1389 let message = error.to_string();
1390 if let Some(form) = &mut self.form {
1391 form.error = Some(message.clone());
1392 }
1393 self.report_store_error("Could not create task", error);
1394 return None;
1395 }
1396 };
1397 let id = task.id;
1398 self.searching = false;
1399 self.search_query.clear();
1400 self.rebuild_view();
1401 self.select_task_by_id(&id);
1402 Some(id)
1403 }
1404
1405 pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1406 let (inline_due, title) = due::parse(draft.title.trim());
1407 if title.is_empty() {
1408 return false;
1409 }
1410 let due = if draft.due.is_empty() {
1411 &inline_due
1412 } else {
1413 &draft.due
1414 };
1415 let expected = self.task_edit_base.clone();
1416 let id = id.to_string();
1417 let due = due.to_string();
1418 let patch = match expected.as_ref() {
1419 Some(base) => TaskPatch {
1420 title: (title != base.title).then_some(title),
1421 body: (draft.body != base.body).then(|| draft.body.clone()),
1422 due: (due != base.due).then_some(due),
1423 importance: (draft.importance != base.importance).then_some(draft.importance),
1424 category_id: (draft.category_id != base.category_id)
1425 .then(|| draft.category_id.clone()),
1426 ..TaskPatch::default()
1427 },
1428 None => TaskPatch {
1429 title: Some(title),
1430 body: Some(draft.body.clone()),
1431 due: Some(due),
1432 importance: Some(draft.importance),
1433 category_id: Some(draft.category_id.clone()),
1434 ..TaskPatch::default()
1435 },
1436 };
1437 match self.update_store(|data| {
1438 if let Some(expected) = &expected {
1439 data.edit_task_if_unchanged(expected, patch)
1440 } else {
1441 data.edit_task(&id, patch)
1442 }
1443 }) {
1444 Ok(_) => {
1445 self.select_task_by_id(&id);
1446 true
1447 }
1448 Err(error) => {
1449 let message = edit_error_message(&error);
1450 if let Some(form) = &mut self.form {
1451 form.error = Some(message);
1452 }
1453 self.report_store_error("Could not update task", error);
1454 false
1455 }
1456 }
1457 }
1458
1459 pub fn delete_task(&mut self, pos: usize) {
1460 let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1461 return;
1462 };
1463 self.delete_task_by_id(&id);
1464 }
1465
1466 pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1467 let id = id.to_string();
1468 if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1469 self.report_store_error("Could not delete task", error);
1470 return false;
1471 }
1472 self.cancel_pending();
1473 true
1474 }
1475
1476 pub fn purge(&mut self) -> usize {
1479 let ids = self.purge_candidate_ids();
1480 self.purge_ids(&ids)
1481 }
1482
1483 pub fn purge_candidate_ids(&self) -> Vec<String> {
1485 let everywhere = self.is_all_view();
1486 let category = self.current_category_id();
1487 self.tasks
1488 .iter()
1489 .filter(|task| {
1490 task.done && (everywhere || task.category_id.as_deref() == Some(category))
1491 })
1492 .map(|task| task.id.clone())
1493 .collect()
1494 }
1495
1496 pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1498 let ids = ids.to_vec();
1499 match self.update_store(|data| data.purge_completed_ids(&ids)) {
1500 Ok(removed) => {
1501 self.cancel_pending();
1502 removed.len()
1503 }
1504 Err(error) => {
1505 self.report_store_error("Could not purge completed tasks", error);
1506 0
1507 }
1508 }
1509 }
1510
1511 pub fn toggle_hide_done(&mut self) -> Option<bool> {
1513 match self.update_store(|data| {
1514 data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1515 }) {
1516 Ok(settings) => Some(settings.hide_done),
1517 Err(error) => {
1518 self.report_store_error("Could not update settings", error);
1519 None
1520 }
1521 }
1522 }
1523
1524 pub fn open_new_category(&mut self) {
1528 let real = self.categories.iter().filter(|c| !c.is_all()).count();
1530 if real >= MAX_CATEGORY_COUNT {
1531 self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1532 return;
1533 }
1534 self.category_edit_base = None;
1535 self.category_form = Some(CategoryForm::new());
1536 self.mode = Mode::CategoryForm;
1537 }
1538
1539 pub fn open_edit_category(&mut self) {
1542 if self.is_all_view() {
1543 return;
1544 }
1545 if let Some(category) = self.categories.get(self.cat_index).cloned() {
1546 self.category_form = Some(CategoryForm::edit(&category));
1547 self.category_edit_base = Some(category);
1548 self.mode = Mode::CategoryForm;
1549 }
1550 }
1551
1552 pub fn close_category_form(&mut self) {
1553 self.category_form = None;
1554 self.category_edit_base = None;
1555 self.mode = Mode::Normal;
1556 self.cancel_pending();
1557 }
1558
1559 pub fn submit_category_form(&mut self) {
1560 let existing: Vec<(String, String)> = self
1561 .categories
1562 .iter()
1563 .filter(|category| !category.is_all())
1564 .map(|category| (category.id.clone(), category.name.clone()))
1565 .collect();
1566 let Some(form) = &mut self.category_form else {
1567 return;
1568 };
1569 let Some((name, description)) = form.submit_with(|name, editing| {
1570 let duplicate = existing.iter().any(|(id, existing_name)| {
1571 Some(id.as_str()) != editing
1572 && caseless_key(existing_name.trim()) == caseless_key(name.trim())
1573 });
1574 if duplicate {
1575 Err("A category with that name already exists".to_string())
1576 } else {
1577 Ok(())
1578 }
1579 }) else {
1580 return;
1581 };
1582 let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1583 let editing = form.editing.clone();
1584 let expected = self.category_edit_base.clone();
1585 let saved = match editing {
1586 Some(id) => {
1587 let patch = match expected.as_ref() {
1588 Some(base) => CategoryPatch {
1589 name: (name != base.name).then_some(name),
1590 description: (description != base.description).then_some(description),
1591 },
1592 None => CategoryPatch {
1593 name: Some(name),
1594 description: Some(description),
1595 },
1596 };
1597 match self.update_store(|data| {
1598 if let Some(expected) = &expected {
1599 data.edit_category_if_unchanged(expected, patch)
1600 } else {
1601 data.edit_category(&id, patch)
1602 }
1603 }) {
1604 Ok(_) => true,
1605 Err(error) => {
1606 let message = edit_error_message(&error);
1607 if let Some(form) = &mut self.category_form {
1608 form.error = Some(message);
1609 }
1610 self.report_store_error("Could not update category", error);
1611 false
1612 }
1613 }
1614 }
1615 None => match self.update_store(|data| data.create_category(name, description)) {
1616 Ok(category) => {
1617 self.cat_index = self
1618 .categories
1619 .iter()
1620 .position(|item| item.id == category.id)
1621 .unwrap_or(0);
1622 self.on_category_changed();
1623 true
1624 }
1625 Err(error) => {
1626 let message = error.to_string();
1627 if let Some(form) = &mut self.category_form {
1628 form.error = Some(message);
1629 }
1630 self.report_store_error("Could not create category", error);
1631 false
1632 }
1633 },
1634 };
1635 if saved {
1636 self.close_category_form();
1637 }
1638 }
1639
1640 pub fn delete_category(&mut self) {
1643 if self.is_all_view() {
1644 return;
1645 }
1646 let id = self.current_category_id().to_string();
1647 let _ = self.delete_category_by_id(&id);
1648 }
1649
1650 pub fn delete_category_by_id(&mut self, id: &str) -> bool {
1651 let Some(category) = self.categories.iter().find(|category| category.id == id) else {
1652 return false;
1653 };
1654 if category.is_all() {
1655 return false;
1656 }
1657 let id = id.to_string();
1658 match self.update_store(|data| data.delete_category(&id)) {
1659 Ok(_) => {
1660 self.cancel_pending();
1661 self.cat_index = 0;
1662 self.on_category_changed();
1663 true
1664 }
1665 Err(error) => {
1666 self.report_store_error("Could not delete category", error);
1667 false
1668 }
1669 }
1670 }
1671
1672 pub fn move_category_order(&mut self, delta: isize) -> bool {
1674 if delta == 0 || self.is_all_view() || self.searching {
1675 return false;
1676 }
1677 let target_display = self.cat_index as isize + delta.signum();
1678 if !(1..self.categories.len() as isize).contains(&target_display) {
1679 return false;
1680 }
1681 let id = self.current_category_id().to_string();
1682 let target_id = self.categories[target_display as usize].id.clone();
1683 let position = if delta.is_negative() {
1684 RelativePosition::Before
1685 } else {
1686 RelativePosition::After
1687 };
1688 match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
1689 Ok(_) => {
1690 self.cat_index = self
1691 .categories
1692 .iter()
1693 .position(|category| category.id == id)
1694 .unwrap_or(0);
1695 self.on_category_changed();
1696 true
1697 }
1698 Err(error) => {
1699 self.report_store_error("Could not reorder category", error);
1700 false
1701 }
1702 }
1703 }
1704
1705 pub fn category_progress(&self, id: &str) -> (usize, usize) {
1707 if let Some(idx) = self.categories.iter().position(|c| c.id == id)
1708 && let Some(&p) = self.cat_progress.get(idx)
1709 {
1710 return p;
1711 }
1712 (0, 0)
1713 }
1714
1715 pub fn open_slash(&mut self) {
1719 if self.searching {
1720 self.end_search();
1721 }
1722 if let Some(version) = self
1723 .update_notice
1724 .as_ref()
1725 .and_then(|notice| notice.available_version.clone())
1726 {
1727 self.dismissed_update_version = Some(version);
1728 }
1729 self.update_notice = None;
1730 self.mode = Mode::Slash;
1731 self.input = TextInput::new("", MAX_SLASH_INPUT_LEN);
1732 self.slash_index = 0;
1733 self.dirty = true;
1734 }
1735
1736 pub fn start_search(&mut self, query: &str) {
1738 self.mode = Mode::Search;
1739 self.focus = Focus::Tasks;
1740 self.input = TextInput::new(query, MAX_TITLE_LEN);
1741 self.search_query = query.to_string();
1742 self.searching = true;
1743 self.task_index = 0;
1744 self.rebuild_view();
1745 }
1746
1747 pub fn update_search(&mut self) {
1748 self.search_query = self.input.value();
1749 self.searching = true;
1750 self.task_index = 0;
1751 self.rebuild_view();
1752 }
1753
1754 pub fn resume_search(&mut self) {
1757 if !self.searching {
1758 return;
1759 }
1760 self.mode = Mode::Search;
1761 self.input = TextInput::new(&self.search_query, MAX_TITLE_LEN);
1762 self.dirty = true;
1763 }
1764
1765 pub fn end_search(&mut self) {
1766 self.searching = false;
1767 self.search_query.clear();
1768 self.task_index = 0;
1769 self.mode = Mode::Normal;
1770 self.rebuild_view();
1771 }
1772
1773 pub fn clamp_slash_index(&mut self) {
1774 let n = crate::slash::matching(&self.input.value()).len();
1775 if n == 0 {
1776 self.slash_index = 0;
1777 } else {
1778 self.slash_index = self.slash_index.min(n - 1);
1779 }
1780 }
1781
1782 pub fn info(&mut self, text: impl Into<String>) {
1785 self.set_message(text.into(), MessageKind::Info, MessageLifetime::Brief);
1786 }
1787
1788 pub(crate) fn archive_result(&mut self, text: impl Into<String>) {
1789 self.set_message(text.into(), MessageKind::Info, MessageLifetime::Long);
1790 }
1791
1792 pub fn error(&mut self, text: impl Into<String>) {
1793 self.set_message(text.into(), MessageKind::Error, MessageLifetime::Standard);
1794 }
1795
1796 pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
1797 self.message
1798 .as_ref()
1799 .map(|message| (message.text.as_str(), message.kind))
1800 .or_else(|| {
1801 self.update_notice
1802 .as_ref()
1803 .map(|notice| (notice.text.as_str(), MessageKind::Info))
1804 })
1805 }
1806
1807 pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
1808 self.update_activity
1809 }
1810
1811 pub(crate) fn update_work_active(&self) -> bool {
1812 self.update_job
1813 .as_ref()
1814 .is_some_and(|job| job.kind == UpdateJobKind::Install)
1815 }
1816
1817 fn set_message(&mut self, text: String, kind: MessageKind, lifetime: MessageLifetime) {
1818 self.set_message_until(text, kind, Instant::now() + lifetime.duration());
1819 }
1820
1821 fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
1822 self.pending = None;
1826 self.message = Some(Message { text, kind, until });
1827 self.dirty = true;
1828 }
1829
1830 pub fn expire_message(&mut self) -> bool {
1832 if let Some(m) = &self.message
1833 && Instant::now() >= m.until
1834 {
1835 self.pending = None;
1836 self.message = None;
1837 self.dirty = true;
1838 return true;
1839 }
1840 false
1841 }
1842
1843 pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
1845 let until = Instant::now() + MessageLifetime::Brief.duration();
1846 self.set_message_until(prompt.into(), MessageKind::Info, until);
1847 self.pending = Some((confirm, until));
1848 }
1849
1850 pub fn awaiting(&self, confirm: Confirm) -> bool {
1852 matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
1853 }
1854
1855 pub fn pending_confirmation(&self) -> Option<&Confirm> {
1856 self.pending
1857 .as_ref()
1858 .filter(|(_, until)| Instant::now() < *until)
1859 .map(|(confirm, _)| confirm)
1860 }
1861
1862 pub fn cycle_setting(&mut self, index: usize, delta: isize) {
1866 use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
1867 if index >= SETTINGS_ITEMS.len() {
1868 return;
1869 }
1870 if let Err(error) = self.update_store(|data| {
1871 data.update_settings(|settings| match index {
1872 0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
1873 1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
1874 2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
1875 3 => {
1876 settings.preview_position =
1877 cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
1878 }
1879 _ => {}
1880 })
1881 }) {
1882 self.report_store_error("Could not update settings", error);
1883 }
1884 }
1885
1886 pub fn setting_value(&self, index: usize) -> String {
1887 match index {
1888 0 => crate::settings::sort_label(&self.settings.sort).to_string(),
1889 1 => crate::settings::theme_label(&self.settings.selected_color),
1890 2 => self.settings.date_format.clone(),
1891 3 => {
1892 crate::settings::preview_position_label(&self.settings.preview_position).to_string()
1893 }
1894 _ => String::new(),
1895 }
1896 }
1897}
1898
1899fn contains_ignore_case(haystack: &str, folded_needle: &str) -> bool {
1902 if folded_needle.is_empty() {
1903 return true;
1904 }
1905 if haystack.is_ascii() && folded_needle.is_ascii() {
1906 return haystack
1907 .as_bytes()
1908 .windows(folded_needle.len())
1909 .any(|w| w.eq_ignore_ascii_case(folded_needle.as_bytes()));
1910 }
1911 caseless_key(haystack).contains(folded_needle)
1912}
1913
1914fn body_contains(task: &Task, query: &str) -> bool {
1916 task.body.iter().any(|block| match block {
1917 crate::model::Block::Text { text }
1918 | crate::model::Block::Todo { text, .. }
1919 | crate::model::Block::Bullet { text }
1920 | crate::model::Block::Number { text }
1921 | crate::model::Block::Link { url: text } => contains_ignore_case(text, query),
1922 crate::model::Block::Image { .. } => false,
1923 })
1924}
1925
1926fn edit_error_message(error: &StoreError) -> String {
1927 match error {
1928 StoreError::StaleEntity { .. } => {
1929 format!("{error}; close and reopen the editor to load the latest values")
1930 }
1931 _ => error.to_string(),
1932 }
1933}
1934
1935pub fn truncate_chars(s: &str, max: usize) -> String {
1936 s.graphemes(true).take(max).collect()
1937}
1938
1939#[cfg(test)]
1940mod tests {
1941 use super::*;
1942
1943 fn assert_message_lifetime(app: &App, expected: Duration) {
1944 let remaining = app
1945 .message
1946 .as_ref()
1947 .expect("temporary message")
1948 .until
1949 .saturating_duration_since(Instant::now());
1950 assert!(remaining <= expected, "{remaining:?} exceeds {expected:?}");
1951 assert!(
1952 remaining >= expected.saturating_sub(Duration::from_millis(100)),
1953 "{remaining:?} is shorter than {expected:?}"
1954 );
1955 }
1956
1957 fn update_result(newer: bool) -> crate::update::CheckResult {
1958 crate::update::CheckResult {
1959 current: "0.2.0".into(),
1960 latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
1961 tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
1962 newer,
1963 prerelease: false,
1964 release_url: "https://example.test/release".into(),
1965 asset_name: "mach-aarch64-apple-darwin".into(),
1966 asset_url: "https://example.test/binary".into(),
1967 checksums_url: "https://example.test/SHA256SUMS".into(),
1968 }
1969 }
1970
1971 fn automatic_outcome(newer: bool) -> UpdateOutcome {
1972 UpdateOutcome::Automatic(CheckResponse::Modified {
1973 info: update_result(newer),
1974 etag: None,
1975 })
1976 }
1977
1978 fn update_failure(message: &str) -> CheckFailure {
1979 CheckFailure {
1980 message: message.into(),
1981 retry_at: None,
1982 }
1983 }
1984
1985 fn finished(result: Result<UpdateOutcome, CheckFailure>) -> UpdateEvent {
1986 UpdateEvent::Finished(Box::new(result))
1987 }
1988
1989 fn claim_automatic_lease(app: &mut App, now: i64) -> UpdateLease {
1990 let AutomaticClaim::Claimed(lease) = app
1991 .update_state
1992 .as_mut()
1993 .expect("test update state")
1994 .try_claim_automatic(now)
1995 .unwrap()
1996 else {
1997 panic!("automatic update should be due");
1998 };
1999 lease
2000 }
2001
2002 #[test]
2003 fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
2004 let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
2005 .expect("open in-memory store");
2006 let mut app = App::with_store("test", store).expect("build app");
2007 app.mode = Mode::Normal;
2008
2009 for _ in 0..(MAX_TITLE_LEN * 2) {
2010 app.typeahead_jump('x');
2011 }
2012
2013 assert!(
2014 app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
2015 "a held key must not grow the navigation query without bound"
2016 );
2017 }
2018
2019 #[test]
2020 fn transient_messages_use_three_shared_lifetimes() {
2021 let store = Store::open_in_memory_with_paths("/tmp/mach-message-lifetime-test")
2022 .expect("open in-memory store");
2023 let mut app = App::with_store("test", store).expect("build app");
2024
2025 app.info("brief info");
2026 assert_message_lifetime(&app, Duration::from_secs(2));
2027
2028 app.ask_confirm(Confirm::Quit, "brief confirmation");
2029 assert_message_lifetime(&app, Duration::from_secs(2));
2030
2031 app.error("standard error");
2032 assert_message_lifetime(&app, Duration::from_secs(4));
2033
2034 app.archive_result("long archive result");
2035 assert_message_lifetime(&app, Duration::from_secs(8));
2036
2037 app.show_update_message("long update result".into(), MessageKind::Info);
2038 assert_message_lifetime(&app, Duration::from_secs(8));
2039 }
2040
2041 #[test]
2042 fn automatic_update_claim_is_shared_across_task_stores() {
2043 let root = std::env::temp_dir().join(format!(
2044 "mach-update-claim-{}-{}",
2045 std::process::id(),
2046 uuid::Uuid::new_v4()
2047 ));
2048 let now = 1_800_000_000;
2049 let state_path = root.join("global").join("update.db");
2050 let mut first = App::with_store_and_update_state(
2051 "test",
2052 Store::open(root.join("one")).unwrap(),
2053 UpdateStateStore::open(&state_path),
2054 )
2055 .unwrap();
2056
2057 assert!(matches!(
2058 first
2059 .update_state
2060 .as_mut()
2061 .unwrap()
2062 .try_claim_automatic(now)
2063 .unwrap(),
2064 AutomaticClaim::Claimed(_)
2065 ));
2066 drop(first);
2067
2068 let mut second = App::with_store_and_update_state(
2069 "test",
2070 Store::open(root.join("two")).unwrap(),
2071 UpdateStateStore::open(&state_path),
2072 )
2073 .unwrap();
2074 assert!(matches!(
2075 second
2076 .update_state
2077 .as_mut()
2078 .unwrap()
2079 .try_claim_automatic(now)
2080 .unwrap(),
2081 AutomaticClaim::Waiting(_)
2082 ));
2083 drop(second);
2084 std::fs::remove_dir_all(root).unwrap();
2085 }
2086
2087 #[test]
2088 fn failed_automatic_update_check_retries_before_the_daily_interval() {
2089 let store = Store::open_in_memory_with_paths("/tmp/mach-update-retry-test").unwrap();
2090 let mut app = App::with_store("test", store).unwrap();
2091 let now = Utc::now().timestamp();
2092
2093 let lease = claim_automatic_lease(&mut app, now);
2094 let (tx, rx) = mpsc::channel();
2095 app.update_job = Some(UpdateJob {
2096 rx,
2097 kind: UpdateJobKind::Automatic,
2098 lease: Some(lease),
2099 });
2100 tx.send(finished(Err(update_failure("offline")))).unwrap();
2101
2102 assert!(!app.poll_update());
2103 let retry_at = app
2104 .update_state
2105 .as_ref()
2106 .unwrap()
2107 .snapshot()
2108 .unwrap()
2109 .next_check_at
2110 .expect("failed check schedules a retry");
2111 assert!(retry_at < now + 24 * 60 * 60);
2112 assert!(matches!(
2113 app.update_state
2114 .as_mut()
2115 .unwrap()
2116 .try_claim_automatic(retry_at)
2117 .unwrap(),
2118 AutomaticClaim::Claimed(_)
2119 ));
2120 }
2121
2122 #[test]
2123 fn available_update_notice_survives_reopening_the_app() {
2124 let dir = std::env::temp_dir().join(format!(
2125 "mach-update-notice-{}-{}",
2126 std::process::id(),
2127 uuid::Uuid::new_v4()
2128 ));
2129 let state_path = dir.join("global-update.db");
2130 let mut app = App::with_store_and_update_state(
2131 "0.2.0",
2132 Store::open(dir.join("tasks")).unwrap(),
2133 UpdateStateStore::open(&state_path),
2134 )
2135 .unwrap();
2136 let lease = claim_automatic_lease(&mut app, Utc::now().timestamp());
2137 let (tx, rx) = mpsc::channel();
2138 app.update_job = Some(UpdateJob {
2139 rx,
2140 kind: UpdateJobKind::Automatic,
2141 lease: Some(lease),
2142 });
2143 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2144
2145 assert!(app.poll_update());
2146 drop(app);
2147
2148 let reopened = App::with_store_and_update_state(
2149 "0.2.0",
2150 Store::open(dir.join("tasks")).unwrap(),
2151 UpdateStateStore::open(&state_path),
2152 )
2153 .unwrap();
2154 assert!(
2155 reopened
2156 .status_message()
2157 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2158 );
2159 drop(reopened);
2160 std::fs::remove_dir_all(dir).unwrap();
2161 }
2162
2163 #[test]
2164 fn available_update_notice_reaches_an_already_running_instance() {
2165 let root = std::env::temp_dir().join(format!(
2166 "mach-running-update-notice-{}-{}",
2167 std::process::id(),
2168 uuid::Uuid::new_v4()
2169 ));
2170 let state_path = root.join("global-update.db");
2171 let mut checker = App::with_store_and_update_state(
2172 "0.2.0",
2173 Store::open(root.join("tasks-one")).unwrap(),
2174 UpdateStateStore::open(&state_path),
2175 )
2176 .unwrap();
2177 let mut observer = App::with_store_and_update_state(
2178 "0.2.0",
2179 Store::open(root.join("tasks-two")).unwrap(),
2180 UpdateStateStore::open(&state_path),
2181 )
2182 .unwrap();
2183 let now = Utc::now().timestamp();
2184 let lease = claim_automatic_lease(&mut checker, now);
2185 let (tx, rx) = mpsc::channel();
2186 checker.update_job = Some(UpdateJob {
2187 rx,
2188 kind: UpdateJobKind::Automatic,
2189 lease: Some(lease),
2190 });
2191 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2192
2193 assert!(checker.poll_update());
2194 assert!(observer.poll_automatic_update_schedule_at(now + 1));
2195 assert!(
2196 observer
2197 .status_message()
2198 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2199 );
2200 drop(checker);
2201 drop(observer);
2202 std::fs::remove_dir_all(root).unwrap();
2203 }
2204
2205 #[test]
2206 fn cached_latest_release_is_compared_per_running_binary() {
2207 let root = std::env::temp_dir().join(format!(
2208 "mach-multiple-binaries-{}-{}",
2209 std::process::id(),
2210 uuid::Uuid::new_v4()
2211 ));
2212 let state_path = root.join("global-update.db");
2213 let now = 1_800_000_000;
2214 let mut state = UpdateStateStore::open(&state_path).unwrap();
2215 let AutomaticClaim::Claimed(lease) = state.try_claim_automatic(now).unwrap() else {
2216 panic!("first check should be due");
2217 };
2218 state.finish_modified(&lease, now, None, "0.3.0").unwrap();
2219 drop(state);
2220
2221 let current = App::with_store_and_update_state(
2222 "0.3.0",
2223 Store::open(root.join("current-tasks")).unwrap(),
2224 UpdateStateStore::open(&state_path),
2225 )
2226 .unwrap();
2227 assert!(current.status_message().is_none());
2228 drop(current);
2229
2230 let older = App::with_store_and_update_state(
2231 "0.2.0",
2232 Store::open(root.join("older-tasks")).unwrap(),
2233 UpdateStateStore::open(&state_path),
2234 )
2235 .unwrap();
2236 assert!(
2237 older
2238 .status_message()
2239 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2240 );
2241 drop(older);
2242 std::fs::remove_dir_all(root).unwrap();
2243 }
2244
2245 #[test]
2246 fn upgraded_version_shows_whats_new_once() {
2247 let dir = std::env::temp_dir().join(format!(
2248 "mach-whats-new-{}-{}",
2249 std::process::id(),
2250 uuid::Uuid::new_v4()
2251 ));
2252 let mut store = Store::open(&dir).unwrap();
2253 store
2254 .update(|data| {
2255 data.settings.last_run_version = Some("0.1.9".into());
2256 Ok(())
2257 })
2258 .unwrap();
2259
2260 let first = App::with_store("0.2.0", store).unwrap();
2261 assert_eq!(first.mode, Mode::WhatsNew);
2262 drop(first);
2263
2264 let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
2265 assert_eq!(second.mode, Mode::Normal);
2266 drop(second);
2267 std::fs::remove_dir_all(dir).unwrap();
2268 }
2269
2270 #[test]
2271 fn tui_update_install_success_requests_restart() {
2272 let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
2273 let mut app = App::with_store("test", store).unwrap();
2274 let (tx, rx) = mpsc::channel();
2275 app.update_job = Some(UpdateJob {
2276 rx,
2277 kind: UpdateJobKind::Install,
2278 lease: None,
2279 });
2280 app.update_activity = Some(UpdateActivity::Checking);
2281 tx.send(finished(Ok(UpdateOutcome::Installed {
2282 result: crate::update::InstallResult {
2283 destination: "/tmp/mach-bin/mach".into(),
2284 tag: "v0.3.0".into(),
2285 },
2286 info: update_result(true),
2287 etag: None,
2288 })))
2289 .unwrap();
2290
2291 assert!(app.poll_update());
2292 assert_eq!(
2293 app.status_message().map(|(text, _)| text),
2294 Some("Installed v0.3.0 · restart mach")
2295 );
2296 assert!(app.update_activity().is_none());
2297
2298 assert!(!app.expire_message());
2299 assert_eq!(
2300 app.status_message().map(|(text, _)| text),
2301 Some("Installed v0.3.0 · restart mach")
2302 );
2303
2304 app.open_slash();
2305 assert!(app.status_message().is_none());
2306 }
2307
2308 #[test]
2309 fn update_download_progress_is_applied_before_the_final_result() {
2310 let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
2311 let mut app = App::with_store("test", store).unwrap();
2312 let (tx, rx) = mpsc::channel();
2313 app.update_job = Some(UpdateJob {
2314 rx,
2315 kind: UpdateJobKind::Install,
2316 lease: None,
2317 });
2318 app.update_activity = Some(UpdateActivity::Checking);
2319 tx.send(UpdateEvent::DownloadProgress(
2320 crate::update::DownloadProgress {
2321 downloaded: 512,
2322 total: Some(1024),
2323 },
2324 ))
2325 .unwrap();
2326
2327 assert!(app.poll_update());
2328 assert_eq!(
2329 app.update_activity(),
2330 Some(UpdateActivity::Downloading(
2331 crate::update::DownloadProgress {
2332 downloaded: 512,
2333 total: Some(1024),
2334 }
2335 ))
2336 );
2337 }
2338
2339 #[test]
2340 fn tui_update_install_error_keeps_the_recovery_command() {
2341 let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
2342 let mut app = App::with_store("test", store).unwrap();
2343 let (tx, rx) = mpsc::channel();
2344 app.update_job = Some(UpdateJob {
2345 rx,
2346 kind: UpdateJobKind::Install,
2347 lease: None,
2348 });
2349 tx.send(finished(Ok(UpdateOutcome::InstallFailed {
2350 message:
2351 "this mach executable is managed by Cargo; run cargo install --locked mach-tui"
2352 .into(),
2353 info: update_result(true),
2354 etag: None,
2355 })))
2356 .unwrap();
2357
2358 assert!(app.poll_update());
2359 let message = app.message.as_ref().expect("visible install error");
2360 assert_eq!(message.kind, MessageKind::Error);
2361 assert!(message.text.contains("cargo install --locked mach-tui"));
2362 }
2363
2364 #[test]
2365 fn automatic_update_results_are_silent_unless_a_new_version_exists() {
2366 let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
2367 let mut app = App::with_store("0.2.0", store).unwrap();
2368 let (tx, rx) = mpsc::channel();
2369 app.update_job = Some(UpdateJob {
2370 rx,
2371 kind: UpdateJobKind::Automatic,
2372 lease: None,
2373 });
2374 tx.send(finished(Ok(automatic_outcome(false)))).unwrap();
2375
2376 assert!(!app.poll_update());
2377 assert!(app.message.is_none());
2378
2379 let (tx, rx) = mpsc::channel();
2380 app.update_job = Some(UpdateJob {
2381 rx,
2382 kind: UpdateJobKind::Automatic,
2383 lease: None,
2384 });
2385 tx.send(finished(Err(update_failure("offline")))).unwrap();
2386
2387 assert!(!app.poll_update());
2388 assert!(app.message.is_none());
2389 }
2390
2391 #[test]
2392 fn automatic_update_notice_waits_for_an_active_confirmation() {
2393 let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
2394 let mut app = App::with_store("0.2.0", store).unwrap();
2395 app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
2396 let (tx, rx) = mpsc::channel();
2397 app.update_job = Some(UpdateJob {
2398 rx,
2399 kind: UpdateJobKind::Automatic,
2400 lease: None,
2401 });
2402 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2403
2404 assert!(!app.poll_update());
2405 assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
2406 assert_eq!(
2407 app.message.as_ref().map(|message| message.text.as_str()),
2408 Some("Press Ctrl+C again to quit")
2409 );
2410
2411 app.cancel_pending();
2412 assert!(app.status_message().is_some_and(|(text, _)| {
2413 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
2414 }));
2415
2416 app.info("Temporary action result");
2417 assert_eq!(
2418 app.status_message().map(|(text, _)| text),
2419 Some("Temporary action result")
2420 );
2421 app.message.as_mut().unwrap().until = Instant::now();
2422 assert!(app.expire_message());
2423 assert!(app.status_message().is_some_and(|(text, _)| {
2424 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
2425 }));
2426
2427 app.open_slash();
2428 assert!(app.status_message().is_none());
2429 }
2430}