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