Skip to main content

mach/
app.rs

1//! Application state and every operation the UI can trigger.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::mpsc::{self, Receiver, TryRecvError};
7use std::time::{Duration, Instant};
8
9use chrono::Utc;
10use ratatui::layout::Rect;
11use ratatui::widgets::{ListState, TableState};
12use unicode_segmentation::UnicodeSegmentation;
13
14use crate::due;
15use crate::form::{CategoryForm, TaskDraft, TaskForm};
16use crate::image::ImageStore;
17use crate::model::{
18    ALL_CATEGORY, Category, Label, LabelColor, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN,
19    MAX_LABEL_COUNT, MAX_LABEL_NAME_LEN, MAX_TASK_COUNT, MAX_TITLE_LEN, Task, caseless_contains,
20    caseless_key, category_name_key, task_text_contains,
21};
22use crate::settings::{LaunchState, Settings};
23use crate::store::{
24    Attachment, CategoryPatch, LabelPatch, RelativePosition, Store, StoreData, StoreError,
25    TaskPatch,
26};
27use crate::text_input::TextInput;
28use crate::theme::Theme;
29use crate::update::{CheckFailure, CheckResponse};
30use crate::update_state::{
31    AutomaticClaim, FAILURE_RETRY_SECONDS, LEASE_SECONDS, UpdateLease, UpdateState,
32    UpdateStateStore,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Focus {
37    Sidebar,
38    Tasks,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43    Normal,
44    /// The `/` command palette (dropdown above the status bar).
45    Slash,
46    /// Live search after choosing Search from the palette.
47    Search,
48    /// The task dialog (new or edit).
49    TaskForm,
50    /// The category dialog (new or edit).
51    CategoryForm,
52    /// Global reusable label manager.
53    Labels,
54    Help,
55    Settings,
56    Welcome,
57    WhatsNew,
58}
59
60impl Mode {
61    /// The bottom command bar, rather than either list panel, owns input.
62    pub fn command_bar_focused(self) -> bool {
63        matches!(self, Mode::Slash | Mode::Search)
64    }
65
66    /// Anything drawn on top of the two panels.
67    pub fn is_overlay(self) -> bool {
68        matches!(
69            self,
70            Mode::Help
71                | Mode::Settings
72                | Mode::Welcome
73                | Mode::WhatsNew
74                | Mode::TaskForm
75                | Mode::CategoryForm
76                | Mode::Labels
77        )
78    }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MessageKind {
83    Info,
84    Error,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum MessageLifetime {
89    Brief,
90    Standard,
91    Long,
92}
93
94impl MessageLifetime {
95    const fn duration(self) -> Duration {
96        match self {
97            Self::Brief => Duration::from_secs(2),
98            Self::Standard => Duration::from_secs(4),
99            Self::Long => Duration::from_secs(8),
100        }
101    }
102}
103
104pub struct Message {
105    pub text: String,
106    pub kind: MessageKind,
107    pub until: Instant,
108}
109
110/// Something destructive waiting on a second press of the same key. Only
111/// one can be armed at a time, so a half-typed delete cannot survive
112/// behind a quit prompt.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Confirm {
115    /// Backspace again deletes this exact task.
116    DeleteTask(String),
117    /// Backspace again deletes this exact category; its tasks become uncategorized.
118    DeleteCategory(String),
119    /// Enter purges this exact set of completed task ids.
120    Purge(Vec<String>),
121    /// Esc again discards the current task/category draft.
122    DiscardTask(Option<String>),
123    DiscardCategory(Option<String>),
124    /// Backspace again deletes this exact label and unassigns it everywhere.
125    DeleteLabel(String),
126    /// Ctrl+C again leaves mach.
127    Quit,
128}
129
130/// Idle gap after which type-to-jump starts a new query.
131const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
132/// Bound free-form commands while leaving room for full filesystem paths.
133const MAX_SLASH_INPUT_LEN: usize = 4096;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum UpdateJobKind {
137    Automatic,
138    Install,
139}
140
141enum UpdateOutcome {
142    Automatic(CheckResponse),
143    UpToDate {
144        info: crate::update::CheckResult,
145        etag: Option<String>,
146    },
147    Installed {
148        result: crate::update::InstallResult,
149        info: crate::update::CheckResult,
150        etag: Option<String>,
151    },
152    InstallFailed {
153        message: String,
154        info: crate::update::CheckResult,
155        etag: Option<String>,
156    },
157}
158
159enum UpdateEvent {
160    DownloadProgress(crate::update::DownloadProgress),
161    Finished(Box<Result<UpdateOutcome, CheckFailure>>),
162}
163
164struct UpdateJob {
165    rx: Receiver<UpdateEvent>,
166    kind: UpdateJobKind,
167    lease: Option<UpdateLease>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum ArchiveJobKind {
172    Export,
173    Import,
174}
175
176impl ArchiveJobKind {
177    const fn name(self) -> &'static str {
178        match self {
179            Self::Export => "export",
180            Self::Import => "import",
181        }
182    }
183
184    const fn title(self) -> &'static str {
185        match self {
186            Self::Export => "Export",
187            Self::Import => "Import",
188        }
189    }
190}
191
192enum ArchiveOutcome {
193    Export(crate::archive::ExportSummary),
194    Import(crate::archive::ImportSummary),
195}
196
197enum ArchiveRequest {
198    Export,
199    Import(PathBuf),
200}
201
202impl ArchiveRequest {
203    const fn kind(&self) -> ArchiveJobKind {
204        match self {
205            Self::Export => ArchiveJobKind::Export,
206            Self::Import(_) => ArchiveJobKind::Import,
207        }
208    }
209}
210
211enum ArchiveEvent {
212    Progress(crate::archive::ArchiveProgress),
213    Finished(Result<ArchiveOutcome, crate::archive::ArchiveError>),
214}
215
216struct ArchiveJob {
217    rx: Receiver<ArchiveEvent>,
218    handle: std::thread::JoinHandle<()>,
219    kind: ArchiveJobKind,
220    control: Arc<crate::archive::ArchiveControl>,
221    progress: crate::archive::ArchiveProgress,
222    cancel_requested: bool,
223}
224
225struct UpdateNotice {
226    text: String,
227    available_version: Option<String>,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub(crate) enum UpdateActivity {
232    Checking,
233    Downloading(crate::update::DownloadProgress),
234}
235
236/// Rects from the last frame, used to hit-test mouse events.
237#[derive(Debug, Default, Clone)]
238pub struct Areas {
239    pub sidebar: Rect,
240    pub tasks: Rect,
241    /// Inner row of the bottom command bar, including its clock.
242    pub command_bar: Rect,
243    /// Bottom-right task preview / docked editor, when the window is tall enough.
244    pub preview: Rect,
245    /// Screen columns of the flag and done markers, as the table laid
246    /// them out. `done_x` is the left edge of the `[ ]`/`[✓]` column
247    /// (see `ui::DONE_MARK_WIDTH`). The flag column is always reserved
248    /// for up to three flags.
249    pub flag_x: Option<u16>,
250    pub done_x: Option<u16>,
251    /// Open top-level command palette, including its border.
252    pub slash_menu: Rect,
253    /// Index of the first command drawn inside a clipped command palette.
254    pub slash_menu_start: usize,
255    /// Final badge rectangles in the global label manager.
256    pub label_hits: Vec<(usize, Rect)>,
257    /// Name field and selectable color swatches in the label editor.
258    pub label_name_input: Rect,
259    pub label_color_hits: Vec<(LabelColor, Rect)>,
260}
261
262#[derive(Debug, Clone)]
263pub struct LabelEditor {
264    pub editing_id: Option<String>,
265    pub name: TextInput,
266    pub color: LabelColor,
267    pub color_focused: bool,
268}
269
270impl LabelEditor {
271    fn new(editing_id: Option<String>, name: &str, color: LabelColor) -> Self {
272        Self {
273            editing_id,
274            name: TextInput::new(name, MAX_LABEL_NAME_LEN),
275            color,
276            color_focused: false,
277        }
278    }
279
280    pub(crate) fn move_color(&mut self, delta: isize) {
281        let len = LabelColor::SWATCHES.len();
282        let position = LabelColor::SWATCHES
283            .iter()
284            .position(|color| *color == self.color);
285        let next = match position {
286            Some(position) => (position as isize + delta).rem_euclid(len as isize) as usize,
287            None if delta.is_negative() => len - 1,
288            None => 0,
289        };
290        self.color = LabelColor::SWATCHES[next];
291    }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub(crate) enum ClickTarget {
296    Sidebar,
297    Tasks,
298    Labels,
299}
300
301pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Task preview"];
302
303/// One row of the task table: a real task, or a category section header
304/// (All Tasks / search only). Headers are not selectable.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum TaskListRow {
307    Separator {
308        title: String,
309    },
310    /// Index into [`App::view`].
311    Task(usize),
312}
313
314pub struct App {
315    store: Store,
316    version: String,
317    store_revision: u64,
318    pub tasks: Vec<Task>,
319    pub categories: Vec<Category>,
320    pub labels: Vec<Label>,
321    pub settings: Settings,
322    pub focus: Focus,
323    pub mode: Mode,
324    /// Index into `categories`.
325    pub cat_index: usize,
326    /// Index into `view`.
327    pub task_index: usize,
328    /// Scroll position of the two panels. Selection is driven by the two
329    /// indices above; ratatui keeps the offsets in these.
330    pub cat_state: ListState,
331    pub task_state: TableState,
332    /// Indices into `tasks`, in display order.
333    pub view: Vec<usize>,
334    /// Table rows including category separators. Parallel to what is drawn;
335    /// selection still uses `task_index` into `view`.
336    pub list_rows: Vec<TaskListRow>,
337    pub searching: bool,
338    pub search_query: String,
339    pub input: TextInput,
340    /// Selected row in the `/` palette dropdown.
341    pub slash_index: usize,
342    /// The open task dialog, if any.
343    pub form: Option<TaskForm>,
344    /// The open category dialog, if any.
345    pub category_form: Option<CategoryForm>,
346    /// Selected row in the global label manager.
347    pub label_index: usize,
348    /// Inline create/rename editor with an explicit name/color focus.
349    pub label_editor: Option<LabelEditor>,
350    pub label_error: Option<String>,
351    labels_return_to_form: bool,
352    pub settings_index: usize,
353    /// First help content row currently visible.
354    pub help_scroll: usize,
355    pub message: Option<Message>,
356    /// Pending entity-bound destructive action and its deadline.
357    pub pending: Option<(Confirm, Instant)>,
358    /// Last click `(time, target, row)` for double-click detection.
359    pub(crate) last_click: Option<(Instant, ClickTarget, usize)>,
360    pub should_quit: bool,
361    pub areas: Areas,
362    /// Description/preview image store.
363    pub images: ImageStore,
364    pub(crate) attachments: Vec<Attachment>,
365    /// Type-to-jump buffer (Tasks/Sidebar focus); cleared on timeout.
366    typeahead: String,
367    typeahead_at: Option<Instant>,
368    /// Needs a redraw.
369    pub dirty: bool,
370    /// Incremented on task mutation (invalidates preview cache).
371    pub data_gen: u64,
372    /// Per-category `(done, total)`, parallel to `categories`.
373    cat_progress: Vec<(usize, usize)>,
374    /// Cached description editor for the read-only preview pane.
375    pub preview_form: Option<TaskForm>,
376    preview_task_id: Option<String>,
377    preview_gen: u64,
378    /// Entity snapshots captured when an edit dialog opens. Save compares only
379    /// editable fields so unrelated changes (for example, another agent
380    /// toggling `done`) are preserved instead of becoming false conflicts.
381    task_edit_base: Option<Task>,
382    category_edit_base: Option<Category>,
383    /// One in-flight automatic check or explicit install.
384    update_job: Option<UpdateJob>,
385    /// One in-flight archive import or export. All archive I/O runs off the
386    /// event-loop thread so large backups cannot freeze input or drawing.
387    archive_job: Option<ArchiveJob>,
388    /// A quit requested while archive work is active waits for safe
389    /// cancellation or finalization instead of abandoning temporary files.
390    quit_after_archive: bool,
391    /// Application-level update scheduling state, independent of task data.
392    update_state: Option<UpdateStateStore>,
393    /// Wall-clock deadline for the next cheap state refresh.
394    next_update_state_poll_at: i64,
395    /// Update notices survive ordinary status messages and clear only when the
396    /// user opens the `/` command palette.
397    update_notice: Option<UpdateNotice>,
398    /// An available version dismissed in this process stays dismissed until a
399    /// newer release is discovered.
400    dismissed_update_version: Option<String>,
401    /// Visible work for an explicit `/update` request.
402    update_activity: Option<UpdateActivity>,
403    /// Whether persistence polling has failed since its last successful pass.
404    /// Repeated failures are quiet so they cannot continuously replace messages
405    /// or disarm destructive confirmations; success rearms reporting.
406    external_poll_failed: bool,
407}
408
409impl App {
410    pub fn new(version: &str) -> Result<Self, StoreError> {
411        Self::with_store_and_update_state(
412            version,
413            Store::open_default(None)?,
414            UpdateStateStore::open_default(),
415        )
416    }
417
418    pub fn with_store(version: &str, store: Store) -> Result<Self, StoreError> {
419        Self::with_store_and_update_state(version, store, UpdateStateStore::open_in_memory())
420    }
421
422    pub(crate) fn with_store_and_update_state(
423        version: &str,
424        store: Store,
425        update_state: Result<UpdateStateStore, StoreError>,
426    ) -> Result<Self, StoreError> {
427        let initial = store.snapshot()?;
428        // This is provisional until the terminal session is safely owned.
429        // `record_launch` repeats the classification inside the fresh write
430        // transaction, preserving the cross-process first-run contract.
431        let launch = if initial.settings.last_run_version.as_deref() == Some(version) {
432            LaunchState::Returning
433        } else {
434            let mut settings = initial.settings.clone();
435            settings.record_launch(version)
436        };
437        let snapshot = initial;
438        let StoreData {
439            revision,
440            categories: real_cats,
441            labels,
442            tasks,
443            settings,
444            attachments,
445        } = snapshot;
446        // "All Tasks" is a view only — prepended in memory, never saved.
447        let mut categories = vec![Category::all_tasks()];
448        categories.extend(real_cats);
449        let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
450        images.set_attachments(&attachments);
451        let (update_state, update_state_error) = match update_state {
452            Ok(store) => (Some(store), None),
453            Err(error) => (None, Some(error.to_string())),
454        };
455
456        let mut app = Self {
457            store,
458            version: version.to_string(),
459            store_revision: revision,
460            tasks,
461            categories,
462            labels,
463            settings,
464            focus: Focus::Tasks,
465            mode: match launch {
466                LaunchState::FirstRun => Mode::Welcome,
467                LaunchState::Upgraded => Mode::WhatsNew,
468                LaunchState::Returning => Mode::Normal,
469            },
470            cat_index: 0,
471            task_index: 0,
472            cat_state: ListState::default(),
473            task_state: TableState::default(),
474            view: Vec::new(),
475            list_rows: Vec::new(),
476            searching: false,
477            search_query: String::new(),
478            input: TextInput::default(),
479            slash_index: 0,
480            form: None,
481            category_form: None,
482            label_index: 0,
483            label_editor: None,
484            label_error: None,
485            labels_return_to_form: false,
486            settings_index: 0,
487            help_scroll: 0,
488            message: None,
489            pending: None,
490            last_click: None,
491            should_quit: false,
492            areas: Areas::default(),
493            images,
494            attachments,
495            typeahead: String::new(),
496            typeahead_at: None,
497            dirty: true,
498            data_gen: 0,
499            cat_progress: Vec::new(),
500            preview_form: None,
501            preview_task_id: None,
502            preview_gen: 0,
503            task_edit_base: None,
504            category_edit_base: None,
505            update_job: None,
506            archive_job: None,
507            quit_after_archive: false,
508            update_state,
509            next_update_state_poll_at: 0,
510            update_notice: None,
511            dismissed_update_version: None,
512            update_activity: None,
513            external_poll_failed: false,
514        };
515        app.rebuild_view();
516        if let Some(error) = update_state_error {
517            app.error(format!("Automatic update checks unavailable: {error}"));
518        } else {
519            app.refresh_update_state(Utc::now().timestamp());
520        }
521        Ok(app)
522    }
523
524    /// Persist the launch only after terminal initialization succeeds.
525    ///
526    /// Until this runs, Welcome / What's New is merely provisional: a failed
527    /// terminal setup must leave it available for the next successful launch.
528    pub(crate) fn record_launch(&mut self) -> Result<(), StoreError> {
529        let version = self.version.clone();
530        let launch = if self.settings.last_run_version.as_deref() == Some(version.as_str()) {
531            LaunchState::Returning
532        } else {
533            self.update_store(|data| Ok(data.settings.record_launch(&version)))?
534        };
535        self.mode = match launch {
536            LaunchState::FirstRun => Mode::Welcome,
537            LaunchState::Upgraded => Mode::WhatsNew,
538            LaunchState::Returning => Mode::Normal,
539        };
540        self.dirty = true;
541        Ok(())
542    }
543
544    pub fn data_dir(&self) -> &Path {
545        self.store.data_dir()
546    }
547
548    /// Refresh after another process commits. Dialogs deliberately defer the
549    /// visual refresh: their entity snapshot is checked transactionally when
550    /// the user saves, so typed work is never replaced under the cursor.
551    pub fn poll_external_changes(&mut self) -> bool {
552        let revision = match self.store.revision() {
553            Ok(revision) => revision,
554            Err(error) => {
555                return self.report_external_poll_error(format!(
556                    "Could not check for external changes: {error}"
557                ));
558            }
559        };
560        if revision == self.store_revision
561            || self.form.is_some()
562            || self.category_form.is_some()
563            || self.mode == Mode::Labels
564        {
565            self.external_poll_failed = false;
566            return false;
567        }
568        match self.reload_store() {
569            Ok(()) => {
570                self.external_poll_failed = false;
571                true
572            }
573            Err(error) => self
574                .report_external_poll_error(format!("Could not reload external changes: {error}")),
575        }
576    }
577
578    fn report_external_poll_error(&mut self, message: String) -> bool {
579        if self.external_poll_failed {
580            return false;
581        }
582        self.external_poll_failed = true;
583        self.error(message);
584        true
585    }
586
587    fn reload_store(&mut self) -> Result<(), StoreError> {
588        let selected_category = self.current_category_id().to_string();
589        let selected_task = self.selected_task().map(|task| task.id.clone());
590        let snapshot = self.store.snapshot()?;
591        self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
592        Ok(())
593    }
594
595    fn apply_snapshot(
596        &mut self,
597        snapshot: StoreData,
598        selected_category: &str,
599        selected_task: Option<&str>,
600    ) {
601        let StoreData {
602            revision,
603            categories,
604            labels,
605            tasks,
606            settings,
607            attachments,
608        } = snapshot;
609        self.store_revision = revision;
610        self.tasks = tasks;
611        self.labels = labels;
612        self.settings = settings;
613        self.attachments = attachments;
614        self.images.set_attachments(&self.attachments);
615        self.categories.clear();
616        self.categories.push(Category::all_tasks());
617        self.categories.extend(categories);
618        self.cat_index = self
619            .categories
620            .iter()
621            .position(|category| category.id == selected_category)
622            .unwrap_or(0);
623        self.cat_progress.clear();
624        self.data_gen = self.data_gen.wrapping_add(1);
625        self.invalidate_preview();
626        self.rebuild_view();
627        if let Some(id) = selected_task {
628            self.select_task_by_id(id);
629        }
630        self.dirty = true;
631    }
632
633    pub(crate) fn start_export_archive(&mut self) {
634        self.start_archive_worker(ArchiveRequest::Export);
635    }
636
637    pub(crate) fn start_import_archive(&mut self, path: PathBuf) {
638        self.start_archive_worker(ArchiveRequest::Import(path));
639    }
640
641    fn start_archive_worker(&mut self, request: ArchiveRequest) {
642        if let Some(active) = self.archive_job.as_ref() {
643            self.info(format!("An {} is already running", active.kind.name()));
644            return;
645        }
646
647        let kind = request.kind();
648        let data_dir = self.store.data_dir().to_path_buf();
649        let control = Arc::new(crate::archive::ArchiveControl::new());
650        let worker_control = Arc::clone(&control);
651        let (tx, rx) = mpsc::channel();
652        let thread_name = match kind {
653            ArchiveJobKind::Export => "mach-archive-export",
654            ArchiveJobKind::Import => "mach-archive-import",
655        };
656        let spawn = std::thread::Builder::new()
657            .name(thread_name.into())
658            .spawn(move || {
659                let result = (|| -> Result<ArchiveOutcome, crate::archive::ArchiveError> {
660                    let mut store = Store::open(data_dir)?;
661                    match request {
662                        ArchiveRequest::Export => crate::archive::export_with_progress(
663                            &store,
664                            None,
665                            &worker_control,
666                            |progress| {
667                                let _ = tx.send(ArchiveEvent::Progress(progress));
668                            },
669                        )
670                        .map(ArchiveOutcome::Export),
671                        ArchiveRequest::Import(path) => crate::archive::import_with_progress(
672                            &mut store,
673                            &path,
674                            &worker_control,
675                            |progress| {
676                                let _ = tx.send(ArchiveEvent::Progress(progress));
677                            },
678                        )
679                        .map(ArchiveOutcome::Import),
680                    }
681                })();
682                let _ = tx.send(ArchiveEvent::Finished(result));
683            });
684
685        match spawn {
686            Ok(handle) => {
687                self.archive_job = Some(ArchiveJob {
688                    rx,
689                    handle,
690                    kind,
691                    control,
692                    progress: crate::archive::ArchiveProgress::Preparing,
693                    cancel_requested: false,
694                });
695                self.dirty = true;
696            }
697            Err(error) => self.error(format!("Could not start archive {}: {error}", kind.name())),
698        }
699    }
700
701    /// Apply archive progress or completion without blocking the event loop.
702    pub(crate) fn poll_archive(&mut self) -> bool {
703        let mut changed = false;
704        loop {
705            let event = self.archive_job.as_ref().map(|job| job.rx.try_recv());
706            match event {
707                None | Some(Err(TryRecvError::Empty)) => return changed,
708                Some(Ok(ArchiveEvent::Progress(progress))) => {
709                    if let Some(job) = self.archive_job.as_mut()
710                        && job.progress != progress
711                    {
712                        job.progress = progress;
713                        changed = true;
714                    }
715                }
716                Some(Ok(ArchiveEvent::Finished(result))) => {
717                    let job = self
718                        .archive_job
719                        .take()
720                        .expect("archive event requires an active job");
721                    let kind = job.kind;
722                    let _ = job.handle.join();
723                    changed |= self.finish_archive(kind, result);
724                    if self.quit_after_archive {
725                        self.should_quit = true;
726                    }
727                    return changed;
728                }
729                Some(Err(TryRecvError::Disconnected)) => {
730                    let job = self
731                        .archive_job
732                        .take()
733                        .expect("archive channel requires an active job");
734                    let kind = job.kind;
735                    let _ = job.handle.join();
736                    self.error(format!("{} stopped unexpectedly", kind.title()));
737                    if self.quit_after_archive {
738                        self.should_quit = true;
739                    }
740                    return true;
741                }
742            }
743        }
744    }
745
746    fn finish_archive(
747        &mut self,
748        kind: ArchiveJobKind,
749        result: Result<ArchiveOutcome, crate::archive::ArchiveError>,
750    ) -> bool {
751        match result {
752            Ok(ArchiveOutcome::Export(summary)) => {
753                let contents = crate::archive::content_count_text(
754                    summary.tasks,
755                    summary.categories,
756                    summary.labels,
757                    summary.images,
758                );
759                self.archive_result(format!("Exported to {} · {contents}", summary.short_path()));
760            }
761            Ok(ArchiveOutcome::Import(summary)) => {
762                if let Err(error) = self.reload_store() {
763                    self.error(format!(
764                        "Archive imported, but mach could not refresh: {error}"
765                    ));
766                    return true;
767                }
768                let added = crate::archive::content_count_text(
769                    summary.tasks_added,
770                    summary.categories_added,
771                    summary.labels_added,
772                    summary.images_added,
773                );
774                let unchanged = crate::archive::content_count_text(
775                    summary.tasks_unchanged,
776                    summary.categories_unchanged,
777                    summary.labels_unchanged,
778                    summary.images_unchanged,
779                );
780                let message = if !summary.changed() {
781                    format!("Nothing imported; {unchanged} already present")
782                } else {
783                    format!("Imported {added}; {unchanged} already present")
784                };
785                self.archive_result(message);
786            }
787            Err(crate::archive::ArchiveError::Cancelled) => {
788                self.info(format!("{} cancelled", kind.title()));
789            }
790            Err(error) => self.error(format!("Could not {}: {error}", kind.name())),
791        }
792        true
793    }
794
795    /// Request cancellation at the next safe I/O boundary. Once finalization
796    /// begins, import may be inside its atomic database commit and must finish.
797    pub(crate) fn cancel_archive(&mut self) -> bool {
798        let Some(job) = self.archive_job.as_mut() else {
799            return false;
800        };
801        if !job.control.request_cancel() {
802            let title = job.kind.title();
803            self.info(format!("{title} is finishing and cannot be cancelled"));
804            return true;
805        }
806        if !job.cancel_requested {
807            job.cancel_requested = true;
808            self.dirty = true;
809        }
810        true
811    }
812
813    pub fn request_quit(&mut self) {
814        let Some(job) = self.archive_job.as_mut() else {
815            self.should_quit = true;
816            return;
817        };
818        self.quit_after_archive = true;
819        if job.control.request_cancel() {
820            job.cancel_requested = true;
821        }
822        self.pending = None;
823        self.message = None;
824        self.dirty = true;
825    }
826
827    /// Join archive work before an event-loop error releases the process.
828    /// Normal quits already defer until `poll_archive` observes completion.
829    pub(crate) fn shutdown_archive(&mut self) {
830        if let Some(job) = self.archive_job.take() {
831            let _ = job.control.request_cancel();
832            let _ = job.handle.join();
833        }
834    }
835
836    /// Commit against the transaction's fresh snapshot and apply the exact
837    /// normalized state returned after a successful commit.
838    fn update_store<R>(
839        &mut self,
840        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
841    ) -> Result<R, StoreError> {
842        let selected_category = self.current_category_id().to_string();
843        let selected_task = self.selected_task().map(|task| task.id.clone());
844        let (result, snapshot) = self.store.update_with_snapshot(operation)?;
845        self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
846        Ok(result)
847    }
848
849    fn report_store_error(&mut self, action: &str, error: StoreError) {
850        self.error(format!("{action}: {error}"));
851    }
852
853    /// Refresh global state and start a due automatic check. The event loop
854    /// calls this throughout the process lifetime; local polling is capped at
855    /// once per minute while the SQLite deadline remains the source of truth.
856    pub(crate) fn poll_automatic_update_schedule(&mut self) -> bool {
857        self.poll_automatic_update_schedule_at(Utc::now().timestamp())
858    }
859
860    fn poll_automatic_update_schedule_at(&mut self, now: i64) -> bool {
861        if now < self.next_update_state_poll_at {
862            return false;
863        }
864        if self.update_job.is_some() {
865            self.next_update_state_poll_at = now.saturating_add(1);
866            return false;
867        }
868        let claim = match self
869            .update_state
870            .as_mut()
871            .map(|store| store.try_claim_automatic(now))
872        {
873            Some(Ok(claim)) => claim,
874            Some(Err(_)) => {
875                self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
876                return false;
877            }
878            None => {
879                self.next_update_state_poll_at = i64::MAX;
880                return false;
881            }
882        };
883        match claim {
884            AutomaticClaim::Claimed(lease) => {
885                self.next_update_state_poll_at = now.saturating_add(LEASE_SECONDS);
886                self.start_update_worker(UpdateJobKind::Automatic, Some(lease));
887                false
888            }
889            AutomaticClaim::Waiting(state) => self.apply_update_state(state, now),
890        }
891    }
892
893    fn refresh_update_state(&mut self, now: i64) -> bool {
894        let state = match self.update_state.as_ref().map(UpdateStateStore::snapshot) {
895            Some(Ok(state)) => state,
896            Some(Err(_)) => {
897                self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
898                return false;
899            }
900            None => {
901                self.next_update_state_poll_at = i64::MAX;
902                return false;
903            }
904        };
905        self.apply_update_state(state, now)
906    }
907
908    fn apply_update_state(&mut self, state: UpdateState, now: i64) -> bool {
909        let changed = self.sync_available_update(state.latest_version.as_deref());
910        self.schedule_update_state_poll(&state, now);
911        changed
912    }
913
914    fn schedule_update_state_poll(&mut self, state: &UpdateState, now: i64) {
915        const STATE_REFRESH_SECONDS: i64 = 60;
916        let deadline = if state.automatic_check_due(now) {
917            state
918                .lease_until
919                .filter(|_| state.lease_active(now))
920                .unwrap_or(now)
921        } else {
922            state.next_check_at.unwrap_or(now)
923        };
924        self.next_update_state_poll_at = deadline.min(now.saturating_add(STATE_REFRESH_SECONDS));
925    }
926
927    /// Explicitly check for and install the latest checksum-verified release (`/update`).
928    pub(crate) fn start_update_install(&mut self) {
929        self.update_notice = None;
930        if self
931            .update_job
932            .as_ref()
933            .is_some_and(|job| job.kind == UpdateJobKind::Install)
934        {
935            self.info("Already updating…");
936            return;
937        }
938
939        // Explicit user intent supersedes an automatic check. Its detached
940        // worker may finish, but dropping the receiver prevents a stale result
941        // from competing with the install result in the UI.
942        self.update_job = None;
943        let now = Utc::now().timestamp();
944        let lease = self
945            .update_state
946            .as_mut()
947            .and_then(|store| store.claim_manual(now).ok());
948        self.start_update_worker(UpdateJobKind::Install, lease);
949    }
950
951    fn start_update_worker(&mut self, kind: UpdateJobKind, lease: Option<UpdateLease>) {
952        let (tx, rx) = mpsc::channel();
953        let thread_name = match kind {
954            UpdateJobKind::Automatic => "mach-update-check",
955            UpdateJobKind::Install => "mach-update-install",
956        };
957        let conditional_etag = lease.as_ref().and_then(|lease| lease.etag.clone());
958        match std::thread::Builder::new()
959            .name(thread_name.into())
960            .spawn(move || {
961                let result = (|| -> Result<UpdateOutcome, CheckFailure> {
962                    match kind {
963                        UpdateJobKind::Automatic => {
964                            crate::update::check_with_etag(conditional_etag.as_deref())
965                                .map(UpdateOutcome::Automatic)
966                        }
967                        UpdateJobKind::Install => {
968                            let CheckResponse::Modified { value: info, etag } =
969                                crate::update::check_with_etag(None)?
970                            else {
971                                return Err(CheckFailure {
972                                    message: "GitHub returned 304 without a conditional request"
973                                        .into(),
974                                    retry_at: None,
975                                });
976                            };
977                            if !info.newer {
978                                return Ok(UpdateOutcome::UpToDate { info, etag });
979                            }
980                            let install = crate::update::install_with_progress(&info, |progress| {
981                                let _ = tx.send(UpdateEvent::DownloadProgress(progress));
982                            });
983                            Ok(match install {
984                                Ok(result) => UpdateOutcome::Installed { result, info, etag },
985                                Err(message) => UpdateOutcome::InstallFailed {
986                                    message,
987                                    info,
988                                    etag,
989                                },
990                            })
991                        }
992                    }
993                })();
994                let _ = tx.send(UpdateEvent::Finished(Box::new(result)));
995            }) {
996            Ok(_) => {
997                self.update_job = Some(UpdateJob { rx, kind, lease });
998                if kind == UpdateJobKind::Install {
999                    self.update_activity = Some(UpdateActivity::Checking);
1000                    self.dirty = true;
1001                }
1002            }
1003            Err(error) => {
1004                self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
1005                if kind == UpdateJobKind::Install {
1006                    self.update_activity = None;
1007                    self.error(format!("Could not start update: {error}"));
1008                }
1009            }
1010        }
1011    }
1012
1013    /// Apply finished update work, if any. Returns true when UI should redraw.
1014    pub(crate) fn poll_update(&mut self) -> bool {
1015        let mut changed = false;
1016        loop {
1017            let event = self
1018                .update_job
1019                .as_ref()
1020                .map(|job| (job.kind, job.rx.try_recv()));
1021            match event {
1022                None | Some((_, Err(TryRecvError::Empty))) => return changed,
1023                Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
1024                    let activity = UpdateActivity::Downloading(progress);
1025                    if self.update_activity != Some(activity) {
1026                        self.update_activity = Some(activity);
1027                        changed = true;
1028                    }
1029                }
1030                Some((kind, Ok(UpdateEvent::Finished(result)))) => {
1031                    let lease = self.update_job.take().and_then(|job| job.lease);
1032                    changed |= self.update_activity.take().is_some();
1033                    return self.finish_update(kind, lease.as_ref(), *result) || changed;
1034                }
1035                Some((kind, Err(TryRecvError::Disconnected))) => {
1036                    let lease = self.update_job.take().and_then(|job| job.lease);
1037                    changed |= self.update_activity.take().is_some();
1038                    self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
1039                    return if kind == UpdateJobKind::Install {
1040                        self.show_update_message("Update failed".into(), MessageKind::Error);
1041                        true
1042                    } else {
1043                        changed
1044                    };
1045                }
1046            }
1047        }
1048    }
1049
1050    fn finish_update(
1051        &mut self,
1052        kind: UpdateJobKind,
1053        lease: Option<&UpdateLease>,
1054        result: Result<UpdateOutcome, CheckFailure>,
1055    ) -> bool {
1056        let now = Utc::now().timestamp();
1057        match result {
1058            Ok(UpdateOutcome::Automatic(CheckResponse::Modified { value: info, etag })) => {
1059                let (committed, changed) =
1060                    self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1061                if !committed {
1062                    return changed;
1063                }
1064                if info.newer {
1065                    self.set_available_update_notice(&info.latest) || changed
1066                } else {
1067                    self.sync_available_update(None) || changed
1068                }
1069            }
1070            Ok(UpdateOutcome::Automatic(CheckResponse::NotModified)) => {
1071                if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
1072                    let _ = store.finish_not_modified(lease, now);
1073                }
1074                self.refresh_update_state(now)
1075            }
1076            Ok(UpdateOutcome::UpToDate { info, etag }) => {
1077                self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1078                self.show_update_message(info.summary(), MessageKind::Info);
1079                true
1080            }
1081            Ok(UpdateOutcome::Installed { result, info, etag }) => {
1082                self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1083                let action = match result.disposition {
1084                    crate::update::InstallDisposition::Installed => "Installed",
1085                    crate::update::InstallDisposition::AlreadyCurrent => "Already installed",
1086                };
1087                self.set_update_notice(UpdateNotice {
1088                    text: format!("{action} {} · restart mach", result.tag),
1089                    available_version: None,
1090                })
1091            }
1092            Ok(UpdateOutcome::InstallFailed {
1093                message,
1094                info,
1095                etag,
1096            }) => {
1097                self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
1098                self.show_update_message(message, MessageKind::Error);
1099                true
1100            }
1101            Err(error) if kind == UpdateJobKind::Install => {
1102                self.finish_update_state_failure(lease, now, error.retry_at);
1103                self.show_update_message(error.message, MessageKind::Error);
1104                true
1105            }
1106            Err(error) => {
1107                self.finish_update_state_failure(lease, now, error.retry_at);
1108                false
1109            }
1110        }
1111    }
1112
1113    fn finish_update_state_modified(
1114        &mut self,
1115        lease: Option<&UpdateLease>,
1116        now: i64,
1117        etag: Option<&str>,
1118        latest_version: &str,
1119    ) -> (bool, bool) {
1120        let committed = match (self.update_state.as_mut(), lease) {
1121            (Some(store), Some(lease)) => store
1122                .finish_modified(lease, now, etag, latest_version)
1123                .unwrap_or(false),
1124            // Manual checks remain useful when application-level persistence
1125            // is unavailable; automatic workers always carry a lease.
1126            _ => true,
1127        };
1128        (committed, self.refresh_update_state(now))
1129    }
1130
1131    fn finish_update_state_failure(
1132        &mut self,
1133        lease: Option<&UpdateLease>,
1134        now: i64,
1135        retry_at: Option<i64>,
1136    ) {
1137        if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
1138            let _ = store.finish_failure(lease, now, retry_at);
1139        }
1140        self.refresh_update_state(now);
1141    }
1142
1143    fn sync_available_update(&mut self, available_version: Option<&str>) -> bool {
1144        let available_version = available_version
1145            .filter(|version| crate::update::is_newer(version, &self.version) == Some(true));
1146        let Some(version) = available_version else {
1147            if self
1148                .update_notice
1149                .as_ref()
1150                .is_some_and(|notice| notice.available_version.is_some())
1151            {
1152                self.update_notice = None;
1153                self.dirty = true;
1154                return true;
1155            }
1156            return false;
1157        };
1158        if self.dismissed_update_version.as_deref() == Some(version)
1159            || self
1160                .update_notice
1161                .as_ref()
1162                .is_some_and(|notice| notice.available_version.is_none())
1163        {
1164            return false;
1165        }
1166        self.set_available_update_notice(version)
1167    }
1168
1169    fn set_available_update_notice(&mut self, latest: &str) -> bool {
1170        if self
1171            .update_notice
1172            .as_ref()
1173            .and_then(|notice| notice.available_version.as_deref())
1174            == Some(latest)
1175        {
1176            return false;
1177        }
1178        self.set_update_notice(UpdateNotice {
1179            text: format!(
1180                "v{} → v{} available · run /update to install",
1181                self.version, latest
1182            ),
1183            available_version: Some(latest.to_string()),
1184        })
1185    }
1186
1187    fn set_update_notice(&mut self, notice: UpdateNotice) -> bool {
1188        let visible = self.message.is_none();
1189        self.update_notice = Some(notice);
1190        if visible {
1191            self.dirty = true;
1192        }
1193        visible
1194    }
1195
1196    fn show_update_message(&mut self, text: String, kind: MessageKind) {
1197        self.set_message(text, kind, MessageLifetime::Long);
1198    }
1199
1200    pub fn mark_dirty(&mut self) {
1201        self.dirty = true;
1202    }
1203
1204    pub fn invalidate_preview(&mut self) {
1205        self.preview_form = None;
1206        self.preview_task_id = None;
1207        self.preview_gen = 0;
1208    }
1209
1210    /// Rebuild [`Self::preview_form`] if the selection or `data_gen` changed.
1211    pub fn ensure_preview(&mut self) {
1212        let Some(task) = self.selected_task() else {
1213            self.invalidate_preview();
1214            return;
1215        };
1216        let id = task.id.clone();
1217        let generation = self.data_gen;
1218        if self.preview_task_id.as_deref() == Some(id.as_str())
1219            && self.preview_gen == generation
1220            && self.preview_form.is_some()
1221        {
1222            return;
1223        }
1224        let task = task.clone();
1225        let mut form =
1226            TaskForm::edit_with_images(&task, self.images.root().to_path_buf(), &self.attachments);
1227        form.set_categories(&self.categories, task.category_id.as_deref());
1228        form.set_labels(&self.labels, &task.label_ids);
1229        self.preview_form = Some(form);
1230        self.preview_task_id = Some(id);
1231        self.preview_gen = generation;
1232    }
1233
1234    pub fn theme(&self) -> Theme {
1235        Theme::new(&self.settings.selected_color)
1236    }
1237
1238    // ---------------------------------------------------------------- view
1239
1240    pub fn current_category_id(&self) -> &str {
1241        self.categories
1242            .get(self.cat_index)
1243            .map(|c| c.id.as_str())
1244            .unwrap_or(ALL_CATEGORY)
1245    }
1246
1247    pub fn is_all_view(&self) -> bool {
1248        self.current_category_id() == ALL_CATEGORY
1249    }
1250
1251    pub fn category_name(&self, id: &str) -> Option<&str> {
1252        self.categories
1253            .iter()
1254            .find(|c| c.id == id)
1255            .map(|c| c.name.as_str())
1256    }
1257
1258    pub fn label_name(&self, id: &str) -> Option<&str> {
1259        self.labels
1260            .iter()
1261            .find(|label| label.id == id)
1262            .map(|label| label.name.as_str())
1263    }
1264
1265    /// Recompute which tasks are shown and in what order.
1266    ///
1267    /// Sort applies **inside** each category. All Tasks (and search) stack
1268    /// those already-sorted groups in sidebar order; a single category is
1269    /// just one group.
1270    pub fn rebuild_view(&mut self) {
1271        let selected_id = self.selected_task().map(|task| task.id.clone());
1272        self.dirty = true;
1273        if self.cat_progress.len() != self.categories.len() {
1274            self.recompute_cat_progress();
1275        }
1276        let cat_id = self.current_category_id();
1277        let all = cat_id == ALL_CATEGORY;
1278        let hide_done = self.settings.hide_done;
1279        let candidates: Vec<usize> = if self.searching {
1280            let q = caseless_key(&self.search_query);
1281            self.tasks
1282                .iter()
1283                .enumerate()
1284                .filter(|(_, t)| {
1285                    !(hide_done && t.done)
1286                        && (task_text_contains(t, &q) || task_labels_contain(t, &self.labels, &q))
1287                })
1288                .map(|(i, _)| i)
1289                .collect()
1290        } else {
1291            self.tasks
1292                .iter()
1293                .enumerate()
1294                .filter(|(_, t)| {
1295                    (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
1296                })
1297                .map(|(i, _)| i)
1298                .collect()
1299        };
1300
1301        // Multi-category views: stack each category's sorted slice.
1302        let multi = all || self.searching;
1303        self.view = if multi {
1304            self.stack_by_category(&candidates)
1305        } else {
1306            let mut view = candidates;
1307            self.sort_within(&mut view);
1308            view
1309        };
1310        if let Some(id) = selected_id {
1311            self.select_task_by_id(&id);
1312        } else if self.task_index >= self.view.len() {
1313            self.task_index = self.view.len().saturating_sub(1);
1314        }
1315        self.list_rows = self.build_list_rows(multi);
1316    }
1317
1318    /// Table rows for the current `view`. Multi-category lists get a
1319    /// section header before each group; a single category is tasks only.
1320    fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
1321        if !multi {
1322            return (0..self.view.len()).map(TaskListRow::Task).collect();
1323        }
1324        let category_names: HashMap<_, _> = self
1325            .categories
1326            .iter()
1327            .map(|category| (category.id.as_str(), category.name.as_str()))
1328            .collect();
1329        let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
1330        let mut prev: Option<Option<&str>> = None;
1331        for (vi, &ti) in self.view.iter().enumerate() {
1332            let key = self.tasks[ti].category_id.as_deref();
1333            if prev != Some(key) {
1334                let title = match key {
1335                    Some(id) => category_names
1336                        .get(id)
1337                        .copied()
1338                        .unwrap_or("Unknown")
1339                        .to_string(),
1340                    None => "Uncategorized".to_string(),
1341                };
1342                rows.push(TaskListRow::Separator { title });
1343                prev = Some(key);
1344            }
1345            rows.push(TaskListRow::Task(vi));
1346        }
1347        rows
1348    }
1349
1350    /// Visual table row for the selected task, if any.
1351    pub fn selected_visual_row(&self) -> Option<usize> {
1352        self.list_rows
1353            .iter()
1354            .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
1355    }
1356
1357    /// `view` index under a visual table row, or `None` for a separator.
1358    pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
1359        match self.list_rows.get(row)? {
1360            TaskListRow::Task(i) => Some(*i),
1361            TaskListRow::Separator { .. } => None,
1362        }
1363    }
1364
1365    /// Sidebar order of real categories, each group sorted; uncategorized last.
1366    fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
1367        let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
1368        for &i in candidates {
1369            buckets
1370                .entry(self.tasks[i].category_id.as_deref())
1371                .or_default()
1372                .push(i);
1373        }
1374        let mut view = Vec::with_capacity(candidates.len());
1375        for cat in self.categories.iter().filter(|c| !c.is_all()) {
1376            if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
1377                self.sort_within(&mut group);
1378                view.extend(group);
1379            }
1380        }
1381        // Anything not filed under a known category (or left uncategorized).
1382        let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
1383        self.sort_within(&mut rest);
1384        view.extend(rest);
1385        view
1386    }
1387
1388    /// Apply the settings sort to one category's rows (or a rest bucket).
1389    fn sort_within(&self, view: &mut [usize]) {
1390        match self.settings.sort.as_str() {
1391            "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
1392            "done" => view.sort_by_key(|i| self.tasks[*i].done),
1393            "due" => {
1394                let today = chrono::Local::now().date_naive();
1395                view.sort_by_cached_key(|i| {
1396                    let due = &self.tasks[*i].due;
1397                    (due.is_empty(), due::sort_key_at(due, today))
1398                });
1399            }
1400            _ => {} // manual — keep the store's explicit task order
1401        }
1402    }
1403
1404    pub fn task_count(&self) -> usize {
1405        self.view.len()
1406    }
1407
1408    pub fn visible_task(&self, pos: usize) -> Option<&Task> {
1409        self.view.get(pos).and_then(|index| self.tasks.get(*index))
1410    }
1411
1412    pub fn selected_task(&self) -> Option<&Task> {
1413        self.visible_task(self.task_index)
1414    }
1415
1416    pub fn done_count(&self) -> usize {
1417        self.view.iter().filter(|i| self.tasks[**i].done).count()
1418    }
1419
1420    // ----------------------------------------------------------- selection
1421
1422    pub fn move_task_selection(&mut self, delta: isize) {
1423        if self.view.is_empty() {
1424            return;
1425        }
1426        let last = self.view.len() - 1;
1427        let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
1428        self.select_task(next);
1429    }
1430
1431    pub fn select_task(&mut self, pos: usize) {
1432        if pos < self.view.len() && pos != self.task_index {
1433            self.task_index = pos;
1434            self.cancel_pending();
1435            self.clear_typeahead();
1436            self.dirty = true;
1437        }
1438    }
1439
1440    pub fn select_first_task(&mut self) {
1441        self.select_task(0);
1442    }
1443
1444    pub fn select_last_task(&mut self) {
1445        self.select_task(self.view.len().saturating_sub(1));
1446    }
1447
1448    /// Type-to-jump: append `c` and select the best fuzzy match (list unchanged).
1449    pub fn typeahead_jump(&mut self, c: char) {
1450        let now = Instant::now();
1451        if self
1452            .typeahead_at
1453            .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
1454        {
1455            self.typeahead.clear();
1456        }
1457        let limit = match self.focus {
1458            Focus::Tasks => MAX_TITLE_LEN,
1459            Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
1460        };
1461        if self.typeahead.graphemes(true).count() < limit {
1462            self.typeahead.push(c);
1463        }
1464        self.typeahead_at = Some(now);
1465
1466        match self.focus {
1467            Focus::Tasks => {
1468                let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
1469                if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
1470                    self.task_index = pos;
1471                    self.cancel_pending();
1472                }
1473            }
1474            Focus::Sidebar => {
1475                let names = self.categories.iter().map(|c| c.name.as_str());
1476                if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
1477                    && pos != self.cat_index
1478                {
1479                    self.cat_index = pos;
1480                    self.cancel_pending();
1481                    self.on_category_changed();
1482                }
1483            }
1484        }
1485    }
1486
1487    pub fn move_category_selection(&mut self, delta: isize) {
1488        if self.categories.is_empty() {
1489            return;
1490        }
1491        let last = self.categories.len() - 1;
1492        let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
1493        self.select_category(next);
1494    }
1495
1496    /// ↑/↓ stay inside the focused panel. Cross-panel moves use ←/→ or Tab.
1497    pub fn navigate_vertical(&mut self, delta: isize) {
1498        if delta == 0 {
1499            return;
1500        }
1501        self.cancel_pending();
1502        match self.focus {
1503            Focus::Tasks => {
1504                if self.view.is_empty() {
1505                    return;
1506                }
1507                self.move_task_selection(delta);
1508            }
1509            Focus::Sidebar => {
1510                self.move_category_selection(delta);
1511            }
1512        }
1513    }
1514
1515    pub fn select_category(&mut self, index: usize) {
1516        if index < self.categories.len() && index != self.cat_index {
1517            self.cat_index = index;
1518            self.cancel_pending();
1519            self.clear_typeahead();
1520            self.on_category_changed();
1521        }
1522    }
1523
1524    pub fn select_last_category(&mut self) {
1525        self.select_category(self.categories.len().saturating_sub(1));
1526    }
1527
1528    fn on_category_changed(&mut self) {
1529        self.searching = false;
1530        self.search_query.clear();
1531        self.task_index = 0;
1532        self.rebuild_view();
1533    }
1534
1535    pub fn toggle_focus(&mut self) {
1536        let next = match self.focus {
1537            Focus::Sidebar => Focus::Tasks,
1538            Focus::Tasks => Focus::Sidebar,
1539        };
1540        let _ = self.set_focus(next);
1541    }
1542
1543    /// Move keyboard focus. A locked search owns the task list until Esc.
1544    pub fn set_focus(&mut self, focus: Focus) -> bool {
1545        if self.searching && focus == Focus::Sidebar {
1546            return false;
1547        }
1548        if self.focus != focus {
1549            self.focus = focus;
1550            self.cancel_pending();
1551            self.clear_typeahead();
1552            self.dirty = true;
1553        }
1554        true
1555    }
1556
1557    pub fn cancel_pending(&mut self) {
1558        if self.pending.take().is_some() && self.message.take().is_some() {
1559            self.dirty = true;
1560        }
1561    }
1562
1563    fn clear_typeahead(&mut self) {
1564        self.typeahead.clear();
1565        self.typeahead_at = None;
1566    }
1567
1568    // ------------------------------------------------------------ mutation
1569
1570    fn recompute_cat_progress(&mut self) {
1571        let mut all_done = 0usize;
1572        let mut all_total = 0usize;
1573        let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
1574        let category_indices: HashMap<_, _> = self
1575            .categories
1576            .iter()
1577            .enumerate()
1578            .map(|(index, category)| (category.id.as_str(), index))
1579            .collect();
1580        for t in &self.tasks {
1581            all_total += 1;
1582            if t.done {
1583                all_done += 1;
1584            }
1585            if let Some(cid) = t.category_id.as_deref()
1586                && let Some(&idx) = category_indices.get(cid)
1587            {
1588                per[idx].1 += 1;
1589                if t.done {
1590                    per[idx].0 += 1;
1591                }
1592            }
1593        }
1594        for (i, cat) in self.categories.iter().enumerate() {
1595            if cat.is_all() {
1596                per[i] = (all_done, all_total);
1597            }
1598        }
1599        self.cat_progress = per;
1600    }
1601
1602    /// Keep the same task selected after the view is rebuilt and rows move.
1603    fn select_task_by_id(&mut self, id: &str) {
1604        if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
1605            self.task_index = pos;
1606        } else if self.task_index >= self.view.len() {
1607            self.task_index = self.view.len().saturating_sub(1);
1608        }
1609    }
1610
1611    pub fn toggle_done(&mut self, pos: usize) {
1612        if let Some(&i) = self.view.get(pos) {
1613            let id = self.tasks[i].id.clone();
1614            match self.update_store(|data| data.toggle_task_done(&id)) {
1615                Ok(_) => self.select_task_by_id(&id),
1616                Err(error) => self.report_store_error("Could not update task", error),
1617            }
1618        }
1619    }
1620
1621    /// Steps a task's importance up, wrapping back to none after three.
1622    pub fn cycle_importance(&mut self, pos: usize) {
1623        if let Some(&i) = self.view.get(pos) {
1624            let id = self.tasks[i].id.clone();
1625            match self.update_store(|data| {
1626                let importance = crate::model::next_importance(data.task(&id)?.importance);
1627                data.set_task_importance(&id, importance)
1628            }) {
1629                Ok(_) => self.select_task_by_id(&id),
1630                Err(error) => self.report_store_error("Could not update task", error),
1631            }
1632        }
1633    }
1634
1635    /// Reorder the selected task inside its category when using manual sort.
1636    /// In All Tasks, crossing a category section boundary is intentionally
1637    /// blocked; changing category belongs in the task form.
1638    pub fn move_task_order(&mut self, delta: isize) -> bool {
1639        if delta == 0 || self.settings.sort != "manual" || self.searching {
1640            return false;
1641        }
1642        let Some(current) = self.selected_task().cloned() else {
1643            return false;
1644        };
1645        let target_view = self.task_index as isize + delta.signum();
1646        if !(0..self.view.len() as isize).contains(&target_view) {
1647            return false;
1648        }
1649        let Some(target) = self.visible_task(target_view as usize) else {
1650            return false;
1651        };
1652        if target.category_id != current.category_id {
1653            return false;
1654        }
1655        let target_id = target.id.clone();
1656        let id = current.id;
1657        let position = if delta.is_negative() {
1658            RelativePosition::Before
1659        } else {
1660            RelativePosition::After
1661        };
1662        match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
1663            Ok(_) => {
1664                self.select_task_by_id(&id);
1665                true
1666            }
1667            Err(error) => {
1668                self.report_store_error("Could not reorder task", error);
1669                false
1670            }
1671        }
1672    }
1673
1674    /// Opens the dialog for a new task, unless the list is full.
1675    pub fn open_new_task(&mut self) {
1676        if self.tasks.len() >= MAX_TASK_COUNT {
1677            self.error(format!(
1678                "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1679            ));
1680            return;
1681        }
1682        let mut form =
1683            TaskForm::new_with_images(self.images.root().to_path_buf(), &self.attachments);
1684        let category = (!self.is_all_view()).then(|| self.current_category_id());
1685        form.set_categories(&self.categories, category);
1686        form.set_labels(&self.labels, &[]);
1687        self.task_edit_base = None;
1688        self.form = Some(form);
1689        self.mode = Mode::TaskForm;
1690    }
1691
1692    /// Opens the dialog on the selected task.
1693    pub fn open_edit_task(&mut self) {
1694        if let Some(task) = self.selected_task().cloned() {
1695            let mut form = TaskForm::edit_with_images(
1696                &task,
1697                self.images.root().to_path_buf(),
1698                &self.attachments,
1699            );
1700            form.set_categories(&self.categories, task.category_id.as_deref());
1701            form.set_labels(&self.labels, &task.label_ids);
1702            // Decode description pictures off the UI thread so the dialog opens
1703            // immediately; they fill in on the next frames.
1704            self.images.prefetch(form.description.images());
1705            self.task_edit_base = Some(task);
1706            self.form = Some(form);
1707            self.mode = Mode::TaskForm;
1708        }
1709    }
1710
1711    pub fn close_form(&mut self) {
1712        self.form = None;
1713        self.task_edit_base = None;
1714        self.mode = Mode::Normal;
1715        self.focus = Focus::Tasks;
1716        // Drop placed graphics so they do not float over the list; pixels
1717        // stay in RAM for a fast reopen. GIF frames are dropped with the form.
1718        self.images.release_form_graphics();
1719        self.images.clear_preview();
1720        self.cancel_pending();
1721    }
1722
1723    /// Validates the open form and writes it back to the task list.
1724    pub fn submit_form(&mut self) {
1725        let Some(form) = &mut self.form else { return };
1726        let Some(draft) = form.submit() else { return };
1727        let saved = match form.editing.clone() {
1728            Some(uuid) => self.update_task(&uuid, &draft),
1729            None => self.create_task(&draft).is_some(),
1730        };
1731        if saved {
1732            self.close_form();
1733        }
1734    }
1735
1736    /// Creates a task in the chosen category and selects it. A `[date]`
1737    /// left in the title is moved into `due` when `due` is empty.
1738    pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1739        let (title, due) = draft.resolved_title_and_due();
1740        if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1741            return None;
1742        }
1743        let description = draft.description.clone();
1744        let category_id = draft.category_id.clone();
1745        let label_ids = draft.label_ids.clone();
1746        let importance = draft.importance;
1747        let task = match self.update_store(|data| {
1748            let task = data.create_task(title, description, due, importance, category_id)?;
1749            data.set_task_labels(&task.id, label_ids)
1750        }) {
1751            Ok(task) => task,
1752            Err(error) => {
1753                let message = error.to_string();
1754                if let Some(form) = &mut self.form {
1755                    form.error = Some(message.clone());
1756                }
1757                self.report_store_error("Could not create task", error);
1758                return None;
1759            }
1760        };
1761        let id = task.id;
1762        self.searching = false;
1763        self.search_query.clear();
1764        self.rebuild_view();
1765        self.select_task_by_id(&id);
1766        Some(id)
1767    }
1768
1769    pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1770        let (title, due) = draft.resolved_title_and_due();
1771        if title.is_empty() {
1772            return false;
1773        }
1774        let expected = self.task_edit_base.clone();
1775        let id = id.to_string();
1776        let patch = match expected.as_ref() {
1777            Some(base) => TaskPatch {
1778                title: (title != base.title).then_some(title),
1779                description: (draft.description != base.description)
1780                    .then(|| draft.description.clone()),
1781                due: (due != base.due).then_some(due),
1782                importance: (draft.importance != base.importance).then_some(draft.importance),
1783                category_id: (draft.category_id != base.category_id)
1784                    .then(|| draft.category_id.clone()),
1785                label_ids: (draft.label_ids != base.label_ids).then(|| draft.label_ids.clone()),
1786                ..TaskPatch::default()
1787            },
1788            None => TaskPatch {
1789                title: Some(title),
1790                description: Some(draft.description.clone()),
1791                due: Some(due),
1792                importance: Some(draft.importance),
1793                category_id: Some(draft.category_id.clone()),
1794                label_ids: Some(draft.label_ids.clone()),
1795                ..TaskPatch::default()
1796            },
1797        };
1798        match self.update_store(|data| {
1799            if let Some(expected) = &expected {
1800                data.edit_task_if_unchanged(expected, patch)
1801            } else {
1802                data.edit_task(&id, patch)
1803            }
1804        }) {
1805            Ok(_) => {
1806                self.select_task_by_id(&id);
1807                true
1808            }
1809            Err(error) => {
1810                let message = edit_error_message(&error);
1811                if let Some(form) = &mut self.form {
1812                    form.error = Some(message);
1813                }
1814                self.report_store_error("Could not update task", error);
1815                false
1816            }
1817        }
1818    }
1819
1820    pub fn delete_task(&mut self, pos: usize) {
1821        let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1822            return;
1823        };
1824        self.delete_task_by_id(&id);
1825    }
1826
1827    pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1828        let id = id.to_string();
1829        if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1830            self.report_store_error("Could not delete task", error);
1831            return false;
1832        }
1833        self.cancel_pending();
1834        true
1835    }
1836
1837    /// Permanently remove done tasks. In All Tasks → every done task; in a
1838    /// category → only that category's done tasks. Nothing is archived.
1839    pub fn purge(&mut self) -> usize {
1840        let ids = self.purge_candidate_ids();
1841        self.purge_ids(&ids)
1842    }
1843
1844    /// Completed task ids in the current purge scope, captured for confirmation.
1845    pub fn purge_candidate_ids(&self) -> Vec<String> {
1846        let everywhere = self.is_all_view();
1847        let category = self.current_category_id();
1848        self.tasks
1849            .iter()
1850            .filter(|task| {
1851                task.done && (everywhere || task.category_id.as_deref() == Some(category))
1852            })
1853            .map(|task| task.id.clone())
1854            .collect()
1855    }
1856
1857    /// Purge exactly the confirmed ids; newly completed tasks are never swept in.
1858    pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1859        let ids = ids.to_vec();
1860        match self.update_store(|data| data.purge_completed_ids(&ids)) {
1861            Ok(removed) => {
1862                self.cancel_pending();
1863                removed.len()
1864            }
1865            Err(error) => {
1866                self.report_store_error("Could not purge completed tasks", error);
1867                0
1868            }
1869        }
1870    }
1871
1872    /// `/done` — show or hide completed tasks in the list (still on disk).
1873    pub fn toggle_hide_done(&mut self) -> Option<bool> {
1874        match self.update_store(|data| {
1875            data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1876        }) {
1877            Ok(settings) => Some(settings.hide_done),
1878            Err(error) => {
1879                self.report_store_error("Could not update settings", error);
1880                None
1881            }
1882        }
1883    }
1884
1885    // ---------------------------------------------------------- categories
1886
1887    /// Opens the dialog for a new category.
1888    pub fn open_new_category(&mut self) {
1889        // Count real categories (exclude the virtual All row).
1890        let real = self.categories.iter().filter(|c| !c.is_all()).count();
1891        if real >= MAX_CATEGORY_COUNT {
1892            self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1893            return;
1894        }
1895        self.category_edit_base = None;
1896        self.category_form = Some(CategoryForm::new());
1897        self.mode = Mode::CategoryForm;
1898    }
1899
1900    /// Opens the dialog on the selected category. "All Tasks" is not a
1901    /// real category and cannot be edited.
1902    pub fn open_edit_category(&mut self) {
1903        if self.is_all_view() {
1904            return;
1905        }
1906        if let Some(category) = self.categories.get(self.cat_index).cloned() {
1907            self.category_form = Some(CategoryForm::edit(&category));
1908            self.category_edit_base = Some(category);
1909            self.mode = Mode::CategoryForm;
1910        }
1911    }
1912
1913    pub fn close_category_form(&mut self) {
1914        self.category_form = None;
1915        self.category_edit_base = None;
1916        self.mode = Mode::Normal;
1917        self.cancel_pending();
1918    }
1919
1920    pub fn submit_category_form(&mut self) {
1921        let existing: Vec<(String, String)> = self
1922            .categories
1923            .iter()
1924            .filter(|category| !category.is_all())
1925            .map(|category| (category.id.clone(), category.name.clone()))
1926            .collect();
1927        let Some(form) = &mut self.category_form else {
1928            return;
1929        };
1930        let Some((name, description)) = form.submit_with(|name, editing| {
1931            let duplicate = existing.iter().any(|(id, existing_name)| {
1932                Some(id.as_str()) != editing
1933                    && category_name_key(existing_name) == category_name_key(name)
1934            });
1935            if duplicate {
1936                Err("A category with that name already exists".to_string())
1937            } else {
1938                Ok(())
1939            }
1940        }) else {
1941            return;
1942        };
1943        let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1944        let editing = form.editing.clone();
1945        let expected = self.category_edit_base.clone();
1946        let saved = match editing {
1947            Some(id) => {
1948                let patch = match expected.as_ref() {
1949                    Some(base) => CategoryPatch {
1950                        name: (name != base.name).then_some(name),
1951                        description: (description != base.description).then_some(description),
1952                    },
1953                    None => CategoryPatch {
1954                        name: Some(name),
1955                        description: Some(description),
1956                    },
1957                };
1958                match self.update_store(|data| {
1959                    if let Some(expected) = &expected {
1960                        data.edit_category_if_unchanged(expected, patch)
1961                    } else {
1962                        data.edit_category(&id, patch)
1963                    }
1964                }) {
1965                    Ok(_) => true,
1966                    Err(error) => {
1967                        let message = edit_error_message(&error);
1968                        if let Some(form) = &mut self.category_form {
1969                            form.error = Some(message);
1970                        }
1971                        self.report_store_error("Could not update category", error);
1972                        false
1973                    }
1974                }
1975            }
1976            None => match self.update_store(|data| data.create_category(name, description)) {
1977                Ok(category) => {
1978                    self.cat_index = self
1979                        .categories
1980                        .iter()
1981                        .position(|item| item.id == category.id)
1982                        .unwrap_or(0);
1983                    self.on_category_changed();
1984                    true
1985                }
1986                Err(error) => {
1987                    let message = error.to_string();
1988                    if let Some(form) = &mut self.category_form {
1989                        form.error = Some(message);
1990                    }
1991                    self.report_store_error("Could not create category", error);
1992                    false
1993                }
1994            },
1995        };
1996        if saved {
1997            self.close_category_form();
1998        }
1999    }
2000
2001    /// Deletes the category while preserving its tasks as Uncategorized.
2002    /// Category ids are stable UUIDs — no renumbering.
2003    pub fn delete_category(&mut self) {
2004        if self.is_all_view() {
2005            return;
2006        }
2007        let id = self.current_category_id().to_string();
2008        let _ = self.delete_category_by_id(&id);
2009    }
2010
2011    pub fn delete_category_by_id(&mut self, id: &str) -> bool {
2012        let Some(category) = self.categories.iter().find(|category| category.id == id) else {
2013            return false;
2014        };
2015        if category.is_all() {
2016            return false;
2017        }
2018        let id = id.to_string();
2019        match self.update_store(|data| data.delete_category(&id)) {
2020            Ok(_) => {
2021                self.cancel_pending();
2022                self.cat_index = 0;
2023                self.on_category_changed();
2024                true
2025            }
2026            Err(error) => {
2027                self.report_store_error("Could not delete category", error);
2028                false
2029            }
2030        }
2031    }
2032
2033    // --------------------------------------------------------------- labels
2034
2035    pub fn open_labels(&mut self) {
2036        self.labels_return_to_form = false;
2037        self.open_labels_manager();
2038    }
2039
2040    pub fn open_labels_from_form(&mut self) {
2041        if let Some(form) = &mut self.form {
2042            form.close_label_picker();
2043        }
2044        self.labels_return_to_form = true;
2045        self.open_labels_manager();
2046    }
2047
2048    fn open_labels_manager(&mut self) {
2049        self.mode = Mode::Labels;
2050        self.label_index = self.label_index.min(self.labels.len().saturating_sub(1));
2051        self.label_editor = None;
2052        self.label_error = None;
2053        self.cancel_pending();
2054        self.dirty = true;
2055    }
2056
2057    pub fn close_labels(&mut self) {
2058        if self.labels_return_to_form && self.form.is_some() {
2059            if let Some(form) = &mut self.form {
2060                form.refresh_labels(&self.labels);
2061            }
2062            self.mode = Mode::TaskForm;
2063        } else {
2064            self.mode = Mode::Normal;
2065        }
2066        self.labels_return_to_form = false;
2067        self.label_editor = None;
2068        self.label_error = None;
2069        self.cancel_pending();
2070        self.dirty = true;
2071    }
2072
2073    pub fn move_label_selection(&mut self, delta: isize) {
2074        if self.labels.is_empty() {
2075            return;
2076        }
2077        let last = self.labels.len() - 1;
2078        self.label_index = (self.label_index as isize + delta).clamp(0, last as isize) as usize;
2079        self.cancel_pending();
2080        self.dirty = true;
2081    }
2082
2083    pub fn begin_new_label(&mut self) {
2084        if self.labels.len() >= MAX_LABEL_COUNT {
2085            self.label_error = Some(format!("At most {MAX_LABEL_COUNT} labels"));
2086            return;
2087        }
2088        self.label_editor = Some(LabelEditor::new(
2089            None,
2090            "",
2091            LabelColor::least_used(&self.labels),
2092        ));
2093        self.label_error = None;
2094        self.cancel_pending();
2095    }
2096
2097    pub fn begin_rename_label(&mut self) {
2098        let Some(label) = self.labels.get(self.label_index) else {
2099            return;
2100        };
2101        self.label_editor = Some(LabelEditor::new(
2102            Some(label.id.clone()),
2103            &label.name,
2104            label.color,
2105        ));
2106        self.label_error = None;
2107        self.cancel_pending();
2108    }
2109
2110    pub fn cancel_label_editor(&mut self) {
2111        self.label_editor = None;
2112        self.label_error = None;
2113        self.dirty = true;
2114    }
2115
2116    pub fn submit_label_editor(&mut self) {
2117        let Some(editor) = &self.label_editor else {
2118            return;
2119        };
2120        let editing = editor.editing_id.clone();
2121        let name = editor.name.value();
2122        let color = editor.color;
2123        let result = match editing {
2124            Some(id) => self.update_store(|data| {
2125                data.edit_label(
2126                    &id,
2127                    LabelPatch {
2128                        name: Some(name),
2129                        color: Some(color),
2130                    },
2131                )
2132            }),
2133            None => self.update_store(|data| data.create_label_with_color(name, color)),
2134        };
2135        match result {
2136            Ok(label) => {
2137                self.label_index = self
2138                    .labels
2139                    .iter()
2140                    .position(|item| item.id == label.id)
2141                    .unwrap_or_default();
2142                self.label_editor = None;
2143                self.label_error = None;
2144            }
2145            Err(error) => {
2146                self.label_error = Some(error.to_string());
2147                self.dirty = true;
2148            }
2149        }
2150    }
2151
2152    pub fn selected_label(&self) -> Option<&Label> {
2153        self.labels.get(self.label_index)
2154    }
2155
2156    pub fn delete_label_by_id(&mut self, id: &str) -> bool {
2157        let id = id.to_string();
2158        match self.update_store(|data| data.delete_label(&id)) {
2159            Ok(_) => {
2160                self.label_index = self.label_index.min(self.labels.len().saturating_sub(1));
2161                self.cancel_pending();
2162                true
2163            }
2164            Err(error) => {
2165                self.report_store_error("Could not delete label", error);
2166                false
2167            }
2168        }
2169    }
2170
2171    pub fn create_label(&mut self, name: &str) -> Result<String, String> {
2172        self.update_store(|data| data.create_label(name))
2173            .map(|label| label.id)
2174            .map_err(|error| error.to_string())
2175    }
2176
2177    pub fn set_task_labels(&mut self, task_id: &str, label_ids: Vec<String>) -> Result<(), String> {
2178        let id = task_id.to_string();
2179        self.update_store(|data| data.set_task_labels(&id, label_ids))
2180            .map(|_| ())
2181            .map_err(|error| error.to_string())
2182    }
2183
2184    /// Reorder real categories while keeping the virtual All Tasks row fixed.
2185    pub fn move_category_order(&mut self, delta: isize) -> bool {
2186        if delta == 0 || self.is_all_view() || self.searching {
2187            return false;
2188        }
2189        let target_display = self.cat_index as isize + delta.signum();
2190        if !(1..self.categories.len() as isize).contains(&target_display) {
2191            return false;
2192        }
2193        let id = self.current_category_id().to_string();
2194        let target_id = self.categories[target_display as usize].id.clone();
2195        let position = if delta.is_negative() {
2196            RelativePosition::Before
2197        } else {
2198            RelativePosition::After
2199        };
2200        match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
2201            Ok(_) => {
2202                self.cat_index = self
2203                    .categories
2204                    .iter()
2205                    .position(|category| category.id == id)
2206                    .unwrap_or(0);
2207                self.on_category_changed();
2208                true
2209            }
2210            Err(error) => {
2211                self.report_store_error("Could not reorder category", error);
2212                false
2213            }
2214        }
2215    }
2216
2217    /// `(done, total)` for a category. All Tasks counts every task.
2218    pub fn category_progress(&self, id: &str) -> (usize, usize) {
2219        if let Some(idx) = self.categories.iter().position(|c| c.id == id)
2220            && let Some(&p) = self.cat_progress.get(idx)
2221        {
2222            return p;
2223        }
2224        (0, 0)
2225    }
2226
2227    pub(crate) fn category_progress_at(&self, index: usize) -> (usize, usize) {
2228        self.cat_progress.get(index).copied().unwrap_or((0, 0))
2229    }
2230
2231    // -------------------------------------------------------------- slash / search
2232
2233    /// Open the `/` command palette.
2234    pub fn open_slash(&mut self) {
2235        if self.searching {
2236            self.end_search();
2237        }
2238        if let Some(notice) = self.update_notice.take()
2239            && let Some(version) = notice.available_version
2240        {
2241            self.dismissed_update_version = Some(version);
2242        }
2243        self.mode = Mode::Slash;
2244        self.input = TextInput::new("", MAX_SLASH_INPUT_LEN);
2245        self.slash_index = 0;
2246        self.dirty = true;
2247    }
2248
2249    /// Enter live search, optionally with an initial query.
2250    pub fn start_search(&mut self, query: &str) {
2251        self.mode = Mode::Search;
2252        self.focus = Focus::Tasks;
2253        self.input = TextInput::new(query, MAX_TITLE_LEN);
2254        self.search_query = query.to_string();
2255        self.searching = true;
2256        self.task_index = 0;
2257        self.rebuild_view();
2258    }
2259
2260    pub fn update_search(&mut self) {
2261        self.search_query = self.input.value();
2262        self.searching = true;
2263        self.task_index = 0;
2264        self.rebuild_view();
2265    }
2266
2267    /// Return keyboard input to an already locked search without rebuilding
2268    /// the view or moving its selected task.
2269    pub fn resume_search(&mut self) {
2270        if !self.searching {
2271            return;
2272        }
2273        self.mode = Mode::Search;
2274        self.input = TextInput::new(&self.search_query, MAX_TITLE_LEN);
2275        self.dirty = true;
2276    }
2277
2278    pub fn end_search(&mut self) {
2279        self.searching = false;
2280        self.search_query.clear();
2281        self.task_index = 0;
2282        self.mode = Mode::Normal;
2283        self.rebuild_view();
2284    }
2285
2286    pub fn clamp_slash_index(&mut self) {
2287        let n = crate::slash::matching(&self.input.value()).len();
2288        if n == 0 {
2289            self.slash_index = 0;
2290        } else {
2291            self.slash_index = self.slash_index.min(n - 1);
2292        }
2293    }
2294
2295    // ------------------------------------------------------------ messages
2296
2297    pub fn info(&mut self, text: impl Into<String>) {
2298        self.set_message(text.into(), MessageKind::Info, MessageLifetime::Brief);
2299    }
2300
2301    pub(crate) fn archive_result(&mut self, text: impl Into<String>) {
2302        self.set_message(text.into(), MessageKind::Info, MessageLifetime::Long);
2303    }
2304
2305    pub fn error(&mut self, text: impl Into<String>) {
2306        self.set_message(text.into(), MessageKind::Error, MessageLifetime::Standard);
2307    }
2308
2309    pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
2310        self.message
2311            .as_ref()
2312            .map(|message| (message.text.as_str(), message.kind))
2313            .or_else(|| {
2314                self.update_notice
2315                    .as_ref()
2316                    .map(|notice| (notice.text.as_str(), MessageKind::Info))
2317            })
2318    }
2319
2320    pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
2321        self.update_activity
2322    }
2323
2324    pub(crate) fn archive_activity_text(&self) -> Option<String> {
2325        let job = self.archive_job.as_ref()?;
2326        if self.quit_after_archive {
2327            let action = if job.cancel_requested {
2328                "Cancelling"
2329            } else {
2330                "Finishing"
2331            };
2332            return Some(format!("{action} {} before quit…", job.kind.name()));
2333        }
2334        if job.cancel_requested {
2335            return Some(format!("Cancelling {}…", job.kind.name()));
2336        }
2337        let text = match job.progress {
2338            crate::archive::ArchiveProgress::Preparing => {
2339                format!("Preparing {}… · Esc cancels", job.kind.name())
2340            }
2341            crate::archive::ArchiveProgress::Attachments { completed, total } if total > 0 => {
2342                let action = match job.kind {
2343                    ArchiveJobKind::Export => "Exporting",
2344                    ArchiveJobKind::Import => "Importing",
2345                };
2346                format!("{action} images {completed}/{total}… · Esc cancels")
2347            }
2348            crate::archive::ArchiveProgress::Attachments { .. } => {
2349                let action = match job.kind {
2350                    ArchiveJobKind::Export => "Writing export",
2351                    ArchiveJobKind::Import => "Reading import",
2352                };
2353                format!("{action}… · Esc cancels")
2354            }
2355            crate::archive::ArchiveProgress::Finalizing => {
2356                format!("Finishing {}…", job.kind.name())
2357            }
2358        };
2359        Some(text)
2360    }
2361
2362    pub(crate) fn background_work_active(&self) -> bool {
2363        self.archive_job.is_some()
2364            || self
2365                .update_job
2366                .as_ref()
2367                .is_some_and(|job| job.kind == UpdateJobKind::Install)
2368    }
2369
2370    fn set_message(&mut self, text: String, kind: MessageKind, lifetime: MessageLifetime) {
2371        self.set_message_until(text, kind, Instant::now() + lifetime.duration());
2372    }
2373
2374    fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
2375        // A confirmation is only safe while its matching prompt is visible.
2376        // Any independent status replaces that prompt and therefore disarms
2377        // the pending destructive action as part of the same state change.
2378        self.pending = None;
2379        self.message = Some(Message { text, kind, until });
2380        self.dirty = true;
2381    }
2382
2383    /// Drop expired status messages. Returns true when the UI should redraw.
2384    pub fn expire_message(&mut self) -> bool {
2385        if let Some(m) = &self.message
2386            && Instant::now() >= m.until
2387        {
2388            self.pending = None;
2389            self.message = None;
2390            self.dirty = true;
2391            return true;
2392        }
2393        false
2394    }
2395
2396    /// Arm a destructive action for its explicit confirmation step, and say so.
2397    pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
2398        let until = Instant::now() + MessageLifetime::Brief.duration();
2399        self.set_message_until(prompt.into(), MessageKind::Info, until);
2400        self.pending = Some((confirm, until));
2401    }
2402
2403    /// Whether `confirm` is armed and still inside its window.
2404    pub fn awaiting(&self, confirm: Confirm) -> bool {
2405        matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
2406    }
2407
2408    pub fn pending_confirmation(&self) -> Option<&Confirm> {
2409        self.pending
2410            .as_ref()
2411            .filter(|(_, until)| Instant::now() < *until)
2412            .map(|(confirm, _)| confirm)
2413    }
2414
2415    // ----------------------------------------------------------- settings
2416
2417    /// Step a settings row by `delta` (+1 forward, −1 back), wrapping.
2418    pub fn cycle_setting(&mut self, index: usize, delta: isize) {
2419        use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
2420        if index >= SETTINGS_ITEMS.len() {
2421            return;
2422        }
2423        if let Err(error) = self.update_store(|data| {
2424            data.update_settings(|settings| match index {
2425                0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
2426                1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
2427                2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
2428                3 => {
2429                    settings.preview_position =
2430                        cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
2431                }
2432                _ => {}
2433            })
2434        }) {
2435            self.report_store_error("Could not update settings", error);
2436        }
2437    }
2438
2439    pub fn setting_value(&self, index: usize) -> String {
2440        match index {
2441            0 => crate::settings::sort_label(&self.settings.sort).to_string(),
2442            1 => crate::settings::theme_label(&self.settings.selected_color),
2443            2 => self.settings.date_format.clone(),
2444            3 => {
2445                crate::settings::preview_position_label(&self.settings.preview_position).to_string()
2446            }
2447            _ => String::new(),
2448        }
2449    }
2450}
2451
2452fn task_labels_contain(task: &Task, labels: &[Label], query: &str) -> bool {
2453    task.label_ids.iter().any(|id| {
2454        labels
2455            .iter()
2456            .find(|label| label.id == *id)
2457            .is_some_and(|label| caseless_contains(&label.name, query))
2458    })
2459}
2460
2461fn edit_error_message(error: &StoreError) -> String {
2462    match error {
2463        StoreError::StaleEntity { .. } => {
2464            format!("{error}; close and reopen the editor to load the latest values")
2465        }
2466        _ => error.to_string(),
2467    }
2468}
2469
2470pub fn truncate_chars(s: &str, max: usize) -> String {
2471    s.graphemes(true).take(max).collect()
2472}
2473
2474#[cfg(test)]
2475mod tests {
2476    use super::*;
2477
2478    fn exported_task_archive(root: &Path, title: &str) -> PathBuf {
2479        let mut source = Store::open(root.join("source")).expect("open archive source");
2480        source
2481            .update(|data| {
2482                data.tasks.push(Task::new(title, 0, None, ""));
2483                Ok(())
2484            })
2485            .expect("create archived task");
2486        let path = root.join("tasks.mach");
2487        crate::archive::export(&source, Some(&path)).expect("export task archive");
2488        path
2489    }
2490
2491    fn wait_for_archive(app: &mut App) {
2492        let deadline = Instant::now() + Duration::from_secs(5);
2493        while app.archive_job.is_some() && Instant::now() < deadline {
2494            app.poll_archive();
2495            std::thread::sleep(Duration::from_millis(10));
2496        }
2497        assert!(app.archive_job.is_none(), "archive worker did not finish");
2498    }
2499
2500    fn assert_message_lifetime(app: &App, expected: Duration) {
2501        let remaining = app
2502            .message
2503            .as_ref()
2504            .expect("temporary message")
2505            .until
2506            .saturating_duration_since(Instant::now());
2507        assert!(remaining <= expected, "{remaining:?} exceeds {expected:?}");
2508        assert!(
2509            remaining >= expected.saturating_sub(Duration::from_millis(100)),
2510            "{remaining:?} is shorter than {expected:?}"
2511        );
2512    }
2513
2514    fn update_result(newer: bool) -> crate::update::CheckResult {
2515        crate::update::CheckResult {
2516            current: "0.2.0".into(),
2517            latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
2518            tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
2519            newer,
2520            prerelease: false,
2521            release_url: "https://example.test/release".into(),
2522            asset_name: "mach-aarch64-apple-darwin".into(),
2523            asset_url: "https://example.test/binary".into(),
2524            checksums_url: "https://example.test/SHA256SUMS".into(),
2525        }
2526    }
2527
2528    fn automatic_outcome(newer: bool) -> UpdateOutcome {
2529        UpdateOutcome::Automatic(CheckResponse::Modified {
2530            value: update_result(newer),
2531            etag: None,
2532        })
2533    }
2534
2535    fn update_failure(message: &str) -> CheckFailure {
2536        CheckFailure {
2537            message: message.into(),
2538            retry_at: None,
2539        }
2540    }
2541
2542    fn finished(result: Result<UpdateOutcome, CheckFailure>) -> UpdateEvent {
2543        UpdateEvent::Finished(Box::new(result))
2544    }
2545
2546    fn claim_automatic_lease(app: &mut App, now: i64) -> UpdateLease {
2547        let AutomaticClaim::Claimed(lease) = app
2548            .update_state
2549            .as_mut()
2550            .expect("test update state")
2551            .try_claim_automatic(now)
2552            .unwrap()
2553        else {
2554            panic!("automatic update should be due");
2555        };
2556        lease
2557    }
2558
2559    #[test]
2560    fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
2561        let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
2562            .expect("open in-memory store");
2563        let mut app = App::with_store("test", store).expect("build app");
2564        app.mode = Mode::Normal;
2565
2566        for _ in 0..(MAX_TITLE_LEN * 2) {
2567            app.typeahead_jump('x');
2568        }
2569
2570        assert!(
2571            app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
2572            "a held key must not grow the navigation query without bound"
2573        );
2574    }
2575
2576    #[test]
2577    fn transient_messages_use_three_shared_lifetimes() {
2578        let store = Store::open_in_memory_with_paths("/tmp/mach-message-lifetime-test")
2579            .expect("open in-memory store");
2580        let mut app = App::with_store("test", store).expect("build app");
2581
2582        app.info("brief info");
2583        assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2584
2585        app.ask_confirm(Confirm::Quit, "brief confirmation");
2586        assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2587
2588        app.ask_confirm(Confirm::DiscardTask(None), "brief discard confirmation");
2589        assert_message_lifetime(&app, MessageLifetime::Brief.duration());
2590
2591        app.error("standard error");
2592        assert_message_lifetime(&app, MessageLifetime::Standard.duration());
2593
2594        app.archive_result("long archive result");
2595        assert_message_lifetime(&app, MessageLifetime::Long.duration());
2596
2597        app.show_update_message("long update result".into(), MessageKind::Info);
2598        assert_message_lifetime(&app, MessageLifetime::Long.duration());
2599    }
2600
2601    #[test]
2602    fn background_import_reloads_the_completed_store() {
2603        let root = std::env::temp_dir().join(format!(
2604            "mach-background-import-{}-{}",
2605            std::process::id(),
2606            uuid::Uuid::new_v4()
2607        ));
2608        let archive = exported_task_archive(&root, "imported in the background");
2609        let store = Store::open(root.join("destination")).expect("open archive destination");
2610        let mut app = App::with_store("test", store).expect("build destination app");
2611
2612        app.start_import_archive(archive);
2613        assert!(app.background_work_active());
2614        wait_for_archive(&mut app);
2615
2616        assert_eq!(app.tasks.len(), 1);
2617        assert_eq!(app.tasks[0].title, "imported in the background");
2618        assert!(
2619            app.message
2620                .as_ref()
2621                .is_some_and(|message| message.text.contains("Imported 1 task"))
2622        );
2623
2624        drop(app);
2625        std::fs::remove_dir_all(&root).expect("remove archive test directory");
2626    }
2627
2628    #[test]
2629    fn cancelling_a_background_import_prevents_its_commit() {
2630        let root = std::env::temp_dir().join(format!(
2631            "mach-cancel-import-{}-{}",
2632            std::process::id(),
2633            uuid::Uuid::new_v4()
2634        ));
2635        let archive = exported_task_archive(&root, "must not be imported");
2636        let destination = root.join("destination");
2637        let store = Store::open(&destination).expect("open archive destination");
2638        let lock = rusqlite::Connection::open(destination.join("mach.db"))
2639            .expect("open destination database lock");
2640        lock.execute_batch("BEGIN IMMEDIATE")
2641            .expect("hold destination write lock");
2642        let mut app = App::with_store("test", store).expect("build destination app");
2643
2644        app.start_import_archive(archive);
2645        assert!(app.cancel_archive());
2646        lock.execute_batch("ROLLBACK")
2647            .expect("release destination write lock");
2648        wait_for_archive(&mut app);
2649
2650        assert!(app.tasks.is_empty());
2651        assert_eq!(
2652            app.message.as_ref().map(|message| message.text.as_str()),
2653            Some("Import cancelled")
2654        );
2655
2656        drop(lock);
2657        drop(app);
2658        std::fs::remove_dir_all(&root).expect("remove archive test directory");
2659    }
2660
2661    #[test]
2662    fn quit_waits_for_background_archive_cleanup() {
2663        let root = std::env::temp_dir().join(format!(
2664            "mach-quit-during-import-{}-{}",
2665            std::process::id(),
2666            uuid::Uuid::new_v4()
2667        ));
2668        let archive = exported_task_archive(&root, "must not outlive mach");
2669        let destination = root.join("destination");
2670        let store = Store::open(&destination).expect("open archive destination");
2671        let lock = rusqlite::Connection::open(destination.join("mach.db"))
2672            .expect("open destination database lock");
2673        lock.execute_batch("BEGIN IMMEDIATE")
2674            .expect("hold destination write lock");
2675        let mut app = App::with_store("test", store).expect("build destination app");
2676
2677        app.start_import_archive(archive);
2678        app.request_quit();
2679        assert!(!app.should_quit, "quit must wait for archive cleanup");
2680        lock.execute_batch("ROLLBACK")
2681            .expect("release destination write lock");
2682        wait_for_archive(&mut app);
2683
2684        assert!(app.should_quit);
2685        assert!(app.tasks.is_empty());
2686
2687        drop(lock);
2688        drop(app);
2689        std::fs::remove_dir_all(&root).expect("remove archive test directory");
2690    }
2691
2692    #[test]
2693    fn automatic_update_claim_is_shared_across_task_stores() {
2694        let root = std::env::temp_dir().join(format!(
2695            "mach-update-claim-{}-{}",
2696            std::process::id(),
2697            uuid::Uuid::new_v4()
2698        ));
2699        let now = 1_800_000_000;
2700        let state_path = root.join("global").join("update.db");
2701        let mut first = App::with_store_and_update_state(
2702            "test",
2703            Store::open(root.join("one")).unwrap(),
2704            UpdateStateStore::open(&state_path),
2705        )
2706        .unwrap();
2707
2708        assert!(matches!(
2709            first
2710                .update_state
2711                .as_mut()
2712                .unwrap()
2713                .try_claim_automatic(now)
2714                .unwrap(),
2715            AutomaticClaim::Claimed(_)
2716        ));
2717        drop(first);
2718
2719        let mut second = App::with_store_and_update_state(
2720            "test",
2721            Store::open(root.join("two")).unwrap(),
2722            UpdateStateStore::open(&state_path),
2723        )
2724        .unwrap();
2725        assert!(matches!(
2726            second
2727                .update_state
2728                .as_mut()
2729                .unwrap()
2730                .try_claim_automatic(now)
2731                .unwrap(),
2732            AutomaticClaim::Waiting(_)
2733        ));
2734        drop(second);
2735        std::fs::remove_dir_all(root).unwrap();
2736    }
2737
2738    #[test]
2739    fn failed_automatic_update_check_retries_before_the_daily_interval() {
2740        let store = Store::open_in_memory_with_paths("/tmp/mach-update-retry-test").unwrap();
2741        let mut app = App::with_store("test", store).unwrap();
2742        let now = Utc::now().timestamp();
2743
2744        let lease = claim_automatic_lease(&mut app, now);
2745        let (tx, rx) = mpsc::channel();
2746        app.update_job = Some(UpdateJob {
2747            rx,
2748            kind: UpdateJobKind::Automatic,
2749            lease: Some(lease),
2750        });
2751        tx.send(finished(Err(update_failure("offline")))).unwrap();
2752
2753        assert!(!app.poll_update());
2754        let retry_at = app
2755            .update_state
2756            .as_ref()
2757            .unwrap()
2758            .snapshot()
2759            .unwrap()
2760            .next_check_at
2761            .expect("failed check schedules a retry");
2762        assert!(retry_at < now + 24 * 60 * 60);
2763        assert!(matches!(
2764            app.update_state
2765                .as_mut()
2766                .unwrap()
2767                .try_claim_automatic(retry_at)
2768                .unwrap(),
2769            AutomaticClaim::Claimed(_)
2770        ));
2771    }
2772
2773    #[test]
2774    fn available_update_notice_survives_reopening_the_app() {
2775        let dir = std::env::temp_dir().join(format!(
2776            "mach-update-notice-{}-{}",
2777            std::process::id(),
2778            uuid::Uuid::new_v4()
2779        ));
2780        let state_path = dir.join("global-update.db");
2781        let mut app = App::with_store_and_update_state(
2782            "0.2.0",
2783            Store::open(dir.join("tasks")).unwrap(),
2784            UpdateStateStore::open(&state_path),
2785        )
2786        .unwrap();
2787        let lease = claim_automatic_lease(&mut app, Utc::now().timestamp());
2788        let (tx, rx) = mpsc::channel();
2789        app.update_job = Some(UpdateJob {
2790            rx,
2791            kind: UpdateJobKind::Automatic,
2792            lease: Some(lease),
2793        });
2794        tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2795
2796        assert!(app.poll_update());
2797        drop(app);
2798
2799        let reopened = App::with_store_and_update_state(
2800            "0.2.0",
2801            Store::open(dir.join("tasks")).unwrap(),
2802            UpdateStateStore::open(&state_path),
2803        )
2804        .unwrap();
2805        assert!(
2806            reopened
2807                .status_message()
2808                .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2809        );
2810        drop(reopened);
2811        std::fs::remove_dir_all(dir).unwrap();
2812    }
2813
2814    #[test]
2815    fn available_update_notice_reaches_an_already_running_instance() {
2816        let root = std::env::temp_dir().join(format!(
2817            "mach-running-update-notice-{}-{}",
2818            std::process::id(),
2819            uuid::Uuid::new_v4()
2820        ));
2821        let state_path = root.join("global-update.db");
2822        let mut checker = App::with_store_and_update_state(
2823            "0.2.0",
2824            Store::open(root.join("tasks-one")).unwrap(),
2825            UpdateStateStore::open(&state_path),
2826        )
2827        .unwrap();
2828        let mut observer = App::with_store_and_update_state(
2829            "0.2.0",
2830            Store::open(root.join("tasks-two")).unwrap(),
2831            UpdateStateStore::open(&state_path),
2832        )
2833        .unwrap();
2834        let now = Utc::now().timestamp();
2835        let lease = claim_automatic_lease(&mut checker, now);
2836        let (tx, rx) = mpsc::channel();
2837        checker.update_job = Some(UpdateJob {
2838            rx,
2839            kind: UpdateJobKind::Automatic,
2840            lease: Some(lease),
2841        });
2842        tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2843
2844        assert!(checker.poll_update());
2845        assert!(observer.poll_automatic_update_schedule_at(now + 1));
2846        assert!(
2847            observer
2848                .status_message()
2849                .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2850        );
2851        drop(checker);
2852        drop(observer);
2853        std::fs::remove_dir_all(root).unwrap();
2854    }
2855
2856    #[test]
2857    fn cached_latest_release_is_compared_per_running_binary() {
2858        let root = std::env::temp_dir().join(format!(
2859            "mach-multiple-binaries-{}-{}",
2860            std::process::id(),
2861            uuid::Uuid::new_v4()
2862        ));
2863        let state_path = root.join("global-update.db");
2864        let now = 1_800_000_000;
2865        let mut state = UpdateStateStore::open(&state_path).unwrap();
2866        let AutomaticClaim::Claimed(lease) = state.try_claim_automatic(now).unwrap() else {
2867            panic!("first check should be due");
2868        };
2869        state.finish_modified(&lease, now, None, "0.3.0").unwrap();
2870        drop(state);
2871
2872        let current = App::with_store_and_update_state(
2873            "0.3.0",
2874            Store::open(root.join("current-tasks")).unwrap(),
2875            UpdateStateStore::open(&state_path),
2876        )
2877        .unwrap();
2878        assert!(current.status_message().is_none());
2879        drop(current);
2880
2881        let older = App::with_store_and_update_state(
2882            "0.2.0",
2883            Store::open(root.join("older-tasks")).unwrap(),
2884            UpdateStateStore::open(&state_path),
2885        )
2886        .unwrap();
2887        assert!(
2888            older
2889                .status_message()
2890                .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2891        );
2892        drop(older);
2893        std::fs::remove_dir_all(root).unwrap();
2894    }
2895
2896    #[test]
2897    fn upgraded_version_shows_whats_new_once() {
2898        let dir = std::env::temp_dir().join(format!(
2899            "mach-whats-new-{}-{}",
2900            std::process::id(),
2901            uuid::Uuid::new_v4()
2902        ));
2903        let mut store = Store::open(&dir).unwrap();
2904        store
2905            .update(|data| {
2906                data.settings.last_run_version = Some("0.1.9".into());
2907                Ok(())
2908            })
2909            .unwrap();
2910
2911        let mut first = App::with_store("0.2.0", store).unwrap();
2912        assert_eq!(first.mode, Mode::WhatsNew);
2913        first.record_launch().unwrap();
2914        drop(first);
2915
2916        let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
2917        assert_eq!(second.mode, Mode::Normal);
2918        drop(second);
2919        std::fs::remove_dir_all(dir).unwrap();
2920    }
2921
2922    #[test]
2923    fn recording_launch_reclassifies_a_concurrent_provisional_welcome() {
2924        let dir = std::env::temp_dir().join(format!(
2925            "mach-concurrent-launch-{}-{}",
2926            std::process::id(),
2927            uuid::Uuid::new_v4()
2928        ));
2929        let mut first = App::with_store("0.4.0", Store::open(&dir).unwrap()).unwrap();
2930        let mut second = App::with_store("0.4.0", Store::open(&dir).unwrap()).unwrap();
2931        assert_eq!(first.mode, Mode::Welcome);
2932        assert_eq!(second.mode, Mode::Welcome);
2933
2934        first.record_launch().unwrap();
2935        second.record_launch().unwrap();
2936
2937        assert_eq!(first.mode, Mode::Welcome);
2938        assert_eq!(second.mode, Mode::Normal);
2939        drop(first);
2940        drop(second);
2941        std::fs::remove_dir_all(dir).unwrap();
2942    }
2943
2944    #[test]
2945    fn tui_update_install_success_requests_restart() {
2946        let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
2947        let mut app = App::with_store("test", store).unwrap();
2948        let (tx, rx) = mpsc::channel();
2949        app.update_job = Some(UpdateJob {
2950            rx,
2951            kind: UpdateJobKind::Install,
2952            lease: None,
2953        });
2954        app.update_activity = Some(UpdateActivity::Checking);
2955        tx.send(finished(Ok(UpdateOutcome::Installed {
2956            result: crate::update::InstallResult {
2957                destination: "/tmp/mach-bin/mach".into(),
2958                tag: "v0.3.0".into(),
2959                disposition: crate::update::InstallDisposition::Installed,
2960            },
2961            info: update_result(true),
2962            etag: None,
2963        })))
2964        .unwrap();
2965
2966        assert!(app.poll_update());
2967        assert_eq!(
2968            app.status_message().map(|(text, _)| text),
2969            Some("Installed v0.3.0 · restart mach")
2970        );
2971        assert!(app.update_activity().is_none());
2972
2973        assert!(!app.expire_message());
2974        assert_eq!(
2975            app.status_message().map(|(text, _)| text),
2976            Some("Installed v0.3.0 · restart mach")
2977        );
2978
2979        app.open_slash();
2980        assert!(app.status_message().is_none());
2981    }
2982
2983    #[test]
2984    fn tui_update_reports_a_concurrently_installed_release_truthfully() {
2985        let store = Store::open_in_memory_with_paths("/tmp/mach-install-race-test").unwrap();
2986        let mut app = App::with_store("test", store).unwrap();
2987        let (tx, rx) = mpsc::channel();
2988        app.update_job = Some(UpdateJob {
2989            rx,
2990            kind: UpdateJobKind::Install,
2991            lease: None,
2992        });
2993        app.update_activity = Some(UpdateActivity::Checking);
2994        tx.send(finished(Ok(UpdateOutcome::Installed {
2995            result: crate::update::InstallResult {
2996                destination: "/tmp/mach-bin/mach".into(),
2997                tag: "v0.3.1".into(),
2998                disposition: crate::update::InstallDisposition::AlreadyCurrent,
2999            },
3000            info: update_result(true),
3001            etag: None,
3002        })))
3003        .unwrap();
3004
3005        assert!(app.poll_update());
3006        assert_eq!(
3007            app.status_message().map(|(text, _)| text),
3008            Some("Already installed v0.3.1 · restart mach")
3009        );
3010    }
3011
3012    #[test]
3013    fn update_download_progress_is_applied_before_the_final_result() {
3014        let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
3015        let mut app = App::with_store("test", store).unwrap();
3016        let (tx, rx) = mpsc::channel();
3017        app.update_job = Some(UpdateJob {
3018            rx,
3019            kind: UpdateJobKind::Install,
3020            lease: None,
3021        });
3022        app.update_activity = Some(UpdateActivity::Checking);
3023        tx.send(UpdateEvent::DownloadProgress(
3024            crate::update::DownloadProgress {
3025                downloaded: 512,
3026                total: Some(1024),
3027            },
3028        ))
3029        .unwrap();
3030
3031        assert!(app.poll_update());
3032        assert_eq!(
3033            app.update_activity(),
3034            Some(UpdateActivity::Downloading(
3035                crate::update::DownloadProgress {
3036                    downloaded: 512,
3037                    total: Some(1024),
3038                }
3039            ))
3040        );
3041    }
3042
3043    #[test]
3044    fn tui_update_install_error_keeps_the_recovery_command() {
3045        let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
3046        let mut app = App::with_store("test", store).unwrap();
3047        let (tx, rx) = mpsc::channel();
3048        app.update_job = Some(UpdateJob {
3049            rx,
3050            kind: UpdateJobKind::Install,
3051            lease: None,
3052        });
3053        tx.send(finished(Ok(UpdateOutcome::InstallFailed {
3054            message:
3055                "this mach executable is managed by Cargo; run cargo install --locked mach-tui"
3056                    .into(),
3057            info: update_result(true),
3058            etag: None,
3059        })))
3060        .unwrap();
3061
3062        assert!(app.poll_update());
3063        let message = app.message.as_ref().expect("visible install error");
3064        assert_eq!(message.kind, MessageKind::Error);
3065        assert!(message.text.contains("cargo install --locked mach-tui"));
3066    }
3067
3068    #[test]
3069    fn automatic_update_results_are_silent_unless_a_new_version_exists() {
3070        let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
3071        let mut app = App::with_store("0.2.0", store).unwrap();
3072        let (tx, rx) = mpsc::channel();
3073        app.update_job = Some(UpdateJob {
3074            rx,
3075            kind: UpdateJobKind::Automatic,
3076            lease: None,
3077        });
3078        tx.send(finished(Ok(automatic_outcome(false)))).unwrap();
3079
3080        assert!(!app.poll_update());
3081        assert!(app.message.is_none());
3082
3083        let (tx, rx) = mpsc::channel();
3084        app.update_job = Some(UpdateJob {
3085            rx,
3086            kind: UpdateJobKind::Automatic,
3087            lease: None,
3088        });
3089        tx.send(finished(Err(update_failure("offline")))).unwrap();
3090
3091        assert!(!app.poll_update());
3092        assert!(app.message.is_none());
3093    }
3094
3095    #[test]
3096    fn automatic_update_notice_waits_for_an_active_confirmation() {
3097        let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
3098        let mut app = App::with_store("0.2.0", store).unwrap();
3099        app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
3100        let (tx, rx) = mpsc::channel();
3101        app.update_job = Some(UpdateJob {
3102            rx,
3103            kind: UpdateJobKind::Automatic,
3104            lease: None,
3105        });
3106        tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
3107
3108        assert!(!app.poll_update());
3109        assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
3110        assert_eq!(
3111            app.message.as_ref().map(|message| message.text.as_str()),
3112            Some("Press Ctrl+C again to quit")
3113        );
3114
3115        app.cancel_pending();
3116        assert!(app.status_message().is_some_and(|(text, _)| {
3117            text.contains("v0.2.0 → v0.3.0 available · run /update to install")
3118        }));
3119
3120        app.info("Temporary action result");
3121        assert_eq!(
3122            app.status_message().map(|(text, _)| text),
3123            Some("Temporary action result")
3124        );
3125        app.message.as_mut().unwrap().until = Instant::now();
3126        assert!(app.expire_message());
3127        assert!(app.status_message().is_some_and(|(text, _)| {
3128            text.contains("v0.2.0 → v0.3.0 available · run /update to install")
3129        }));
3130
3131        app.open_slash();
3132        assert!(app.status_message().is_none());
3133    }
3134}