Skip to main content

okf_studio/
app.rs

1//! The application model: state, messages, commands, and the reducer.
2//!
3//! Elm-style unidirectional flow: `update(&mut App, Msg)` is pure state
4//! mutation — it never blocks and never touches the disk. Side effects are
5//! requested as [`Command`]s that the main loop drains to the worker thread,
6//! plus two main-thread specials (`$EDITOR` suspension and OSC 52 copy).
7
8use crate::graph::{LayoutEngine, LayoutMode};
9use crate::keymap::{Action, Context, resolve};
10use crate::snapshot::{LeafKind, Snapshot, TreeNode};
11use crate::theme::Theme;
12use crate::{StudioOptions, Tab};
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
14use okf_core::{ConceptId, Date, RefactorError, TrustTier};
15use okf_core::{MergeReport, MoveReport, RemoveReport, RenameSectionReport, SplitReport};
16use std::collections::{HashSet, VecDeque};
17use std::path::PathBuf;
18use std::sync::Arc;
19
20/// Ticks (250 ms each) a toast stays visible.
21const TOAST_TTL: u8 = 12;
22
23/// An input message consumed by [`App::update`].
24#[derive(Debug)]
25pub enum Msg {
26    /// A key press.
27    Key(KeyEvent),
28    /// A mouse event.
29    Mouse(MouseEvent),
30    /// The terminal was resized.
31    Resize,
32    /// The 250 ms heartbeat: spinners, toast expiry, debounced previews,
33    /// incremental layout.
34    Tick,
35    /// The watcher saw files change on disk.
36    FilesChanged,
37    /// The worker finished building a snapshot.
38    SnapshotReady(Arc<Snapshot>),
39    /// The worker failed to load the bundle.
40    SnapshotFailed(String),
41    /// A refactor dry-run finished.
42    PreviewReady(u64, Result<PreviewReport, RefactorError>),
43    /// A write operation finished: `Ok(toast)` or `Err(message)`.
44    Applied(Result<String, String>),
45    /// A bundle-wide fix dry-run finished.
46    FixReportReady(Box<okf_core::BundleFixReport>),
47    /// A background error worth a toast.
48    Error(String),
49}
50
51/// A side-effect request executed by the worker thread.
52#[derive(Clone, Debug)]
53pub enum Command {
54    /// Rebuild the snapshot from disk.
55    Reload,
56    /// Dry-run a refactor; answer with [`Msg::PreviewReady`] carrying the id.
57    Preview {
58        /// Correlation id for the answer.
59        request: u64,
60        /// The operation to simulate.
61        op: RefactorOp,
62    },
63    /// Apply a refactor for real.
64    Apply(RefactorOp),
65    /// Append a verification stamp to a concept.
66    StampVerification(ConceptId),
67    /// Write a new `stale_after` date.
68    SetStaleAfter(ConceptId, Date),
69    /// Scaffold a new concept file.
70    CreateConcept {
71        /// Path relative to the bundle root (`.md` optional).
72        rel_path: String,
73        /// The concept `type`.
74        type_: String,
75        /// Optional explicit title.
76        title: Option<String>,
77    },
78    /// Dry-run the bundle fix engine.
79    PreviewFix,
80    /// Apply all safe fixes.
81    ApplyFix,
82    /// Apply safe fixes to one file.
83    ApplyFixFile(PathBuf),
84    /// Re-pin the evaluation date (`None` returns to the wall clock).
85    SetToday(Option<Date>),
86    /// Stop the worker thread.
87    Shutdown,
88}
89
90/// A refactor operation, shared by preview and apply.
91#[derive(Clone, Debug)]
92pub enum RefactorOp {
93    /// Move / rename a concept.
94    Move {
95        /// Current id.
96        source: ConceptId,
97        /// New id.
98        target: ConceptId,
99        /// Overwrite an existing target.
100        force: bool,
101    },
102    /// Remove a concept.
103    Remove {
104        /// The concept to remove.
105        target: ConceptId,
106        /// Redirect inbound links here.
107        redirect_to: Option<ConceptId>,
108        /// Unlink inbound links to plain text.
109        unlink: bool,
110        /// Remove even with inbound links.
111        force: bool,
112    },
113    /// Merge `source` into `target`.
114    Merge {
115        /// The concept that disappears.
116        source: ConceptId,
117        /// The surviving concept.
118        target: ConceptId,
119    },
120    /// Split a section out into a new concept.
121    Split {
122        /// The concept holding the section.
123        source: ConceptId,
124        /// The new concept's id.
125        target: ConceptId,
126        /// The section heading to extract.
127        section: String,
128        /// Title for the new concept.
129        title: Option<String>,
130        /// Overwrite an existing target.
131        force: bool,
132    },
133    /// Rename a section heading.
134    RenameSection {
135        /// The concept holding the section.
136        concept: ConceptId,
137        /// The current heading.
138        old: String,
139        /// The new heading.
140        new: String,
141    },
142}
143
144/// A dry-run (or applied) refactor report.
145#[derive(Clone, Debug)]
146pub enum PreviewReport {
147    /// From [`okf_core::move_concept`].
148    Move(MoveReport),
149    /// From [`okf_core::remove_concept`].
150    Remove(RemoveReport),
151    /// From [`okf_core::merge_concepts`].
152    Merge(MergeReport),
153    /// From [`okf_core::split_concept`].
154    Split(SplitReport),
155    /// From [`okf_core::rename_section`].
156    RenameSection(RenameSectionReport),
157}
158
159/// What the tree selection points at. Selection is held by identifier, not
160/// index, so it survives snapshot swaps.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub enum TreeSel {
163    /// A directory (by `/`-joined relative path).
164    Dir(String),
165    /// A concept.
166    Concept(ConceptId),
167    /// A reserved or broken file.
168    File(PathBuf),
169}
170
171/// One visible row of the flattened tree.
172#[derive(Clone, Debug)]
173pub struct TreeRow {
174    /// Indentation depth.
175    pub depth: usize,
176    /// The selection identifier.
177    pub sel: TreeSel,
178    /// Display name.
179    pub name: String,
180    /// Row payload.
181    pub kind: TreeRowKind,
182}
183
184/// The payload of a tree row.
185#[derive(Clone, Debug)]
186pub enum TreeRowKind {
187    /// A directory row.
188    Dir {
189        /// Recursive concept count.
190        count: usize,
191        /// Whether it is currently collapsed.
192        collapsed: bool,
193    },
194    /// A file row.
195    Leaf(LeafKind),
196}
197
198/// Which explorer pane has focus.
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum ExplorerPane {
201    /// The tree.
202    #[default]
203    Tree,
204    /// The document viewer.
205    Viewer,
206}
207
208/// Explorer workspace state.
209#[derive(Debug, Default)]
210pub struct ExplorerState {
211    /// Focused pane.
212    pub pane: ExplorerPane,
213    /// Current selection.
214    pub selected: Option<TreeSel>,
215    /// Collapsed directory paths.
216    pub collapsed: HashSet<String>,
217    /// Tree scroll offset, written back by the view.
218    pub tree_offset: std::cell::Cell<usize>,
219    /// Viewer scroll offset (rendered lines).
220    pub scroll: usize,
221    /// The height-clamped maximum scroll, written back by the view.
222    pub max_scroll: std::cell::Cell<usize>,
223    /// Focused link index in the rendered document.
224    pub focused_link: Option<usize>,
225    /// Whether frontmatter shows as raw YAML.
226    pub raw_yaml: bool,
227    /// Inspector tab: 0 Meta, 1 Links, 2 Sources, 3 History.
228    pub inspector_tab: usize,
229    /// Back stack of viewed concepts.
230    pub history: Vec<ConceptId>,
231    /// Forward stack.
232    pub future: Vec<ConceptId>,
233}
234
235/// The dimension the graph colors nodes by.
236#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
237pub enum ColorBy {
238    /// Trust tier (the default and the studio's primary hue).
239    #[default]
240    Trust,
241    /// Lifecycle status.
242    Status,
243    /// Staleness.
244    Staleness,
245    /// Concept type.
246    Type,
247    /// Open diagnostics.
248    Diagnostics,
249}
250
251impl ColorBy {
252    /// The next dimension in the cycle.
253    #[must_use]
254    pub const fn next(self) -> Self {
255        match self {
256            Self::Trust => Self::Status,
257            Self::Status => Self::Staleness,
258            Self::Staleness => Self::Type,
259            Self::Type => Self::Diagnostics,
260            Self::Diagnostics => Self::Trust,
261        }
262    }
263
264    /// Display name.
265    #[must_use]
266    pub const fn name(self) -> &'static str {
267        match self {
268            Self::Trust => "trust",
269            Self::Status => "status",
270            Self::Staleness => "staleness",
271            Self::Type => "type",
272            Self::Diagnostics => "diagnostics",
273        }
274    }
275}
276
277/// Graph workspace state.
278#[derive(Debug)]
279pub struct GraphState {
280    /// The layout engine (positions persist across snapshots).
281    pub layout: LayoutEngine,
282    /// The layout algorithm.
283    pub mode: LayoutMode,
284    /// Pan offset in layout space.
285    pub pan: (f64, f64),
286    /// Zoom factor (larger = closer).
287    pub zoom: f64,
288    /// Selected node key.
289    pub selected: Option<String>,
290    /// Egocentric focus: center node key and hop count (cycles 1→2→3→off).
291    pub focus: Option<(String, usize)>,
292    /// Coloring dimension.
293    pub color_by: ColorBy,
294    /// Whether external source nodes show.
295    pub show_sources: bool,
296    /// Whether broken-target nodes show.
297    pub show_broken: bool,
298    /// Whether derivation edges show.
299    pub show_derivations: bool,
300    /// The applied fuzzy node filter.
301    pub filter: String,
302    /// The filter input line, when open.
303    pub filter_input: Option<String>,
304}
305
306impl Default for GraphState {
307    fn default() -> Self {
308        Self {
309            layout: LayoutEngine::default(),
310            mode: LayoutMode::default(),
311            pan: (0.0, 0.0),
312            zoom: 1.0,
313            selected: None,
314            focus: None,
315            color_by: ColorBy::default(),
316            show_sources: false,
317            show_broken: true,
318            show_derivations: true,
319            filter: String::new(),
320            filter_input: None,
321        }
322    }
323}
324
325/// Which mission-control panel has focus.
326#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
327pub enum TrustPanel {
328    /// The attention queue.
329    #[default]
330    Queue,
331    /// The trust-tier distribution bars.
332    TrustBars,
333    /// The lifecycle distribution bars.
334    LifecycleBars,
335    /// The freshness distribution bars.
336    FreshnessBars,
337    /// The activity sparkline.
338    Activity,
339}
340
341impl TrustPanel {
342    /// The next panel in the focus cycle.
343    #[must_use]
344    pub const fn next(self) -> Self {
345        match self {
346            Self::Queue => Self::TrustBars,
347            Self::TrustBars => Self::LifecycleBars,
348            Self::LifecycleBars => Self::FreshnessBars,
349            Self::FreshnessBars => Self::Activity,
350            Self::Activity => Self::Queue,
351        }
352    }
353}
354
355/// A cohort filter applied to the attention queue.
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357pub enum Cohort {
358    /// Filter to a trust tier.
359    Tier(TrustTier),
360    /// Filter to a status bucket (index into `status_counts`).
361    Status(usize),
362    /// Freshness bucket: 0 fresh, 1 stale, 2 stale-soon.
363    Fresh(usize),
364}
365
366/// Attention-queue sort order.
367#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
368pub enum QueueSort {
369    /// By risk score (default).
370    #[default]
371    Risk,
372    /// By days overdue.
373    Staleness,
374    /// By blast radius (backlinks).
375    Backlinks,
376}
377
378impl QueueSort {
379    /// The next sort in the cycle.
380    #[must_use]
381    pub const fn next(self) -> Self {
382        match self {
383            Self::Risk => Self::Staleness,
384            Self::Staleness => Self::Backlinks,
385            Self::Backlinks => Self::Risk,
386        }
387    }
388
389    /// Display name.
390    #[must_use]
391    pub const fn name(self) -> &'static str {
392        match self {
393            Self::Risk => "risk",
394            Self::Staleness => "staleness",
395            Self::Backlinks => "backlinks",
396        }
397    }
398}
399
400/// Mission-control workspace state.
401#[derive(Debug, Default)]
402pub struct TrustState {
403    /// Focused panel.
404    pub panel: TrustPanel,
405    /// Selected queue row.
406    pub queue_sel: usize,
407    /// Selected bar row within the focused bar panel.
408    pub bar_sel: usize,
409    /// Active cohort filter.
410    pub cohort: Option<Cohort>,
411    /// Queue sort.
412    pub sort: QueueSort,
413}
414
415/// Which computations pane has focus.
416#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
417pub enum CompPane {
418    /// The contract list.
419    #[default]
420    List,
421    /// The playground form.
422    Form,
423}
424
425/// Computations workspace state.
426#[derive(Debug, Default)]
427pub struct ComputationsState {
428    /// Focused pane.
429    pub pane: CompPane,
430    /// Selected contract index.
431    pub selected: usize,
432    /// Focused form field.
433    pub field: usize,
434    /// Parameter values, keyed by `concept-id\u{0}param-name`.
435    pub values: std::collections::HashMap<String, String>,
436}
437
438/// The palette's mode, derived from its input prefix.
439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
440pub enum PaletteMode {
441    /// Omnisearch over concepts.
442    Search,
443    /// Command mode (`>` prefix).
444    Command,
445}
446
447/// Palette overlay state.
448#[derive(Debug, Default)]
449pub struct PaletteState {
450    /// The typed query.
451    pub input: String,
452    /// Selected result row.
453    pub sel: usize,
454}
455
456impl PaletteState {
457    /// The active mode.
458    #[must_use]
459    pub fn mode(&self) -> PaletteMode {
460        if self.input.trim_start().starts_with('>') {
461            PaletteMode::Command
462        } else {
463            PaletteMode::Search
464        }
465    }
466}
467
468/// Diagnostics overlay filter.
469#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
470pub enum DiagFilter {
471    /// Everything.
472    #[default]
473    All,
474    /// Validation errors only.
475    Errors,
476    /// Validation warnings only.
477    Warnings,
478    /// Lint findings only.
479    Lint,
480}
481
482/// Diagnostics overlay state.
483#[derive(Debug, Default)]
484pub struct DiagnosticsState {
485    /// Active filter.
486    pub filter: DiagFilter,
487    /// Selected row.
488    pub sel: usize,
489}
490
491/// What a confirm overlay confirms.
492#[derive(Clone, Debug)]
493pub enum ConfirmAction {
494    /// Stamp a verification.
495    Verify(ConceptId),
496}
497
498/// Confirm overlay state.
499#[derive(Debug)]
500pub struct ConfirmState {
501    /// Title line.
502    pub title: String,
503    /// Body lines.
504    pub body: Vec<String>,
505    /// What Enter does.
506    pub action: ConfirmAction,
507}
508
509/// Date-picker overlay state (stale extension / today pinning).
510#[derive(Debug)]
511pub struct DatePickerState {
512    /// The concept whose `stale_after` is written; `None` pins `--today`.
513    pub id: Option<ConceptId>,
514    /// The typed date.
515    pub input: String,
516}
517
518/// The refactor verb a modal drives.
519#[derive(Clone, Copy, Debug, PartialEq, Eq)]
520pub enum VerbKind {
521    /// Move / rename.
522    Move,
523    /// Remove.
524    Remove,
525    /// Merge into another concept.
526    Merge,
527    /// Split a section out.
528    Split,
529    /// Rename a section heading.
530    RenameSection,
531}
532
533/// The remove modal's decision, mapped 1:1 to [`okf_core::RemoveOptions`].
534#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
535pub enum RemoveChoice {
536    /// No inbound links (or not decided yet).
537    #[default]
538    Plain,
539    /// Redirect inbound links.
540    Redirect,
541    /// Unlink inbound links to plain text.
542    Unlink,
543    /// Force: leave broken links.
544    Force,
545}
546
547/// Refactor modal state: intent → (decision) → preview.
548#[derive(Debug)]
549pub struct RefactorState {
550    /// The verb.
551    pub verb: VerbKind,
552    /// The subject (studio already knows what you're looking at).
553    pub subject: ConceptId,
554    /// The section, for the heading verbs.
555    pub section: Option<String>,
556    /// Main input: target id or new heading text.
557    pub input: String,
558    /// Secondary input: split title / remove redirect target.
559    pub extra: String,
560    /// Which input is active (0 main, 1 extra).
561    pub field: usize,
562    /// The remove decision, when the dry-run surfaced inbound links.
563    pub choice: RemoveChoice,
564    /// Whether the move target may overwrite (set after the engine asks).
565    pub force: bool,
566    /// The latest dry-run result.
567    pub preview: Option<Result<PreviewReport, RefactorError>>,
568    /// The latest request id sent.
569    pub request: u64,
570    /// Debounce flag: a preview should be sent on the next tick.
571    pub needs_preview: bool,
572}
573
574/// New-concept form state.
575#[derive(Debug)]
576pub struct NewConceptState {
577    /// Field values: path, type, title.
578    pub fields: [String; 3],
579    /// Focused field.
580    pub field: usize,
581}
582
583/// Fix preview overlay state.
584#[derive(Debug)]
585pub struct FixPreviewState {
586    /// The dry-run report (contents carry before/after text).
587    pub report: Box<okf_core::BundleFixReport>,
588    /// Selected changed file.
589    pub file_sel: usize,
590    /// Diff scroll.
591    pub scroll: usize,
592}
593
594/// Outline overlay state.
595#[derive(Debug)]
596pub struct OutlineState {
597    /// The concept whose outline shows.
598    pub id: ConceptId,
599    /// Selected heading row.
600    pub sel: usize,
601    /// When set, the selected heading is being renamed with this input.
602    pub rename: Option<String>,
603}
604
605/// An open overlay. Overlays stack; `Esc` closes the topmost.
606#[derive(Debug)]
607pub enum Overlay {
608    /// Omnisearch / command palette.
609    Palette(PaletteState),
610    /// Diagnostics & fix engine.
611    Diagnostics(DiagnosticsState),
612    /// Help cheatsheet (scroll offset).
613    Help(usize),
614    /// Confirmation prompt.
615    Confirm(ConfirmState),
616    /// Date picker.
617    DatePicker(DatePickerState),
618    /// Refactor modal.
619    Refactor(Box<RefactorState>),
620    /// New-concept form.
621    NewConcept(NewConceptState),
622    /// Fix preview diff.
623    FixPreview(FixPreviewState),
624    /// Full merged log view (scroll offset).
625    LogView(usize),
626    /// Outline jump list.
627    Outline(OutlineState),
628}
629
630/// A transient status message.
631#[derive(Clone, Debug)]
632pub struct Toast {
633    /// The text.
634    pub text: String,
635    /// Remaining ticks.
636    pub ttl: u8,
637    /// Whether it reports an error.
638    pub error: bool,
639}
640
641/// What background work is in flight, for the status-bar spinner.
642#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
643pub enum LoadPhase {
644    /// Nothing in flight.
645    #[default]
646    Idle,
647    /// A snapshot rebuild is in flight.
648    Reloading,
649    /// A write operation is in flight.
650    Applying,
651}
652
653/// The whole application state.
654#[allow(clippy::struct_excessive_bools)]
655pub struct App {
656    /// The immutable world model (absent until the first load lands).
657    pub snapshot: Option<Arc<Snapshot>>,
658    /// The active workspace.
659    pub tab: Tab,
660    /// Explorer state.
661    pub explorer: ExplorerState,
662    /// Graph state.
663    pub graph: GraphState,
664    /// Mission-control state.
665    pub trust: TrustState,
666    /// Computations state.
667    pub computations: ComputationsState,
668    /// The overlay stack.
669    pub overlays: Vec<Overlay>,
670    /// Transient status messages.
671    pub toasts: VecDeque<Toast>,
672    /// Background work indicator.
673    pub loading: LoadPhase,
674    /// The theme.
675    pub theme: Theme,
676    /// Author identity for stamps and log entries.
677    pub author: String,
678    /// Commands awaiting dispatch to the worker (drained by the main loop).
679    pub pending_commands: Vec<Command>,
680    /// A file to open in `$EDITOR` (handled by the main loop).
681    pub editor_request: Option<PathBuf>,
682    /// Text to copy to the clipboard via OSC 52 (handled by the main loop).
683    pub copy_request: Option<String>,
684    /// Set when the user quits.
685    pub should_quit: bool,
686    /// Tick counter.
687    pub tick: u64,
688    /// A reload arrived while one was in flight.
689    pub reload_queued: bool,
690    /// Monotonic preview-request counter.
691    pub next_request: u64,
692    /// The bundle directory's display name.
693    pub root_name: String,
694    /// A fatal load error, shown instead of a workspace.
695    pub load_error: Option<String>,
696    /// Whether the watcher is disabled.
697    pub no_watch: bool,
698    /// The first snapshot has not yet been inspected (focus-mode default).
699    first_snapshot: bool,
700}
701
702impl App {
703    /// Creates the initial state from the launch options.
704    #[must_use]
705    pub fn new(options: &StudioOptions) -> Self {
706        let root_name = options
707            .root
708            .canonicalize()
709            .unwrap_or_else(|_| options.root.clone())
710            .file_name()
711            .map_or_else(
712                || options.root.display().to_string(),
713                |n| n.to_string_lossy().into_owned(),
714            );
715        Self {
716            snapshot: None,
717            tab: options.initial_tab.unwrap_or(Tab::Explorer),
718            explorer: ExplorerState::default(),
719            graph: GraphState::default(),
720            trust: TrustState::default(),
721            computations: ComputationsState::default(),
722            overlays: Vec::new(),
723            toasts: VecDeque::new(),
724            loading: LoadPhase::Reloading,
725            theme: Theme::from_env(),
726            author: options
727                .author
728                .clone()
729                .unwrap_or_else(okf_core::default_author),
730            pending_commands: vec![Command::Reload],
731            editor_request: None,
732            copy_request: None,
733            should_quit: false,
734            tick: 0,
735            reload_queued: false,
736            next_request: 0,
737            root_name,
738            load_error: None,
739            no_watch: options.no_watch,
740            first_snapshot: true,
741        }
742    }
743
744    /// Pushes a toast.
745    pub fn toast(&mut self, text: impl Into<String>, error: bool) {
746        self.toasts.push_back(Toast {
747            text: text.into(),
748            ttl: TOAST_TTL,
749            error,
750        });
751        while self.toasts.len() > 3 {
752            self.toasts.pop_front();
753        }
754    }
755
756    fn send(&mut self, command: Command) {
757        self.pending_commands.push(command);
758    }
759
760    /// The concept the current selection denotes, if any — the subject every
761    /// contextual verb acts on.
762    #[must_use]
763    pub fn selected_concept(&self) -> Option<ConceptId> {
764        let snapshot = self.snapshot.as_ref()?;
765        match self.tab {
766            Tab::Explorer => match &self.explorer.selected {
767                Some(TreeSel::Concept(id)) => Some(id.clone()),
768                _ => None,
769            },
770            Tab::Graph => {
771                let key = self.graph.selected.as_ref()?;
772                snapshot
773                    .graph
774                    .nodes
775                    .iter()
776                    .find(|n| &n.key == key)
777                    .and_then(|n| n.id.clone())
778            }
779            Tab::Trust => self
780                .filtered_queue()
781                .get(self.trust.queue_sel)
782                .map(|item| item.id.clone()),
783            Tab::Computations => snapshot
784                .contracts
785                .get(self.computations.selected)
786                .map(|c| c.id.clone()),
787        }
788    }
789
790    /// The on-disk file the current selection denotes (for `$EDITOR`).
791    #[must_use]
792    pub fn selected_path(&self) -> Option<PathBuf> {
793        let snapshot = self.snapshot.as_ref()?;
794        if self.tab == Tab::Explorer {
795            match &self.explorer.selected {
796                Some(TreeSel::File(path)) => return Some(path.clone()),
797                Some(TreeSel::Dir(_)) | None => return None,
798                Some(TreeSel::Concept(_)) => {}
799            }
800        }
801        self.selected_concept()
802            .map(|id| id.to_path(snapshot.bundle.root()))
803    }
804
805    /// The visible tree rows given the current collapse state.
806    #[must_use]
807    pub fn tree_rows(&self) -> Vec<TreeRow> {
808        let mut rows = Vec::new();
809        if let Some(snapshot) = &self.snapshot {
810            flatten_tree(&snapshot.tree.roots, 0, &self.explorer.collapsed, &mut rows);
811        }
812        rows
813    }
814
815    /// The attention queue after the cohort filter and sort.
816    #[must_use]
817    pub fn filtered_queue(&self) -> Vec<crate::snapshot::AttentionItem> {
818        let Some(snapshot) = &self.snapshot else {
819            return Vec::new();
820        };
821        let mut items: Vec<crate::snapshot::AttentionItem> = snapshot
822            .attention
823            .iter()
824            .filter(|item| {
825                let Some(meta) = snapshot.meta(&item.id) else {
826                    return false;
827                };
828                match self.trust.cohort {
829                    None => true,
830                    Some(Cohort::Tier(tier)) => meta.tier == tier,
831                    Some(Cohort::Status(ix)) => {
832                        ix == match meta.status {
833                            okf_core::Status::Draft => 0,
834                            okf_core::Status::Stable => 1,
835                            okf_core::Status::Deprecated => 2,
836                            okf_core::Status::Other(_) => 3,
837                        }
838                    }
839                    Some(Cohort::Fresh(ix)) => match ix {
840                        1 => meta.stale,
841                        2 => meta.stale_in_days.is_some(),
842                        _ => !meta.stale && meta.stale_in_days.is_none(),
843                    },
844                }
845            })
846            .cloned()
847            .collect();
848        match self.trust.sort {
849            QueueSort::Risk => {}
850            QueueSort::Staleness => items.sort_by_key(|item| {
851                std::cmp::Reverse(
852                    self.snapshot
853                        .as_ref()
854                        .and_then(|s| s.meta(&item.id))
855                        .and_then(|m| m.overdue_days)
856                        .unwrap_or(i64::MIN),
857                )
858            }),
859            QueueSort::Backlinks => items.sort_by_key(|item| {
860                std::cmp::Reverse(
861                    self.snapshot
862                        .as_ref()
863                        .and_then(|s| s.meta(&item.id))
864                        .map_or(0, |m| m.in_degree),
865                )
866            }),
867        }
868        items
869    }
870
871    /// Applies one message. Never blocks, never touches disk.
872    pub fn update(&mut self, msg: Msg) {
873        match msg {
874            Msg::Key(key) => self.on_key(key),
875            Msg::Mouse(_) | Msg::Resize => {}
876            Msg::Tick => self.on_tick(),
877            Msg::FilesChanged => {
878                if self.loading == LoadPhase::Idle {
879                    self.loading = LoadPhase::Reloading;
880                    self.send(Command::Reload);
881                } else {
882                    self.reload_queued = true;
883                }
884            }
885            Msg::SnapshotReady(snapshot) => self.on_snapshot(snapshot),
886            Msg::SnapshotFailed(e) => {
887                self.load_error = Some(e);
888                self.loading = LoadPhase::Idle;
889            }
890            Msg::PreviewReady(request, result) => {
891                if let Some(Overlay::Refactor(state)) = self.overlays.last_mut()
892                    && state.request == request
893                {
894                    if let Err(RefactorError::HasInboundLinks { .. }) = &result
895                        && state.verb == VerbKind::Remove
896                        && state.choice == RemoveChoice::Plain
897                    {
898                        // The decision stage: the modal now asks exactly the
899                        // question the engine raised.
900                    }
901                    state.preview = Some(result);
902                }
903            }
904            Msg::Applied(result) => {
905                self.loading = LoadPhase::Idle;
906                match result {
907                    Ok(text) => self.toast(text, false),
908                    Err(e) => self.toast(format!("✗ {e}"), true),
909                }
910            }
911            Msg::FixReportReady(report) => {
912                if report.is_empty() {
913                    self.toast("✔ nothing to fix", false);
914                } else {
915                    self.overlays.push(Overlay::FixPreview(FixPreviewState {
916                        report,
917                        file_sel: 0,
918                        scroll: 0,
919                    }));
920                }
921            }
922            Msg::Error(e) => self.toast(format!("✗ {e}"), true),
923        }
924    }
925
926    fn on_tick(&mut self) {
927        self.tick += 1;
928        for toast in &mut self.toasts {
929            toast.ttl = toast.ttl.saturating_sub(1);
930        }
931        self.toasts.retain(|t| t.ttl > 0);
932
933        // Debounced refactor preview.
934        let mut to_send: Option<(u64, RefactorOp)> = None;
935        if let Some(Overlay::Refactor(state)) = self.overlays.last_mut()
936            && state.needs_preview
937        {
938            state.needs_preview = false;
939            if let Some(op) = build_op(state) {
940                self.next_request += 1;
941                state.request = self.next_request;
942                to_send = Some((state.request, op));
943            } else {
944                state.preview = None;
945            }
946        }
947        if let Some((request, op)) = to_send {
948            self.send(Command::Preview { request, op });
949        }
950
951        // Incremental layout stepping while the graph is visible.
952        if self.tab == Tab::Graph
953            && self.graph.mode == LayoutMode::Force
954            && let Some(snapshot) = self.snapshot.clone()
955        {
956            let included = self.graph_included(&snapshot);
957            self.graph.layout.step(&snapshot.graph, &included, 12);
958        }
959    }
960
961    /// The nodes currently visible in the graph, honoring toggles, focus
962    /// mode, and the fuzzy filter (filtered nodes stay visible but dimmed —
963    /// they remain in layout).
964    #[must_use]
965    pub fn graph_included(&self, snapshot: &Snapshot) -> Vec<bool> {
966        let model = &snapshot.graph;
967        let mut included: Vec<bool> = model
968            .nodes
969            .iter()
970            .map(|node| match node.kind {
971                crate::graph::NodeKind::Source => self.graph.show_sources,
972                crate::graph::NodeKind::Phantom => self.graph.show_broken,
973                _ => true,
974            })
975            .collect();
976        if let Some((center_key, k)) = &self.graph.focus
977            && let Some(center) = model.nodes.iter().position(|n| &n.key == center_key)
978        {
979            let hood = model.neighborhood(center, *k);
980            for (i, inc) in included.iter_mut().enumerate() {
981                *inc = *inc && hood[i];
982            }
983        }
984        included
985    }
986
987    fn on_snapshot(&mut self, snapshot: Arc<Snapshot>) {
988        self.load_error = None;
989        self.loading = LoadPhase::Idle;
990
991        // Selection survival: fall back when the selected thing vanished.
992        if let Some(TreeSel::Concept(id)) = &self.explorer.selected
993            && !snapshot.bundle.contains(id)
994        {
995            self.explorer.selected = None;
996        }
997        if self.explorer.selected.is_none() {
998            self.explorer.selected = snapshot
999                .bundle
1000                .concepts()
1001                .first()
1002                .map(|c| TreeSel::Concept(c.id.clone()));
1003        }
1004        if let Some(key) = &self.graph.selected
1005            && !snapshot.graph.nodes.iter().any(|n| &n.key == key)
1006        {
1007            self.graph.selected = None;
1008        }
1009        self.computations.selected = self
1010            .computations
1011            .selected
1012            .min(snapshot.contracts.len().saturating_sub(1));
1013
1014        // Layout: carry positions over; seed new nodes; keep radial layouts.
1015        self.graph.layout.seed(&snapshot.graph);
1016        if self.graph.mode == LayoutMode::Radial {
1017            self.graph.layout.radial(&snapshot.graph);
1018        }
1019
1020        // Egocentric default for large bundles: the full hairball is not a
1021        // useful first picture past ~500 nodes.
1022        if self.first_snapshot {
1023            self.first_snapshot = false;
1024            if snapshot.graph.nodes.len() > 500 {
1025                let center = snapshot.bundle.concepts().first().map(|c| c.id.to_string());
1026                if let Some(center) = center {
1027                    self.graph.focus = Some((center, 1));
1028                }
1029            }
1030        }
1031
1032        self.snapshot = Some(snapshot);
1033        if self.reload_queued {
1034            self.reload_queued = false;
1035            self.loading = LoadPhase::Reloading;
1036            self.send(Command::Reload);
1037        }
1038    }
1039}
1040
1041fn flatten_tree(
1042    nodes: &[TreeNode],
1043    depth: usize,
1044    collapsed: &HashSet<String>,
1045    out: &mut Vec<TreeRow>,
1046) {
1047    for node in nodes {
1048        match node {
1049            TreeNode::Dir {
1050                name,
1051                path,
1052                concept_count,
1053                children,
1054            } => {
1055                let is_collapsed = collapsed.contains(path);
1056                out.push(TreeRow {
1057                    depth,
1058                    sel: TreeSel::Dir(path.clone()),
1059                    name: name.clone(),
1060                    kind: TreeRowKind::Dir {
1061                        count: *concept_count,
1062                        collapsed: is_collapsed,
1063                    },
1064                });
1065                if !is_collapsed {
1066                    flatten_tree(children, depth + 1, collapsed, out);
1067                }
1068            }
1069            TreeNode::Leaf { name, kind } => {
1070                let sel = match kind {
1071                    LeafKind::Concept(id) => TreeSel::Concept(id.clone()),
1072                    LeafKind::Index(p) | LeafKind::Log(p) | LeafKind::Broken(p) => {
1073                        TreeSel::File(p.clone())
1074                    }
1075                };
1076                out.push(TreeRow {
1077                    depth,
1078                    sel,
1079                    name: name.clone(),
1080                    kind: TreeRowKind::Leaf(kind.clone()),
1081                });
1082            }
1083        }
1084    }
1085}
1086
1087/// Builds the refactor operation a modal currently describes, or `None` when
1088/// its inputs are not yet valid.
1089fn build_op(state: &RefactorState) -> Option<RefactorOp> {
1090    match state.verb {
1091        VerbKind::Move => {
1092            let target = ConceptId::parse(state.input.trim()).ok()?;
1093            Some(RefactorOp::Move {
1094                source: state.subject.clone(),
1095                target,
1096                force: state.force,
1097            })
1098        }
1099        VerbKind::Remove => {
1100            let redirect_to = if state.choice == RemoveChoice::Redirect {
1101                Some(ConceptId::parse(state.extra.trim()).ok()?)
1102            } else {
1103                None
1104            };
1105            Some(RefactorOp::Remove {
1106                target: state.subject.clone(),
1107                redirect_to,
1108                unlink: state.choice == RemoveChoice::Unlink,
1109                force: state.choice == RemoveChoice::Force,
1110            })
1111        }
1112        VerbKind::Merge => {
1113            let target = ConceptId::parse(state.input.trim()).ok()?;
1114            if target == state.subject {
1115                return None;
1116            }
1117            Some(RefactorOp::Merge {
1118                source: state.subject.clone(),
1119                target,
1120            })
1121        }
1122        VerbKind::Split => {
1123            let target = ConceptId::parse(state.input.trim()).ok()?;
1124            let title = if state.extra.trim().is_empty() {
1125                None
1126            } else {
1127                Some(state.extra.trim().to_string())
1128            };
1129            Some(RefactorOp::Split {
1130                source: state.subject.clone(),
1131                target,
1132                section: state.section.clone()?,
1133                title,
1134                force: state.force,
1135            })
1136        }
1137        VerbKind::RenameSection => {
1138            let new = state.input.trim();
1139            if new.is_empty() {
1140                return None;
1141            }
1142            Some(RefactorOp::RenameSection {
1143                concept: state.subject.clone(),
1144                old: state.section.clone()?,
1145                new: new.to_string(),
1146            })
1147        }
1148    }
1149}
1150
1151// ---------------------------------------------------------------------------
1152// Key dispatch.
1153// ---------------------------------------------------------------------------
1154
1155impl App {
1156    fn on_key(&mut self, key: KeyEvent) {
1157        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1158            self.should_quit = true;
1159            return;
1160        }
1161        if self.overlays.is_empty() {
1162            // Graph filter input captures keys before the keymap.
1163            if self.tab == Tab::Graph && self.graph.filter_input.is_some() {
1164                self.graph_filter_key(key);
1165                return;
1166            }
1167            // Playground form typing captures printable keys.
1168            if self.tab == Tab::Computations
1169                && self.computations.pane == CompPane::Form
1170                && self.playground_form_key(&key)
1171            {
1172                return;
1173            }
1174            let mut contexts: Vec<Context> = vec![match (self.tab, self.explorer.pane) {
1175                (Tab::Explorer, ExplorerPane::Tree) => Context::Tree,
1176                (Tab::Explorer, ExplorerPane::Viewer) => Context::Viewer,
1177                (Tab::Graph, _) => Context::Graph,
1178                (Tab::Trust, _) => Context::Trust,
1179                (Tab::Computations, _) => Context::Computations,
1180            }];
1181            if self.selected_concept().is_some() {
1182                contexts.push(Context::Concept);
1183            }
1184            contexts.push(Context::Global);
1185            if let Some(action) = resolve(&contexts, &key) {
1186                self.run_action(action);
1187            }
1188            return;
1189        }
1190        self.overlay_key(key);
1191    }
1192
1193    /// Executes one keymap action (also the palette command executor).
1194    #[allow(clippy::too_many_lines)]
1195    pub fn run_action(&mut self, action: Action) {
1196        match action {
1197            Action::SwitchTab(ix) => {
1198                self.tab = match ix {
1199                    1 => Tab::Graph,
1200                    2 => Tab::Trust,
1201                    3 => Tab::Computations,
1202                    _ => Tab::Explorer,
1203                };
1204            }
1205            Action::OpenPalette => self
1206                .overlays
1207                .push(Overlay::Palette(PaletteState::default())),
1208            Action::OpenDiagnostics => self
1209                .overlays
1210                .push(Overlay::Diagnostics(DiagnosticsState::default())),
1211            Action::OpenHelp => self.overlays.push(Overlay::Help(0)),
1212            Action::Reload => {
1213                self.loading = LoadPhase::Reloading;
1214                self.send(Command::Reload);
1215            }
1216            Action::OpenEditor => {
1217                if let Some(path) = self.selected_path() {
1218                    self.editor_request = Some(path);
1219                } else {
1220                    self.toast("no file selected", true);
1221                }
1222            }
1223            Action::Quit => self.should_quit = true,
1224            Action::Verify => {
1225                if let Some(id) = self.selected_concept() {
1226                    self.overlays.push(Overlay::Confirm(ConfirmState {
1227                        title: format!("Stamp verification on {id}"),
1228                        body: vec![
1229                            format!("by {}", self.author),
1230                            "Appends a { by, at } event to `verified` and logs it.".to_string(),
1231                        ],
1232                        action: ConfirmAction::Verify(id),
1233                    }));
1234                }
1235            }
1236            Action::ExtendStale => {
1237                if let Some(id) = self.selected_concept() {
1238                    let current = self
1239                        .snapshot
1240                        .as_ref()
1241                        .and_then(|s| s.meta(&id))
1242                        .and_then(|m| m.stale_after.as_ref())
1243                        .and_then(|f| f.datetime.map(|dt| dt.date));
1244                    let today = self.snapshot.as_ref().map_or(
1245                        Date {
1246                            year: 2026,
1247                            month: 1,
1248                            day: 1,
1249                        },
1250                        |s| s.today,
1251                    );
1252                    let default = Date::from_days_since_epoch(
1253                        current
1254                            .unwrap_or(today)
1255                            .days_since_epoch()
1256                            .max(today.days_since_epoch())
1257                            + 90,
1258                    );
1259                    self.overlays.push(Overlay::DatePicker(DatePickerState {
1260                        id: Some(id),
1261                        input: default.to_string(),
1262                    }));
1263                }
1264            }
1265            Action::MoveOrRename => {
1266                if let Some(id) = self.selected_concept() {
1267                    self.open_refactor(VerbKind::Move, id, None);
1268                }
1269            }
1270            Action::Remove => {
1271                if let Some(id) = self.selected_concept() {
1272                    self.open_refactor(VerbKind::Remove, id, None);
1273                }
1274            }
1275            Action::Merge => {
1276                if let Some(id) = self.selected_concept() {
1277                    self.open_refactor(VerbKind::Merge, id, None);
1278                }
1279            }
1280            Action::SplitSection | Action::Outline => self.open_outline(),
1281            Action::NewConcept => {
1282                let dir = match &self.explorer.selected {
1283                    Some(TreeSel::Dir(path)) => path.clone(),
1284                    Some(TreeSel::Concept(id)) => {
1285                        id.parent().map(|p| p.to_string()).unwrap_or_default()
1286                    }
1287                    _ => String::new(),
1288                };
1289                let path = if dir.is_empty() {
1290                    String::new()
1291                } else {
1292                    format!("{dir}/")
1293                };
1294                self.overlays.push(Overlay::NewConcept(NewConceptState {
1295                    fields: [path, "Concept".to_string(), String::new()],
1296                    field: 0,
1297                }));
1298            }
1299            Action::CycleInspector => {
1300                self.explorer.inspector_tab = (self.explorer.inspector_tab + 1) % 4;
1301            }
1302            Action::ToggleRawYaml => self.explorer.raw_yaml = !self.explorer.raw_yaml,
1303            Action::NextLink | Action::PrevLink => self.cycle_link(action == Action::NextLink),
1304            Action::Back => self.nav_back(),
1305            Action::Forward => self.nav_forward(),
1306            Action::ZoomIn => self.graph.zoom = (self.graph.zoom * 1.25).min(40.0),
1307            Action::ZoomOut => self.graph.zoom = (self.graph.zoom / 1.25).max(0.05),
1308            Action::NextNode | Action::PrevNode => self.cycle_node(action == Action::NextNode),
1309            Action::FocusMode => self.cycle_focus_mode(),
1310            Action::CycleColor => self.graph.color_by = self.graph.color_by.next(),
1311            Action::ToggleSources => self.graph.show_sources = !self.graph.show_sources,
1312            Action::ToggleBroken => self.graph.show_broken = !self.graph.show_broken,
1313            Action::ToggleDerivations => {
1314                self.graph.show_derivations = !self.graph.show_derivations;
1315            }
1316            Action::PauseLayout => self.graph.layout.toggle_running(),
1317            Action::GraphFilter => {
1318                self.graph.filter_input = Some(self.graph.filter.clone());
1319            }
1320            Action::CycleLayout => {
1321                self.graph.mode = self.graph.mode.next();
1322                if let Some(snapshot) = &self.snapshot {
1323                    match self.graph.mode {
1324                        LayoutMode::Radial => self.graph.layout.radial(&snapshot.graph),
1325                        LayoutMode::Force => {
1326                            self.graph.layout.running = true;
1327                            self.graph.layout.toggle_running();
1328                            self.graph.layout.toggle_running();
1329                        }
1330                    }
1331                }
1332            }
1333            Action::CyclePanel => match self.tab {
1334                Tab::Trust => {
1335                    self.trust.panel = self.trust.panel.next();
1336                    self.trust.bar_sel = 0;
1337                }
1338                Tab::Computations => {
1339                    self.computations.pane = match self.computations.pane {
1340                        CompPane::List => CompPane::Form,
1341                        CompPane::Form => CompPane::List,
1342                    };
1343                }
1344                _ => {}
1345            },
1346            Action::CycleSort => self.trust.sort = self.trust.sort.next(),
1347            Action::CopySketch => self.copy_sketch(),
1348            Action::FixAll => self.send(Command::PreviewFix),
1349            Action::PinToday => {
1350                let today = self
1351                    .snapshot
1352                    .as_ref()
1353                    .map_or_else(String::new, |s| s.today.to_string());
1354                self.overlays.push(Overlay::DatePicker(DatePickerState {
1355                    id: None,
1356                    input: today,
1357                }));
1358            }
1359            Action::OpenLog => self.overlays.push(Overlay::LogView(0)),
1360            Action::Up
1361            | Action::Down
1362            | Action::Left
1363            | Action::Right
1364            | Action::PageUp
1365            | Action::PageDown
1366            | Action::HalfUp
1367            | Action::HalfDown
1368            | Action::Home
1369            | Action::End
1370            | Action::Activate => self.navigate(action),
1371        }
1372    }
1373
1374    fn open_refactor(&mut self, verb: VerbKind, subject: ConceptId, section: Option<String>) {
1375        let input = match verb {
1376            VerbKind::Move => subject.to_string(),
1377            VerbKind::RenameSection => section.clone().unwrap_or_default(),
1378            VerbKind::Split => section
1379                .as_deref()
1380                .map(|s| {
1381                    let slug = okf_core::heading_slug(s);
1382                    subject
1383                        .parent()
1384                        .map_or_else(|| slug.clone(), |p| format!("{p}/{slug}"))
1385                })
1386                .unwrap_or_default(),
1387            _ => String::new(),
1388        };
1389        let extra = match verb {
1390            VerbKind::Split => section.clone().unwrap_or_default(),
1391            _ => String::new(),
1392        };
1393        let mut state = RefactorState {
1394            verb,
1395            subject,
1396            section,
1397            input,
1398            extra,
1399            field: 0,
1400            choice: RemoveChoice::Plain,
1401            force: false,
1402            preview: None,
1403            request: 0,
1404            needs_preview: true,
1405        };
1406        // Remove needs no input: dry-run immediately.
1407        if verb == VerbKind::Remove {
1408            state.needs_preview = true;
1409        }
1410        self.overlays.push(Overlay::Refactor(Box::new(state)));
1411    }
1412
1413    fn open_outline(&mut self) {
1414        if self.tab != Tab::Explorer {
1415            return;
1416        }
1417        if let Some(TreeSel::Concept(id)) = &self.explorer.selected {
1418            self.overlays.push(Overlay::Outline(OutlineState {
1419                id: id.clone(),
1420                sel: 0,
1421                rename: None,
1422            }));
1423        }
1424    }
1425
1426    fn copy_sketch(&mut self) {
1427        let Some(snapshot) = &self.snapshot else {
1428            return;
1429        };
1430        let Some(info) = snapshot.contracts.get(self.computations.selected) else {
1431            return;
1432        };
1433        let mut args: Vec<String> = Vec::new();
1434        for parameter in &info.contract.parameters {
1435            let name = parameter.name.clone().unwrap_or_default();
1436            let key = format!("{}\u{0}{}", info.id, name);
1437            let value = self
1438                .computations
1439                .values
1440                .get(&key)
1441                .cloned()
1442                .unwrap_or_default();
1443            if !value.is_empty() || parameter.is_required() {
1444                args.push(format!("{name}={value}"));
1445            }
1446        }
1447        let sketch = format!("{}({})", info.id.name(), args.join(", "));
1448        self.copy_request = Some(sketch.clone());
1449        self.toast(format!("✔ copied: {sketch}"), false);
1450    }
1451
1452    fn graph_filter_key(&mut self, key: KeyEvent) {
1453        let Some(input) = self.graph.filter_input.as_mut() else {
1454            return;
1455        };
1456        match key.code {
1457            KeyCode::Esc => {
1458                self.graph.filter_input = None;
1459                self.graph.filter.clear();
1460            }
1461            KeyCode::Enter => {
1462                self.graph.filter = self.graph.filter_input.take().unwrap_or_default();
1463            }
1464            KeyCode::Backspace => {
1465                input.pop();
1466            }
1467            KeyCode::Char(c) => input.push(c),
1468            _ => {}
1469        }
1470    }
1471
1472    /// Handles printable input for the playground form. Returns `true` when
1473    /// the key was consumed.
1474    fn playground_form_key(&mut self, key: &KeyEvent) -> bool {
1475        let Some(snapshot) = &self.snapshot else {
1476            return false;
1477        };
1478        let Some(info) = snapshot.contracts.get(self.computations.selected) else {
1479            return false;
1480        };
1481        let params: Vec<String> = info
1482            .contract
1483            .parameters
1484            .iter()
1485            .map(|p| p.name.clone().unwrap_or_default())
1486            .collect();
1487        if params.is_empty() {
1488            return false;
1489        }
1490        let field = self.computations.field.min(params.len() - 1);
1491        let value_key = format!("{}\u{0}{}", info.id, params[field]);
1492        match key.code {
1493            KeyCode::Char(c)
1494                if !key.modifiers.contains(KeyModifiers::CONTROL)
1495                    && c != '?'
1496                    && c != '!'
1497                    && c != '/' =>
1498            {
1499                self.computations
1500                    .values
1501                    .entry(value_key)
1502                    .or_default()
1503                    .push(c);
1504                true
1505            }
1506            KeyCode::Backspace => {
1507                self.computations.values.entry(value_key).or_default().pop();
1508                true
1509            }
1510            KeyCode::Up => {
1511                self.computations.field = field.saturating_sub(1);
1512                true
1513            }
1514            KeyCode::Down => {
1515                self.computations.field = (field + 1).min(params.len() - 1);
1516                true
1517            }
1518            _ => false,
1519        }
1520    }
1521
1522    // -- Navigation -------------------------------------------------------
1523
1524    #[allow(clippy::too_many_lines)]
1525    fn navigate(&mut self, action: Action) {
1526        match self.tab {
1527            Tab::Explorer => match self.explorer.pane {
1528                ExplorerPane::Tree => self.tree_navigate(action),
1529                ExplorerPane::Viewer => self.viewer_navigate(action),
1530            },
1531            Tab::Graph => self.graph_navigate(action),
1532            Tab::Trust => self.trust_navigate(action),
1533            Tab::Computations => self.computations_navigate(action),
1534        }
1535    }
1536
1537    fn tree_navigate(&mut self, action: Action) {
1538        let rows = self.tree_rows();
1539        if rows.is_empty() {
1540            return;
1541        }
1542        let current = self
1543            .explorer
1544            .selected
1545            .as_ref()
1546            .and_then(|sel| rows.iter().position(|row| &row.sel == sel))
1547            .unwrap_or(0);
1548        let mut next = current;
1549        match action {
1550            Action::Up => next = current.saturating_sub(1),
1551            Action::Down => next = (current + 1).min(rows.len() - 1),
1552            Action::PageUp => next = current.saturating_sub(10),
1553            Action::PageDown => next = (current + 10).min(rows.len() - 1),
1554            Action::Home => next = 0,
1555            Action::End => next = rows.len() - 1,
1556            Action::Left => {
1557                match &rows[current].sel {
1558                    TreeSel::Dir(path) if !self.explorer.collapsed.contains(path) => {
1559                        self.explorer.collapsed.insert(path.clone());
1560                    }
1561                    sel => {
1562                        // Jump to the parent directory row.
1563                        let parent = match sel {
1564                            TreeSel::Concept(id) => id.parent().map(|p| p.to_string()),
1565                            TreeSel::Dir(path) => {
1566                                path.rsplit_once('/').map(|(parent, _)| parent.to_string())
1567                            }
1568                            TreeSel::File(_) => None,
1569                        };
1570                        if let Some(parent) = parent
1571                            && let Some(ix) = rows
1572                                .iter()
1573                                .position(|row| row.sel == TreeSel::Dir(parent.clone()))
1574                        {
1575                            next = ix;
1576                        }
1577                    }
1578                }
1579            }
1580            Action::Right => match &rows[current].sel {
1581                TreeSel::Dir(path) => {
1582                    self.explorer.collapsed.remove(path);
1583                }
1584                _ => self.explorer.pane = ExplorerPane::Viewer,
1585            },
1586            Action::Activate => match &rows[current].sel {
1587                TreeSel::Dir(path) => {
1588                    if self.explorer.collapsed.contains(path) {
1589                        self.explorer.collapsed.remove(path);
1590                    } else {
1591                        self.explorer.collapsed.insert(path.clone());
1592                    }
1593                }
1594                _ => self.explorer.pane = ExplorerPane::Viewer,
1595            },
1596            _ => {}
1597        }
1598        if next != current {
1599            self.select_row(&rows[next].sel);
1600        }
1601    }
1602
1603    fn select_row(&mut self, sel: &TreeSel) {
1604        self.explorer.selected = Some(sel.clone());
1605        self.explorer.scroll = 0;
1606        self.explorer.focused_link = None;
1607    }
1608
1609    fn viewer_navigate(&mut self, action: Action) {
1610        let max = self.explorer.max_scroll.get();
1611        let scroll = &mut self.explorer.scroll;
1612        match action {
1613            Action::Up => *scroll = scroll.saturating_sub(1),
1614            Action::Down => *scroll = (*scroll + 1).min(max),
1615            Action::PageUp => *scroll = scroll.saturating_sub(20),
1616            Action::PageDown => *scroll = (*scroll + 20).min(max),
1617            Action::HalfUp => *scroll = scroll.saturating_sub(10),
1618            Action::HalfDown => *scroll = (*scroll + 10).min(max),
1619            Action::Home => *scroll = 0,
1620            Action::End => *scroll = max,
1621            Action::Left => self.explorer.pane = ExplorerPane::Tree,
1622            Action::Activate => self.follow_focused_link(),
1623            _ => {}
1624        }
1625    }
1626
1627    fn cycle_link(&mut self, forward: bool) {
1628        if self.tab != Tab::Explorer {
1629            return;
1630        }
1631        self.explorer.pane = ExplorerPane::Viewer;
1632        // The number of links is only known to the renderer; the view stores
1633        // it alongside max_scroll. We advance optimistically and let the view
1634        // clamp via the cache key; link count lives in the render cache.
1635        let count = self.explorer_link_count();
1636        if count == 0 {
1637            self.explorer.focused_link = None;
1638            return;
1639        }
1640        let next = match self.explorer.focused_link {
1641            None if forward => 0,
1642            None => count - 1,
1643            Some(i) if forward => (i + 1) % count,
1644            Some(i) => (i + count - 1) % count,
1645        };
1646        self.explorer.focused_link = Some(next);
1647    }
1648
1649    /// Renders the selected concept once (uncached) to count its links.
1650    fn explorer_link_count(&self) -> usize {
1651        let Some(snapshot) = &self.snapshot else {
1652            return 0;
1653        };
1654        let Some(TreeSel::Concept(id)) = &self.explorer.selected else {
1655            return 0;
1656        };
1657        let Some(concept) = snapshot.bundle.get(id) else {
1658            return 0;
1659        };
1660        crate::markdown::render_document(&concept.document.body, 80, &self.theme, None)
1661            .links
1662            .len()
1663    }
1664
1665    fn follow_focused_link(&mut self) {
1666        let Some(snapshot) = self.snapshot.clone() else {
1667            return;
1668        };
1669        let Some(TreeSel::Concept(id)) = self.explorer.selected.clone() else {
1670            return;
1671        };
1672        let Some(concept) = snapshot.bundle.get(&id) else {
1673            return;
1674        };
1675        let Some(focus) = self.explorer.focused_link else {
1676            return;
1677        };
1678        let rendered =
1679            crate::markdown::render_document(&concept.document.body, 80, &self.theme, None);
1680        let Some(target) = rendered.links.get(focus) else {
1681            return;
1682        };
1683        match &target.kind {
1684            crate::markdown::FocusKind::Footnote(label) => {
1685                if let Some(&line) = rendered.footnote_defs.get(label) {
1686                    self.explorer.scroll = line;
1687                }
1688            }
1689            crate::markdown::FocusKind::Link { target, kind, .. } => {
1690                let link = okf_core::Link {
1691                    text: String::new(),
1692                    target: target.clone(),
1693                    kind: *kind,
1694                };
1695                if let Some(resolved) = link
1696                    .resolve_all(&id)
1697                    .into_iter()
1698                    .find(|t| snapshot.bundle.contains(t))
1699                {
1700                    self.open_concept(&resolved);
1701                } else if *kind == okf_core::LinkKind::External {
1702                    self.toast(format!("external: {target}"), false);
1703                } else {
1704                    self.toast(format!("✗ broken link: {target}"), true);
1705                }
1706            }
1707        }
1708    }
1709
1710    /// Jumps the explorer to a concept, recording history.
1711    pub fn open_concept(&mut self, id: &ConceptId) {
1712        if let Some(TreeSel::Concept(current)) = &self.explorer.selected
1713            && current != id
1714        {
1715            self.explorer.history.push(current.clone());
1716            self.explorer.future.clear();
1717        }
1718        self.tab = Tab::Explorer;
1719        self.explorer.pane = ExplorerPane::Viewer;
1720        self.select_row(&TreeSel::Concept(id.clone()));
1721        self.reveal_in_tree(id);
1722    }
1723
1724    /// Expands ancestors so the selection is visible in the tree.
1725    fn reveal_in_tree(&mut self, id: &ConceptId) {
1726        let mut prefix = String::new();
1727        let segments = id.segments();
1728        for segment in &segments[..segments.len().saturating_sub(1)] {
1729            if !prefix.is_empty() {
1730                prefix.push('/');
1731            }
1732            prefix.push_str(segment);
1733            self.explorer.collapsed.remove(&prefix);
1734        }
1735    }
1736
1737    fn nav_back(&mut self) {
1738        if let Some(previous) = self.explorer.history.pop() {
1739            if let Some(TreeSel::Concept(current)) = &self.explorer.selected {
1740                self.explorer.future.push(current.clone());
1741            }
1742            self.select_row(&TreeSel::Concept(previous.clone()));
1743            self.reveal_in_tree(&previous);
1744        }
1745    }
1746
1747    fn nav_forward(&mut self) {
1748        if let Some(next) = self.explorer.future.pop() {
1749            if let Some(TreeSel::Concept(current)) = &self.explorer.selected {
1750                self.explorer.history.push(current.clone());
1751            }
1752            self.select_row(&TreeSel::Concept(next.clone()));
1753            self.reveal_in_tree(&next);
1754        }
1755    }
1756
1757    fn graph_navigate(&mut self, action: Action) {
1758        let step = 0.15 / self.graph.zoom;
1759        match action {
1760            Action::Up => self.graph.pan.1 += step,
1761            Action::Down => self.graph.pan.1 -= step,
1762            Action::Left => self.graph.pan.0 -= step,
1763            Action::Right => self.graph.pan.0 += step,
1764            Action::Activate => {
1765                if let Some(id) = self.selected_concept() {
1766                    self.open_concept(&id);
1767                }
1768            }
1769            _ => {}
1770        }
1771    }
1772
1773    fn cycle_node(&mut self, forward: bool) {
1774        let Some(snapshot) = self.snapshot.clone() else {
1775            return;
1776        };
1777        let included = self.graph_included(&snapshot);
1778        // Reading order over layout positions gives a stable, spatial cycle.
1779        let mut visible: Vec<(usize, (f64, f64))> = snapshot
1780            .graph
1781            .nodes
1782            .iter()
1783            .enumerate()
1784            .filter(|(i, _)| included[*i])
1785            .map(|(i, node)| {
1786                (
1787                    i,
1788                    self.graph
1789                        .layout
1790                        .positions
1791                        .get(&node.key)
1792                        .copied()
1793                        .unwrap_or((0.0, 0.0)),
1794                )
1795            })
1796            .collect();
1797        if visible.is_empty() {
1798            return;
1799        }
1800        visible.sort_by(|a, b| {
1801            b.1.1
1802                .partial_cmp(&a.1.1)
1803                .unwrap_or(std::cmp::Ordering::Equal)
1804                .then(
1805                    a.1.0
1806                        .partial_cmp(&b.1.0)
1807                        .unwrap_or(std::cmp::Ordering::Equal),
1808                )
1809        });
1810        let current = self.graph.selected.as_ref().and_then(|key| {
1811            visible
1812                .iter()
1813                .position(|(i, _)| &snapshot.graph.nodes[*i].key == key)
1814        });
1815        let next_pos = match current {
1816            None => 0,
1817            Some(p) if forward => (p + 1) % visible.len(),
1818            Some(p) => (p + visible.len() - 1) % visible.len(),
1819        };
1820        let node = &snapshot.graph.nodes[visible[next_pos].0];
1821        self.graph.selected = Some(node.key.clone());
1822        // Center the selection.
1823        if let Some(&(x, y)) = self.graph.layout.positions.get(&node.key) {
1824            self.graph.pan = (x, y);
1825        }
1826    }
1827
1828    fn cycle_focus_mode(&mut self) {
1829        let center = self
1830            .graph
1831            .selected
1832            .clone()
1833            .or_else(|| self.graph.focus.as_ref().map(|(key, _)| key.clone()));
1834        let Some(center) = center else {
1835            self.toast("select a node first (Tab)", false);
1836            return;
1837        };
1838        self.graph.focus = match &self.graph.focus {
1839            None => Some((center, 1)),
1840            Some((_, k)) if *k < 3 => Some((center, k + 1)),
1841            Some(_) => None,
1842        };
1843    }
1844
1845    fn trust_navigate(&mut self, action: Action) {
1846        let queue_len = self.filtered_queue().len();
1847        match self.trust.panel {
1848            TrustPanel::Queue => match action {
1849                Action::Up => self.trust.queue_sel = self.trust.queue_sel.saturating_sub(1),
1850                Action::Down => {
1851                    self.trust.queue_sel =
1852                        (self.trust.queue_sel + 1).min(queue_len.saturating_sub(1));
1853                }
1854                Action::Home => self.trust.queue_sel = 0,
1855                Action::End => self.trust.queue_sel = queue_len.saturating_sub(1),
1856                Action::Activate => {
1857                    if let Some(item) = self.filtered_queue().get(self.trust.queue_sel) {
1858                        let id = item.id.clone();
1859                        self.open_concept(&id);
1860                    }
1861                }
1862                _ => {}
1863            },
1864            TrustPanel::Activity => {
1865                if action == Action::Activate {
1866                    self.overlays.push(Overlay::LogView(0));
1867                }
1868            }
1869            panel => {
1870                let rows = match panel {
1871                    TrustPanel::TrustBars | TrustPanel::FreshnessBars => 3,
1872                    _ => 4,
1873                };
1874                match action {
1875                    Action::Up => self.trust.bar_sel = self.trust.bar_sel.saturating_sub(1),
1876                    Action::Down => self.trust.bar_sel = (self.trust.bar_sel + 1).min(rows - 1),
1877                    Action::Activate => {
1878                        let cohort = match panel {
1879                            TrustPanel::TrustBars => Cohort::Tier(match self.trust.bar_sel {
1880                                2 => TrustTier::Unverified,
1881                                1 => TrustTier::MachineConfirmed,
1882                                _ => TrustTier::HumanReviewed,
1883                            }),
1884                            TrustPanel::LifecycleBars => Cohort::Status(match self.trust.bar_sel {
1885                                0 => 1, // stable listed first
1886                                1 => 0,
1887                                other => other,
1888                            }),
1889                            _ => Cohort::Fresh(self.trust.bar_sel),
1890                        };
1891                        // Selecting the active cohort again clears it.
1892                        self.trust.cohort = if self.trust.cohort == Some(cohort) {
1893                            None
1894                        } else {
1895                            Some(cohort)
1896                        };
1897                        self.trust.queue_sel = 0;
1898                    }
1899                    _ => {}
1900                }
1901            }
1902        }
1903    }
1904
1905    fn computations_navigate(&mut self, action: Action) {
1906        let Some(snapshot) = &self.snapshot else {
1907            return;
1908        };
1909        let count = snapshot.contracts.len();
1910        match self.computations.pane {
1911            CompPane::List => match action {
1912                Action::Up => {
1913                    self.computations.selected = self.computations.selected.saturating_sub(1);
1914                }
1915                Action::Down => {
1916                    self.computations.selected =
1917                        (self.computations.selected + 1).min(count.saturating_sub(1));
1918                }
1919                Action::Activate => {
1920                    if let Some(id) = self.selected_concept() {
1921                        self.open_concept(&id);
1922                    }
1923                }
1924                _ => {}
1925            },
1926            CompPane::Form => {}
1927        }
1928    }
1929}
1930
1931// ---------------------------------------------------------------------------
1932// Overlays.
1933// ---------------------------------------------------------------------------
1934
1935/// One row of the merged diagnostics overlay.
1936#[derive(Clone, Debug)]
1937pub struct DiagRow {
1938    /// `true` when the row comes from lint rather than validation.
1939    pub from_lint: bool,
1940    /// Severity.
1941    pub severity: okf_validator::Severity,
1942    /// The concept, when attributable.
1943    pub concept: Option<ConceptId>,
1944    /// The file, when attributable.
1945    pub path: Option<PathBuf>,
1946    /// The message.
1947    pub message: String,
1948    /// Whether `okf fix` can remediate it.
1949    pub fixable: bool,
1950}
1951
1952/// The palette's computed results.
1953#[derive(Debug)]
1954pub enum PaletteResults {
1955    /// Omnisearch hits.
1956    Search(Vec<crate::search::SearchHit>),
1957    /// Command rows: `(label, key hint, action)`.
1958    Commands(Vec<(String, String, Action)>),
1959}
1960
1961/// The curated command-mode entries: every studio action as a fuzzy-searchable
1962/// verb, each showing its direct keybinding as passive training.
1963fn command_entries() -> Vec<(&'static str, Action)> {
1964    vec![
1965        ("stamp verification", Action::Verify),
1966        ("extend stale_after…", Action::ExtendStale),
1967        ("move concept…", Action::MoveOrRename),
1968        ("remove concept…", Action::Remove),
1969        ("merge concept into…", Action::Merge),
1970        ("split section…", Action::SplitSection),
1971        ("new concept…", Action::NewConcept),
1972        ("fix all safe issues", Action::FixAll),
1973        ("diagnostics", Action::OpenDiagnostics),
1974        ("pin evaluation date (--today)…", Action::PinToday),
1975        ("open activity log", Action::OpenLog),
1976        ("open in $EDITOR", Action::OpenEditor),
1977        ("reload bundle", Action::Reload),
1978        ("toggle: color graph by next dimension", Action::CycleColor),
1979        ("toggle: graph source nodes", Action::ToggleSources),
1980        ("toggle: graph broken targets", Action::ToggleBroken),
1981        ("toggle: graph derivation edges", Action::ToggleDerivations),
1982        ("cycle graph layout", Action::CycleLayout),
1983        ("toggle raw YAML frontmatter", Action::ToggleRawYaml),
1984        ("help / cheatsheet", Action::OpenHelp),
1985        ("quit", Action::Quit),
1986    ]
1987}
1988
1989impl App {
1990    /// Computes the palette's result rows for its current input.
1991    #[must_use]
1992    pub fn palette_results(&self, state: &PaletteState) -> PaletteResults {
1993        if state.mode() == PaletteMode::Command {
1994            let query = state.input.trim_start().trim_start_matches('>').trim();
1995            let mut rows: Vec<(i32, (String, String, Action))> = Vec::new();
1996            for (label, action) in command_entries() {
1997                let score = if query.is_empty() {
1998                    Some((0, Vec::new()))
1999                } else {
2000                    crate::search::fuzzy_match(query, label)
2001                };
2002                if let Some((score, _)) = score {
2003                    let key = crate::keymap::bindings()
2004                        .iter()
2005                        .find(|b| b.action == action && b.primary)
2006                        .map_or(String::new(), |b| b.label.to_string());
2007                    rows.push((score, (label.to_string(), key, action)));
2008                }
2009            }
2010            rows.sort_by_key(|a| std::cmp::Reverse(a.0));
2011            PaletteResults::Commands(rows.into_iter().map(|(_, row)| row).collect())
2012        } else {
2013            let hits = self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| {
2014                snapshot.search.search(&state.input, 50)
2015            });
2016            PaletteResults::Search(hits)
2017        }
2018    }
2019
2020    /// The merged validation + lint rows for a diagnostics filter.
2021    #[must_use]
2022    pub fn diag_rows(&self, filter: DiagFilter) -> Vec<DiagRow> {
2023        let Some(snapshot) = &self.snapshot else {
2024            return Vec::new();
2025        };
2026        let mut rows = Vec::new();
2027        if filter != DiagFilter::Lint {
2028            for d in &snapshot.validation.diagnostics {
2029                let include = match filter {
2030                    DiagFilter::Errors => d.severity == okf_validator::Severity::Error,
2031                    DiagFilter::Warnings => d.severity == okf_validator::Severity::Warning,
2032                    _ => true,
2033                };
2034                if include {
2035                    rows.push(DiagRow {
2036                        from_lint: false,
2037                        severity: d.severity,
2038                        concept: d.concept.clone(),
2039                        path: d.path.clone(),
2040                        message: d.message.clone(),
2041                        fixable: d.fixable,
2042                    });
2043                }
2044            }
2045        }
2046        if matches!(filter, DiagFilter::All | DiagFilter::Lint) {
2047            for d in &snapshot.lint.diagnostics {
2048                rows.push(DiagRow {
2049                    from_lint: true,
2050                    severity: d.severity,
2051                    concept: d.concept.clone(),
2052                    path: d.path.clone(),
2053                    message: d.message.clone(),
2054                    fixable: d.fixable,
2055                });
2056            }
2057        }
2058        // Errors first, then warnings, then the rest.
2059        rows.sort_by_key(|a| std::cmp::Reverse(a.severity));
2060        rows
2061    }
2062
2063    #[allow(clippy::too_many_lines)]
2064    fn overlay_key(&mut self, key: KeyEvent) {
2065        if key.code == KeyCode::Esc {
2066            self.overlays.pop();
2067            return;
2068        }
2069        let Some(overlay) = self.overlays.pop() else {
2070            return;
2071        };
2072        match overlay {
2073            Overlay::Palette(state) => self.palette_key(state, key),
2074            Overlay::Diagnostics(state) => self.diagnostics_key(state, key),
2075            Overlay::Help(scroll) => {
2076                let next = match key.code {
2077                    KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
2078                    KeyCode::Down | KeyCode::Char('j') => scroll + 1,
2079                    KeyCode::PageUp => scroll.saturating_sub(20),
2080                    KeyCode::PageDown => scroll + 20,
2081                    KeyCode::Char('q' | '?') | KeyCode::Enter => {
2082                        return;
2083                    }
2084                    _ => scroll,
2085                };
2086                self.overlays.push(Overlay::Help(next));
2087            }
2088            Overlay::Confirm(state) => {
2089                if key.code == KeyCode::Enter {
2090                    match &state.action {
2091                        ConfirmAction::Verify(id) => {
2092                            self.loading = LoadPhase::Applying;
2093                            self.send(Command::StampVerification(id.clone()));
2094                        }
2095                    }
2096                } else {
2097                    self.overlays.push(Overlay::Confirm(state));
2098                }
2099            }
2100            Overlay::DatePicker(state) => self.date_picker_key(state, key),
2101            Overlay::Refactor(state) => self.refactor_key(*state, key),
2102            Overlay::NewConcept(state) => self.new_concept_key(state, key),
2103            Overlay::FixPreview(state) => self.fix_preview_key(state, key),
2104            Overlay::LogView(scroll) => {
2105                let next = match key.code {
2106                    KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
2107                    KeyCode::Down | KeyCode::Char('j') => scroll + 1,
2108                    KeyCode::PageUp => scroll.saturating_sub(20),
2109                    KeyCode::PageDown => scroll + 20,
2110                    KeyCode::Char('q') => return,
2111                    _ => scroll,
2112                };
2113                self.overlays.push(Overlay::LogView(next));
2114            }
2115            Overlay::Outline(state) => self.outline_key(state, key),
2116        }
2117    }
2118
2119    fn palette_key(&mut self, mut state: PaletteState, key: KeyEvent) {
2120        match key.code {
2121            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2122                state.input.push(c);
2123                state.sel = 0;
2124                self.overlays.push(Overlay::Palette(state));
2125            }
2126            KeyCode::Backspace => {
2127                state.input.pop();
2128                state.sel = 0;
2129                self.overlays.push(Overlay::Palette(state));
2130            }
2131            KeyCode::Up => {
2132                state.sel = state.sel.saturating_sub(1);
2133                self.overlays.push(Overlay::Palette(state));
2134            }
2135            KeyCode::Down => {
2136                state.sel += 1;
2137                self.overlays.push(Overlay::Palette(state));
2138            }
2139            KeyCode::Enter => match self.palette_results(&state) {
2140                PaletteResults::Search(hits) => {
2141                    if let Some(hit) = hits.get(state.sel.min(hits.len().saturating_sub(1))) {
2142                        let id = hit.id.clone();
2143                        if self.tab == Tab::Graph {
2144                            let key = id.to_string();
2145                            if let Some(&(x, y)) = self.graph.layout.positions.get(&key) {
2146                                self.graph.pan = (x, y);
2147                            }
2148                            self.graph.selected = Some(key);
2149                        } else {
2150                            self.open_concept(&id);
2151                        }
2152                    }
2153                }
2154                PaletteResults::Commands(commands) => {
2155                    if let Some((_, _, action)) =
2156                        commands.get(state.sel.min(commands.len().saturating_sub(1)))
2157                    {
2158                        self.run_action(*action);
2159                    }
2160                }
2161            },
2162            _ => self.overlays.push(Overlay::Palette(state)),
2163        }
2164    }
2165
2166    fn diagnostics_key(&mut self, mut state: DiagnosticsState, key: KeyEvent) {
2167        let rows = self.diag_rows(state.filter);
2168        match key.code {
2169            KeyCode::Char('a') => state.filter = DiagFilter::All,
2170            KeyCode::Char('e') => state.filter = DiagFilter::Errors,
2171            KeyCode::Char('w') => state.filter = DiagFilter::Warnings,
2172            KeyCode::Char('l') => state.filter = DiagFilter::Lint,
2173            KeyCode::Up | KeyCode::Char('k') => state.sel = state.sel.saturating_sub(1),
2174            KeyCode::Down | KeyCode::Char('j') => {
2175                state.sel = (state.sel + 1).min(rows.len().saturating_sub(1));
2176            }
2177            KeyCode::Enter => {
2178                if let Some(row) = rows.get(state.sel)
2179                    && let Some(id) = row.concept.clone()
2180                {
2181                    self.open_concept(&id);
2182                    return;
2183                }
2184            }
2185            KeyCode::Char('f') => {
2186                if let Some(row) = rows.get(state.sel) {
2187                    let path = row.path.clone().or_else(|| {
2188                        self.snapshot.as_ref().and_then(|s| {
2189                            row.concept.as_ref().map(|id| id.to_path(s.bundle.root()))
2190                        })
2191                    });
2192                    if let Some(path) = path {
2193                        self.loading = LoadPhase::Applying;
2194                        self.send(Command::ApplyFixFile(path));
2195                    } else {
2196                        self.toast("no file to fix", true);
2197                    }
2198                }
2199            }
2200            KeyCode::Char('F') => self.send(Command::PreviewFix),
2201            KeyCode::Char('t') => {
2202                let today = self
2203                    .snapshot
2204                    .as_ref()
2205                    .map_or_else(String::new, |s| s.today.to_string());
2206                self.overlays.push(Overlay::Diagnostics(state));
2207                self.overlays.push(Overlay::DatePicker(DatePickerState {
2208                    id: None,
2209                    input: today,
2210                }));
2211                return;
2212            }
2213            _ => {}
2214        }
2215        state.sel = state
2216            .sel
2217            .min(self.diag_rows(state.filter).len().saturating_sub(1));
2218        self.overlays.push(Overlay::Diagnostics(state));
2219    }
2220
2221    fn date_picker_key(&mut self, mut state: DatePickerState, key: KeyEvent) {
2222        let shift = |input: &str, days: i64| -> Option<String> {
2223            Date::parse(input.trim())
2224                .map(|d| Date::from_days_since_epoch(d.days_since_epoch() + days).to_string())
2225        };
2226        match key.code {
2227            KeyCode::Char(c) if c.is_ascii_digit() || c == '-' => state.input.push(c),
2228            KeyCode::Backspace => {
2229                state.input.pop();
2230            }
2231            KeyCode::Up => {
2232                if let Some(next) = shift(&state.input, 1) {
2233                    state.input = next;
2234                }
2235            }
2236            KeyCode::Down => {
2237                if let Some(next) = shift(&state.input, -1) {
2238                    state.input = next;
2239                }
2240            }
2241            KeyCode::PageUp => {
2242                if let Some(next) = shift(&state.input, 30) {
2243                    state.input = next;
2244                }
2245            }
2246            KeyCode::PageDown => {
2247                if let Some(next) = shift(&state.input, -30) {
2248                    state.input = next;
2249                }
2250            }
2251            KeyCode::Enter => {
2252                match (&state.id, Date::parse(state.input.trim())) {
2253                    (Some(id), Some(date)) => {
2254                        self.loading = LoadPhase::Applying;
2255                        self.send(Command::SetStaleAfter(id.clone(), date));
2256                    }
2257                    (None, Some(date)) => {
2258                        self.loading = LoadPhase::Reloading;
2259                        self.send(Command::SetToday(Some(date)));
2260                        self.toast(format!("today pinned to {date}"), false);
2261                    }
2262                    (None, None) if state.input.trim().is_empty() => {
2263                        self.loading = LoadPhase::Reloading;
2264                        self.send(Command::SetToday(None));
2265                        self.toast("today unpinned (wall clock)", false);
2266                    }
2267                    _ => {
2268                        self.toast("not a YYYY-MM-DD date", true);
2269                        self.overlays.push(Overlay::DatePicker(state));
2270                    }
2271                }
2272                return;
2273            }
2274            _ => {}
2275        }
2276        self.overlays.push(Overlay::DatePicker(state));
2277    }
2278
2279    #[allow(clippy::too_many_lines)]
2280    fn refactor_key(&mut self, mut state: RefactorState, key: KeyEvent) {
2281        let awaiting_decision = matches!(
2282            state.preview,
2283            Some(Err(RefactorError::HasInboundLinks { .. }))
2284        ) || state.choice != RemoveChoice::Plain;
2285        let target_exists = matches!(
2286            state.preview,
2287            Some(Err(RefactorError::ConceptAlreadyExists(_)))
2288        );
2289        match key.code {
2290            KeyCode::Enter => {
2291                if matches!(state.preview, Some(Ok(_)))
2292                    && let Some(op) = build_op(&state)
2293                {
2294                    self.loading = LoadPhase::Applying;
2295                    self.send(Command::Apply(op));
2296                    return;
2297                }
2298                state.needs_preview = true;
2299            }
2300            KeyCode::Tab | KeyCode::BackTab => {
2301                let fields = match state.verb {
2302                    VerbKind::Split => 2,
2303                    VerbKind::Remove if state.choice == RemoveChoice::Redirect => 2,
2304                    _ => 1,
2305                };
2306                state.field = (state.field + 1) % fields;
2307            }
2308            KeyCode::Char('r') if state.verb == VerbKind::Remove && awaiting_decision => {
2309                state.choice = RemoveChoice::Redirect;
2310                state.field = 1;
2311                state.needs_preview = true;
2312            }
2313            KeyCode::Char('u') if state.verb == VerbKind::Remove && awaiting_decision => {
2314                state.choice = RemoveChoice::Unlink;
2315                state.needs_preview = true;
2316            }
2317            KeyCode::Char('f') if state.verb == VerbKind::Remove && awaiting_decision => {
2318                state.choice = RemoveChoice::Force;
2319                state.needs_preview = true;
2320            }
2321            KeyCode::Char('o')
2322                if matches!(state.verb, VerbKind::Move | VerbKind::Split) && target_exists =>
2323            {
2324                state.force = true;
2325                state.needs_preview = true;
2326            }
2327            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2328                let field = if state.field == 0 {
2329                    &mut state.input
2330                } else {
2331                    &mut state.extra
2332                };
2333                field.push(c);
2334                state.force = false;
2335                state.needs_preview = true;
2336            }
2337            KeyCode::Backspace => {
2338                let field = if state.field == 0 {
2339                    &mut state.input
2340                } else {
2341                    &mut state.extra
2342                };
2343                field.pop();
2344                state.force = false;
2345                state.needs_preview = true;
2346            }
2347            _ => {}
2348        }
2349        self.overlays.push(Overlay::Refactor(Box::new(state)));
2350    }
2351
2352    fn new_concept_key(&mut self, mut state: NewConceptState, key: KeyEvent) {
2353        match key.code {
2354            KeyCode::Tab | KeyCode::Down => state.field = (state.field + 1) % 3,
2355            KeyCode::BackTab | KeyCode::Up => state.field = (state.field + 2) % 3,
2356            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2357                state.fields[state.field].push(c);
2358            }
2359            KeyCode::Backspace => {
2360                state.fields[state.field].pop();
2361            }
2362            KeyCode::Enter => {
2363                let path = state.fields[0].trim().to_string();
2364                if path.is_empty() || path.ends_with('/') {
2365                    self.toast("enter a concept path", true);
2366                    self.overlays.push(Overlay::NewConcept(state));
2367                    return;
2368                }
2369                let type_ = if state.fields[1].trim().is_empty() {
2370                    "Concept".to_string()
2371                } else {
2372                    state.fields[1].trim().to_string()
2373                };
2374                let title = if state.fields[2].trim().is_empty() {
2375                    None
2376                } else {
2377                    Some(state.fields[2].trim().to_string())
2378                };
2379                self.loading = LoadPhase::Applying;
2380                self.send(Command::CreateConcept {
2381                    rel_path: path,
2382                    type_,
2383                    title,
2384                });
2385                return;
2386            }
2387            _ => {}
2388        }
2389        self.overlays.push(Overlay::NewConcept(state));
2390    }
2391
2392    fn fix_preview_key(&mut self, mut state: FixPreviewState, key: KeyEvent) {
2393        let changed_count = state.report.files.iter().filter(|f| f.changed).count();
2394        match key.code {
2395            KeyCode::Up | KeyCode::Char('k') => state.scroll = state.scroll.saturating_sub(1),
2396            KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
2397            KeyCode::PageUp => state.scroll = state.scroll.saturating_sub(20),
2398            KeyCode::PageDown => state.scroll += 20,
2399            KeyCode::Left | KeyCode::BackTab => {
2400                state.file_sel = state.file_sel.saturating_sub(1);
2401                state.scroll = 0;
2402            }
2403            KeyCode::Right | KeyCode::Tab => {
2404                state.file_sel = (state.file_sel + 1).min(changed_count.saturating_sub(1));
2405                state.scroll = 0;
2406            }
2407            KeyCode::Enter => {
2408                self.loading = LoadPhase::Applying;
2409                self.send(Command::ApplyFix);
2410                return;
2411            }
2412            _ => {}
2413        }
2414        self.overlays.push(Overlay::FixPreview(state));
2415    }
2416
2417    fn outline_key(&mut self, mut state: OutlineState, key: KeyEvent) {
2418        let headings: Vec<(usize, String)> = self
2419            .snapshot
2420            .as_ref()
2421            .and_then(|s| s.meta(&state.id))
2422            .map(|m| m.headings.clone())
2423            .unwrap_or_default();
2424        match key.code {
2425            KeyCode::Up | KeyCode::Char('k') => state.sel = state.sel.saturating_sub(1),
2426            KeyCode::Down | KeyCode::Char('j') => {
2427                state.sel = (state.sel + 1).min(headings.len().saturating_sub(1));
2428            }
2429            KeyCode::Enter => {
2430                if let Some((_, text)) = headings.get(state.sel) {
2431                    self.scroll_to_heading(&state.id, text);
2432                }
2433                return;
2434            }
2435            KeyCode::Char('s') => {
2436                if let Some((_, text)) = headings.get(state.sel) {
2437                    let id = state.id.clone();
2438                    self.open_refactor(VerbKind::Split, id, Some(text.clone()));
2439                }
2440                return;
2441            }
2442            KeyCode::Char('r') | KeyCode::F(2) => {
2443                if let Some((_, text)) = headings.get(state.sel) {
2444                    let id = state.id.clone();
2445                    self.open_refactor(VerbKind::RenameSection, id, Some(text.clone()));
2446                }
2447                return;
2448            }
2449            _ => {}
2450        }
2451        self.overlays.push(Overlay::Outline(state));
2452    }
2453
2454    /// Scrolls the viewer to a heading (rendered-line index approximated at a
2455    /// standard width; the view clamps).
2456    fn scroll_to_heading(&mut self, id: &ConceptId, heading: &str) {
2457        let Some(snapshot) = &self.snapshot else {
2458            return;
2459        };
2460        let Some(concept) = snapshot.bundle.get(id) else {
2461            return;
2462        };
2463        let rendered =
2464            crate::markdown::render_document(&concept.document.body, 80, &self.theme, None);
2465        if let Some(pos) = rendered.headings.iter().find(|h| h.text == heading) {
2466            self.explorer.pane = ExplorerPane::Viewer;
2467            self.explorer.scroll = pos.line;
2468        }
2469    }
2470}