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