Skip to main content

mach/
app.rs

1//! Application state and every operation the UI can trigger.
2
3use 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    /// The `/` command palette (dropdown above the status bar).
41    Slash,
42    /// Live search after choosing Search from the palette.
43    Search,
44    /// The task dialog (new or edit).
45    TaskForm,
46    /// The category dialog (new or edit).
47    CategoryForm,
48    Help,
49    Settings,
50    Welcome,
51    WhatsNew,
52}
53
54impl Mode {
55    /// The bottom command bar, rather than either list panel, owns input.
56    pub fn command_bar_focused(self) -> bool {
57        matches!(self, Mode::Slash | Mode::Search)
58    }
59
60    /// Anything drawn on top of the two panels.
61    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/// Something destructive waiting on a second press of the same key. Only
104/// one can be armed at a time, so a half-typed delete cannot survive
105/// behind a quit prompt.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum Confirm {
108    /// Backspace again deletes this exact task.
109    DeleteTask(String),
110    /// Backspace again deletes this exact category; its tasks become uncategorized.
111    DeleteCategory(String),
112    /// Enter purges this exact set of completed task ids.
113    Purge(Vec<String>),
114    /// Esc again discards the current task/category draft.
115    DiscardTask(Option<String>),
116    DiscardCategory(Option<String>),
117    /// Ctrl+C again leaves mach.
118    Quit,
119}
120
121/// Idle gap after which type-to-jump starts a new query.
122const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
123/// Bound free-form commands while leaving room for full filesystem paths.
124const 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/// Rects from the last frame, used to hit-test mouse events.
173#[derive(Debug, Default, Clone, Copy)]
174pub struct Areas {
175    pub sidebar: Rect,
176    pub tasks: Rect,
177    /// Inner row of the bottom command bar, including its clock.
178    pub command_bar: Rect,
179    /// Bottom-right task preview / docked editor, when the window is tall enough.
180    pub preview: Rect,
181    /// Screen columns of the flag and done markers, as the table laid
182    /// them out. `done_x` is the left edge of the `[ ]`/`[✓]` column
183    /// (see `ui::DONE_MARK_WIDTH`). The flag column is always reserved
184    /// for up to three flags.
185    pub flag_x: Option<u16>,
186    pub done_x: Option<u16>,
187    /// Open top-level command palette, including its border.
188    pub slash_menu: Rect,
189    /// Index of the first command drawn inside a clipped command palette.
190    pub slash_menu_start: usize,
191}
192
193pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Task preview"];
194
195/// One row of the task table: a real task, or a category section header
196/// (All Tasks / search only). Headers are not selectable.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum TaskListRow {
199    Separator {
200        title: String,
201    },
202    /// Index into [`App::view`].
203    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    /// Index into `categories`.
216    pub cat_index: usize,
217    /// Index into `view`.
218    pub task_index: usize,
219    /// Scroll position of the two panels. Selection is driven by the two
220    /// indices above; ratatui keeps the offsets in these.
221    pub cat_state: ListState,
222    pub task_state: TableState,
223    /// Indices into `tasks`, in display order.
224    pub view: Vec<usize>,
225    /// Table rows including category separators. Parallel to what is drawn;
226    /// selection still uses `task_index` into `view`.
227    pub list_rows: Vec<TaskListRow>,
228    pub searching: bool,
229    pub search_query: String,
230    pub input: TextInput,
231    /// Selected row in the `/` palette dropdown.
232    pub slash_index: usize,
233    /// The open task dialog, if any.
234    pub form: Option<TaskForm>,
235    /// The open category dialog, if any.
236    pub category_form: Option<CategoryForm>,
237    pub settings_index: usize,
238    /// First help content row currently visible.
239    pub help_scroll: usize,
240    pub message: Option<Message>,
241    /// Pending entity-bound destructive action and its deadline.
242    pub pending: Option<(Confirm, Instant)>,
243    /// Last click `(time, panel, row)` for double-click detection.
244    pub last_click: Option<(Instant, Focus, usize)>,
245    pub should_quit: bool,
246    pub areas: Areas,
247    /// Body/preview image store.
248    pub images: ImageStore,
249    pub(crate) attachments: Vec<Attachment>,
250    /// Type-to-jump buffer (Tasks/Sidebar focus); cleared on timeout.
251    typeahead: String,
252    typeahead_at: Option<Instant>,
253    /// Needs a redraw.
254    pub dirty: bool,
255    /// Incremented on task mutation (invalidates preview cache).
256    pub data_gen: u64,
257    /// Per-category `(done, total)`, parallel to `categories`.
258    cat_progress: Vec<(usize, usize)>,
259    /// Cached body editor for the read-only preview pane.
260    pub preview_form: Option<TaskForm>,
261    preview_task_id: Option<String>,
262    preview_gen: u64,
263    /// Entity snapshots captured when an edit dialog opens. Save compares only
264    /// editable fields so unrelated changes (for example, another agent
265    /// toggling `done`) are preserved instead of becoming false conflicts.
266    task_edit_base: Option<Task>,
267    category_edit_base: Option<Category>,
268    /// One in-flight automatic check or explicit install.
269    update_job: Option<UpdateJob>,
270    /// Application-level update scheduling state, independent of task data.
271    update_state: Option<UpdateStateStore>,
272    /// Wall-clock deadline for the next cheap state refresh.
273    next_update_state_poll_at: i64,
274    /// Update notices survive ordinary status messages and clear only when the
275    /// user opens the `/` command palette.
276    update_notice: Option<UpdateNotice>,
277    /// An available version dismissed in this process stays dismissed until a
278    /// newer release is discovered.
279    dismissed_update_version: Option<String>,
280    /// Visible work for an explicit `/update` request.
281    update_activity: Option<UpdateActivity>,
282    /// Whether persistence polling has failed since its last successful pass.
283    /// Repeated failures are quiet so they cannot continuously replace messages
284    /// or disarm destructive confirmations; success rearms reporting.
285    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        // The transaction reads fresh state, so two concurrently-starting
307        // processes cannot both claim the same first run or upgrade.
308        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        // "All Tasks" is a view only — prepended in memory, never saved.
322        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    /// Refresh after another process commits. Dialogs deliberately defer the
397    /// visual refresh: their entity snapshot is checked transactionally when
398    /// the user saves, so typed work is never replaced under the cursor.
399    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    /// Commit against the transaction's fresh snapshot and apply the exact
497    /// normalized state returned after a successful commit.
498    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    /// Refresh global state and start a due automatic check. The event loop
514    /// calls this throughout the process lifetime; local polling is capped at
515    /// once per minute while the SQLite deadline remains the source of truth.
516    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    /// Explicitly check for and install the latest verified release (`/update`).
588    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        // Explicit user intent supersedes an automatic check. Its detached
600        // worker may finish, but dropping the receiver prevents a stale result
601        // from competing with the install result in the UI.
602        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    /// Apply finished update work, if any. Returns true when UI should redraw.
674    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            // Manual checks remain useful when application-level persistence
782            // is unavailable; automatic workers always carry a lease.
783            _ => 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    /// Rebuild [`Self::preview_form`] if the selection or `data_gen` changed.
868    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    // ---------------------------------------------------------------- view
898
899    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    /// Recompute which tasks are shown and in what order.
918    ///
919    /// Sort applies **inside** each category. All Tasks (and search) stack
920    /// those already-sorted groups in sidebar order; a single category is
921    /// just one group.
922    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        // Multi-category views: stack each category's sorted slice.
954        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    /// Table rows for the current `view`. Multi-category lists get a
971    /// section header before each group; a single category is tasks only.
972    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    /// Visual table row for the selected task, if any.
994    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    /// `view` index under a visual table row, or `None` for a separator.
1001    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    /// Sidebar order of real categories, each group sorted; uncategorized last.
1009    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        // Anything not filed under a known category (or left uncategorized).
1026        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    /// Apply the settings sort to one category's rows (or a rest bucket).
1033    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            _ => {} // manual — keep the store's explicit task order
1042        }
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    // ----------------------------------------------------------- selection
1062
1063    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    /// Type-to-jump: append `c` and select the best fuzzy match (list unchanged).
1090    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    /// ↑/↓ stay inside the focused panel. Cross-panel moves use ←/→ or Tab.
1138    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    /// Move keyboard focus. A locked search owns the task list until Esc.
1185    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    // ------------------------------------------------------------ mutation
1210
1211    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    /// Keep the same task selected after the view is rebuilt and rows move.
1238    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    /// Steps a task's importance up, wrapping back to none after three.
1257    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    /// Reorder the selected task inside its category when using manual sort.
1272    /// In All Tasks, crossing a category section boundary is intentionally
1273    /// blocked; changing category belongs in the task form.
1274    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    /// Opens the dialog for a new task, unless the list is full.
1311    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    /// Opens the dialog on the selected task.
1329    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            // Decode body pictures off the UI thread so the dialog opens
1336            // immediately; they fill in on the next frames.
1337            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        // Drop placed graphics so they do not float over the list; pixels
1350        // stay in RAM for a fast reopen. GIF frames are dropped with the form.
1351        self.images.release_form_graphics();
1352        self.images.clear_preview();
1353        self.cancel_pending();
1354    }
1355
1356    /// Validates the open form and writes it back to the task list.
1357    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    /// Creates a task in the chosen category and selects it. A `[date]`
1370    /// left in the title is moved into `due` when `due` is empty.
1371    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    /// Permanently remove done tasks. In All Tasks → every done task; in a
1477    /// category → only that category's done tasks. Nothing is archived.
1478    pub fn purge(&mut self) -> usize {
1479        let ids = self.purge_candidate_ids();
1480        self.purge_ids(&ids)
1481    }
1482
1483    /// Completed task ids in the current purge scope, captured for confirmation.
1484    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    /// Purge exactly the confirmed ids; newly completed tasks are never swept in.
1497    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    /// `/done` — show or hide completed tasks in the list (still on disk).
1512    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    // ---------------------------------------------------------- categories
1525
1526    /// Opens the dialog for a new category.
1527    pub fn open_new_category(&mut self) {
1528        // Count real categories (exclude the virtual All row).
1529        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    /// Opens the dialog on the selected category. "All Tasks" is not a
1540    /// real category and cannot be edited.
1541    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    /// Deletes the category while preserving its tasks as Uncategorized.
1641    /// Category ids are stable UUIDs — no renumbering.
1642    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    /// Reorder real categories while keeping the virtual All Tasks row fixed.
1673    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    /// `(done, total)` for a category. All Tasks counts every task.
1706    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    // -------------------------------------------------------------- slash / search
1716
1717    /// Open the `/` command palette.
1718    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    /// Enter live search, optionally with an initial query.
1737    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    /// Return keyboard input to an already locked search without rebuilding
1755    /// the view or moving its selected task.
1756    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    // ------------------------------------------------------------ messages
1783
1784    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        // A confirmation is only safe while its matching prompt is visible.
1823        // Any independent status replaces that prompt and therefore disarms
1824        // the pending destructive action as part of the same state change.
1825        self.pending = None;
1826        self.message = Some(Message { text, kind, until });
1827        self.dirty = true;
1828    }
1829
1830    /// Drop expired status messages. Returns true when the UI should redraw.
1831    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    /// Arm a destructive key on its second press, and say so.
1844    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    /// Whether `confirm` is armed and still inside its window.
1851    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    // ----------------------------------------------------------- settings
1863
1864    /// Step a settings row by `delta` (+1 forward, −1 back), wrapping.
1865    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
1899/// Unicode-caseless contains. `folded_needle` must already be normalized.
1900/// ASCII path avoids allocating.
1901fn 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
1914/// Whether any prose or to-do in the body mentions `query`.
1915fn 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}