1use 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
20const TOAST_TTL: u8 = 12;
22
23#[derive(Debug)]
25pub enum Msg {
26 Key(KeyEvent),
28 Mouse(MouseEvent),
30 Resize,
32 Tick,
35 FilesChanged,
37 SnapshotReady(Arc<Snapshot>),
39 SnapshotFailed(String),
41 PreviewReady(u64, Result<PreviewReport, RefactorError>),
43 Applied(Result<String, String>),
45 FixReportReady(Box<okf_core::BundleFixReport>),
47 Error(String),
49}
50
51#[derive(Clone, Debug)]
53pub enum Command {
54 Reload,
56 Preview {
58 request: u64,
60 op: RefactorOp,
62 },
63 Apply(RefactorOp),
65 StampVerification(ConceptId),
67 SetStaleAfter(ConceptId, Date),
69 CreateConcept {
71 rel_path: String,
73 type_: String,
75 title: Option<String>,
77 },
78 PreviewFix,
80 ApplyFix,
82 ApplyFixFile(PathBuf),
84 SetToday(Option<Date>),
86 Shutdown,
88}
89
90#[derive(Clone, Debug)]
92pub enum RefactorOp {
93 Move {
95 source: ConceptId,
97 target: ConceptId,
99 force: bool,
101 },
102 Remove {
104 target: ConceptId,
106 redirect_to: Option<ConceptId>,
108 unlink: bool,
110 force: bool,
112 },
113 Merge {
115 source: ConceptId,
117 target: ConceptId,
119 },
120 Split {
122 source: ConceptId,
124 target: ConceptId,
126 section: String,
128 title: Option<String>,
130 force: bool,
132 },
133 RenameSection {
135 concept: ConceptId,
137 old: String,
139 new: String,
141 },
142}
143
144#[derive(Clone, Debug)]
146pub enum PreviewReport {
147 Move(MoveReport),
149 Remove(RemoveReport),
151 Merge(MergeReport),
153 Split(SplitReport),
155 RenameSection(RenameSectionReport),
157}
158
159#[derive(Clone, Debug, PartialEq, Eq)]
162pub enum TreeSel {
163 Dir(String),
165 Concept(ConceptId),
167 File(PathBuf),
169}
170
171#[derive(Clone, Debug)]
173pub struct TreeRow {
174 pub depth: usize,
176 pub sel: TreeSel,
178 pub name: String,
180 pub kind: TreeRowKind,
182}
183
184#[derive(Clone, Debug)]
186pub enum TreeRowKind {
187 Dir {
189 count: usize,
191 collapsed: bool,
193 },
194 Leaf(LeafKind),
196}
197
198#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum ExplorerPane {
201 #[default]
203 Tree,
204 Viewer,
206}
207
208#[derive(Debug, Default)]
210pub struct ExplorerState {
211 pub pane: ExplorerPane,
213 pub selected: Option<TreeSel>,
215 pub collapsed: HashSet<String>,
217 pub tree_offset: std::cell::Cell<usize>,
219 pub scroll: usize,
221 pub max_scroll: std::cell::Cell<usize>,
223 pub focused_link: Option<usize>,
225 pub raw_yaml: bool,
227 pub inspector_tab: usize,
229 pub history: Vec<ConceptId>,
231 pub future: Vec<ConceptId>,
233}
234
235#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
237pub enum ColorBy {
238 #[default]
240 Trust,
241 Status,
243 Staleness,
245 Type,
247 Diagnostics,
249}
250
251impl ColorBy {
252 #[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 #[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#[derive(Debug)]
279pub struct GraphState {
280 pub layout: LayoutEngine,
282 pub mode: LayoutMode,
284 pub pan: (f64, f64),
286 pub zoom: f64,
288 pub selected: Option<String>,
290 pub focus: Option<(String, usize)>,
292 pub color_by: ColorBy,
294 pub show_sources: bool,
296 pub show_broken: bool,
298 pub show_derivations: bool,
300 pub filter: String,
302 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
327pub enum TrustPanel {
328 #[default]
330 Queue,
331 TrustBars,
333 LifecycleBars,
335 FreshnessBars,
337 Activity,
339}
340
341impl TrustPanel {
342 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357pub enum Cohort {
358 Tier(TrustTier),
360 Status(usize),
362 Fresh(usize),
364}
365
366#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
368pub enum QueueSort {
369 #[default]
371 Risk,
372 Staleness,
374 Backlinks,
376}
377
378impl QueueSort {
379 #[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 #[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#[derive(Debug, Default)]
402pub struct TrustState {
403 pub panel: TrustPanel,
405 pub queue_sel: usize,
407 pub bar_sel: usize,
409 pub cohort: Option<Cohort>,
411 pub sort: QueueSort,
413}
414
415#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
417pub enum CompPane {
418 #[default]
420 List,
421 Form,
423}
424
425#[derive(Debug, Default)]
427pub struct ComputationsState {
428 pub pane: CompPane,
430 pub selected: usize,
432 pub field: usize,
434 pub values: std::collections::HashMap<String, String>,
436}
437
438#[derive(Clone, Copy, Debug, PartialEq, Eq)]
440pub enum PaletteMode {
441 Search,
443 Command,
445}
446
447#[derive(Debug, Default)]
449pub struct PaletteState {
450 pub input: String,
452 pub sel: usize,
454}
455
456impl PaletteState {
457 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
470pub enum DiagFilter {
471 #[default]
473 All,
474 Errors,
476 Warnings,
478 Lint,
480}
481
482#[derive(Debug, Default)]
484pub struct DiagnosticsState {
485 pub filter: DiagFilter,
487 pub sel: usize,
489}
490
491#[derive(Clone, Debug)]
493pub enum ConfirmAction {
494 Verify(ConceptId),
496}
497
498#[derive(Debug)]
500pub struct ConfirmState {
501 pub title: String,
503 pub body: Vec<String>,
505 pub action: ConfirmAction,
507}
508
509#[derive(Debug)]
511pub struct DatePickerState {
512 pub id: Option<ConceptId>,
514 pub input: String,
516}
517
518#[derive(Clone, Copy, Debug, PartialEq, Eq)]
520pub enum VerbKind {
521 Move,
523 Remove,
525 Merge,
527 Split,
529 RenameSection,
531}
532
533#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
535pub enum RemoveChoice {
536 #[default]
538 Plain,
539 Redirect,
541 Unlink,
543 Force,
545}
546
547#[derive(Debug)]
549pub struct RefactorState {
550 pub verb: VerbKind,
552 pub subject: ConceptId,
554 pub section: Option<String>,
556 pub input: String,
558 pub extra: String,
560 pub field: usize,
562 pub choice: RemoveChoice,
564 pub force: bool,
566 pub preview: Option<Result<PreviewReport, RefactorError>>,
568 pub request: u64,
570 pub needs_preview: bool,
572}
573
574#[derive(Debug)]
576pub struct NewConceptState {
577 pub fields: [String; 3],
579 pub field: usize,
581}
582
583#[derive(Debug)]
585pub struct FixPreviewState {
586 pub report: Box<okf_core::BundleFixReport>,
588 pub file_sel: usize,
590 pub scroll: usize,
592}
593
594#[derive(Debug)]
596pub struct OutlineState {
597 pub id: ConceptId,
599 pub sel: usize,
601 pub rename: Option<String>,
603}
604
605#[derive(Debug)]
607pub enum Overlay {
608 Palette(PaletteState),
610 Diagnostics(DiagnosticsState),
612 Help(usize),
614 Confirm(ConfirmState),
616 DatePicker(DatePickerState),
618 Refactor(Box<RefactorState>),
620 NewConcept(NewConceptState),
622 FixPreview(FixPreviewState),
624 LogView(usize),
626 Outline(OutlineState),
628}
629
630#[derive(Clone, Debug)]
632pub struct Toast {
633 pub text: String,
635 pub ttl: u8,
637 pub error: bool,
639}
640
641#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
643pub enum LoadPhase {
644 #[default]
646 Idle,
647 Reloading,
649 Applying,
651}
652
653#[allow(clippy::struct_excessive_bools)]
655pub struct App {
656 pub snapshot: Option<Arc<Snapshot>>,
658 pub tab: Tab,
660 pub explorer: ExplorerState,
662 pub graph: GraphState,
664 pub trust: TrustState,
666 pub computations: ComputationsState,
668 pub overlays: Vec<Overlay>,
670 pub toasts: VecDeque<Toast>,
672 pub loading: LoadPhase,
674 pub theme: Theme,
676 pub author: String,
678 pub pending_commands: Vec<Command>,
680 pub editor_request: Option<PathBuf>,
682 pub copy_request: Option<String>,
684 pub should_quit: bool,
686 pub tick: u64,
688 pub reload_queued: bool,
690 pub next_request: u64,
692 pub root_name: String,
694 pub load_error: Option<String>,
696 pub no_watch: bool,
698 first_snapshot: bool,
700}
701
702impl App {
703 #[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 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 #[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 #[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 #[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 #[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 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 }
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 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 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 #[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 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 self.graph.layout.seed(&snapshot.graph);
1016 if self.graph.mode == LayoutMode::Radial {
1017 self.graph.layout.radial(&snapshot.graph);
1018 }
1019
1020 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
1087fn 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
1151impl 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 if self.tab == Tab::Graph && self.graph.filter_input.is_some() {
1164 self.graph_filter_key(key);
1165 return;
1166 }
1167 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 #[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 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 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 #[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 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 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 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 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 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 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 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, 1 => 0,
1887 other => other,
1888 }),
1889 _ => Cohort::Fresh(self.trust.bar_sel),
1890 };
1891 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#[derive(Clone, Debug)]
1937pub struct DiagRow {
1938 pub from_lint: bool,
1940 pub severity: okf_validator::Severity,
1942 pub concept: Option<ConceptId>,
1944 pub path: Option<PathBuf>,
1946 pub message: String,
1948 pub fixable: bool,
1950}
1951
1952#[derive(Debug)]
1954pub enum PaletteResults {
1955 Search(Vec<crate::search::SearchHit>),
1957 Commands(Vec<(String, String, Action)>),
1959}
1960
1961fn 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 #[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 #[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 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 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}