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