use crate::graph::{LayoutEngine, LayoutMode};
use crate::keymap::{Action, Context, resolve};
use crate::snapshot::{LeafKind, Snapshot, TreeNode};
use crate::theme::Theme;
use crate::{StudioOptions, Tab};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use okf_core::{ConceptId, Date, RefactorError, TrustTier};
use okf_core::{MergeReport, MoveReport, RemoveReport, RenameSectionReport, SplitReport};
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
const TOAST_TTL: u8 = 12;
#[derive(Debug)]
pub enum Msg {
Key(KeyEvent),
Mouse(MouseEvent),
Resize,
Tick,
FilesChanged,
SnapshotReady(Arc<Snapshot>),
SnapshotFailed(String),
PreviewReady(u64, Result<PreviewReport, RefactorError>),
Applied(Result<String, String>),
FixReportReady(Box<okf_core::BundleFixReport>),
Error(String),
}
#[derive(Clone, Debug)]
pub enum Command {
Reload,
Preview {
request: u64,
op: RefactorOp,
},
Apply(RefactorOp),
StampVerification(ConceptId),
SetStaleAfter(ConceptId, Date),
CreateConcept {
rel_path: String,
type_: String,
title: Option<String>,
},
PreviewFix,
ApplyFix,
ApplyFixFile(PathBuf),
SetToday(Option<Date>),
Shutdown,
}
#[derive(Clone, Debug)]
pub enum RefactorOp {
Move {
source: ConceptId,
target: ConceptId,
force: bool,
},
Remove {
target: ConceptId,
redirect_to: Option<ConceptId>,
unlink: bool,
force: bool,
},
Merge {
source: ConceptId,
target: ConceptId,
},
Split {
source: ConceptId,
target: ConceptId,
section: String,
title: Option<String>,
force: bool,
},
RenameSection {
concept: ConceptId,
old: String,
new: String,
},
}
#[derive(Clone, Debug)]
pub enum PreviewReport {
Move(MoveReport),
Remove(RemoveReport),
Merge(MergeReport),
Split(SplitReport),
RenameSection(RenameSectionReport),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TreeSel {
Dir(String),
Concept(ConceptId),
File(PathBuf),
}
#[derive(Clone, Debug)]
pub struct TreeRow {
pub depth: usize,
pub sel: TreeSel,
pub name: String,
pub kind: TreeRowKind,
}
#[derive(Clone, Debug)]
pub enum TreeRowKind {
Dir {
count: usize,
collapsed: bool,
},
Leaf(LeafKind),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExplorerPane {
#[default]
Tree,
Viewer,
}
#[derive(Debug, Default)]
pub struct ExplorerState {
pub pane: ExplorerPane,
pub selected: Option<TreeSel>,
pub collapsed: HashSet<String>,
pub tree_offset: std::cell::Cell<usize>,
pub scroll: usize,
pub max_scroll: std::cell::Cell<usize>,
pub focused_link: Option<usize>,
pub raw_yaml: bool,
pub inspector_tab: usize,
pub history: Vec<ConceptId>,
pub future: Vec<ConceptId>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorBy {
#[default]
Trust,
Status,
Staleness,
Type,
Diagnostics,
}
impl ColorBy {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Trust => Self::Status,
Self::Status => Self::Staleness,
Self::Staleness => Self::Type,
Self::Type => Self::Diagnostics,
Self::Diagnostics => Self::Trust,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Trust => "trust",
Self::Status => "status",
Self::Staleness => "staleness",
Self::Type => "type",
Self::Diagnostics => "diagnostics",
}
}
}
#[derive(Debug)]
pub struct GraphState {
pub layout: LayoutEngine,
pub mode: LayoutMode,
pub pan: (f64, f64),
pub zoom: f64,
pub selected: Option<String>,
pub focus: Option<(String, usize)>,
pub color_by: ColorBy,
pub show_sources: bool,
pub show_broken: bool,
pub show_derivations: bool,
pub filter: String,
pub filter_input: Option<String>,
}
impl Default for GraphState {
fn default() -> Self {
Self {
layout: LayoutEngine::default(),
mode: LayoutMode::default(),
pan: (0.0, 0.0),
zoom: 1.0,
selected: None,
focus: None,
color_by: ColorBy::default(),
show_sources: false,
show_broken: true,
show_derivations: true,
filter: String::new(),
filter_input: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TrustPanel {
#[default]
Queue,
TrustBars,
LifecycleBars,
FreshnessBars,
Activity,
}
impl TrustPanel {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Queue => Self::TrustBars,
Self::TrustBars => Self::LifecycleBars,
Self::LifecycleBars => Self::FreshnessBars,
Self::FreshnessBars => Self::Activity,
Self::Activity => Self::Queue,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cohort {
Tier(TrustTier),
Status(usize),
Fresh(usize),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum QueueSort {
#[default]
Risk,
Staleness,
Backlinks,
}
impl QueueSort {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Risk => Self::Staleness,
Self::Staleness => Self::Backlinks,
Self::Backlinks => Self::Risk,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Risk => "risk",
Self::Staleness => "staleness",
Self::Backlinks => "backlinks",
}
}
}
#[derive(Debug, Default)]
pub struct TrustState {
pub panel: TrustPanel,
pub queue_sel: usize,
pub bar_sel: usize,
pub cohort: Option<Cohort>,
pub sort: QueueSort,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CompPane {
#[default]
List,
Form,
}
#[derive(Debug, Default)]
pub struct ComputationsState {
pub pane: CompPane,
pub selected: usize,
pub field: usize,
pub values: std::collections::HashMap<String, String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaletteMode {
Search,
Command,
}
#[derive(Debug, Default)]
pub struct PaletteState {
pub input: String,
pub sel: usize,
}
impl PaletteState {
#[must_use]
pub fn mode(&self) -> PaletteMode {
if self.input.trim_start().starts_with('>') {
PaletteMode::Command
} else {
PaletteMode::Search
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DiagFilter {
#[default]
All,
Errors,
Warnings,
Lint,
}
#[derive(Debug, Default)]
pub struct DiagnosticsState {
pub filter: DiagFilter,
pub sel: usize,
}
#[derive(Clone, Debug)]
pub enum ConfirmAction {
Verify(ConceptId),
}
#[derive(Debug)]
pub struct ConfirmState {
pub title: String,
pub body: Vec<String>,
pub action: ConfirmAction,
}
#[derive(Debug)]
pub struct DatePickerState {
pub id: Option<ConceptId>,
pub input: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VerbKind {
Move,
Remove,
Merge,
Split,
RenameSection,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RemoveChoice {
#[default]
Plain,
Redirect,
Unlink,
Force,
}
#[derive(Debug)]
pub struct RefactorState {
pub verb: VerbKind,
pub subject: ConceptId,
pub section: Option<String>,
pub input: String,
pub extra: String,
pub field: usize,
pub choice: RemoveChoice,
pub force: bool,
pub preview: Option<Result<PreviewReport, RefactorError>>,
pub request: u64,
pub needs_preview: bool,
}
#[derive(Debug)]
pub struct NewConceptState {
pub fields: [String; 3],
pub field: usize,
}
#[derive(Debug)]
pub struct FixPreviewState {
pub report: Box<okf_core::BundleFixReport>,
pub file_sel: usize,
pub scroll: usize,
}
#[derive(Debug)]
pub struct OutlineState {
pub id: ConceptId,
pub sel: usize,
pub rename: Option<String>,
}
#[derive(Debug)]
pub enum Overlay {
Palette(PaletteState),
Diagnostics(DiagnosticsState),
Help(usize),
Confirm(ConfirmState),
DatePicker(DatePickerState),
Refactor(Box<RefactorState>),
NewConcept(NewConceptState),
FixPreview(FixPreviewState),
LogView(usize),
Outline(OutlineState),
}
#[derive(Clone, Debug)]
pub struct Toast {
pub text: String,
pub ttl: u8,
pub error: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoadPhase {
#[default]
Idle,
Reloading,
Applying,
}
#[allow(clippy::struct_excessive_bools)]
pub struct App {
pub snapshot: Option<Arc<Snapshot>>,
pub tab: Tab,
pub explorer: ExplorerState,
pub graph: GraphState,
pub trust: TrustState,
pub computations: ComputationsState,
pub overlays: Vec<Overlay>,
pub toasts: VecDeque<Toast>,
pub loading: LoadPhase,
pub theme: Theme,
pub author: String,
pub pending_commands: Vec<Command>,
pub editor_request: Option<PathBuf>,
pub copy_request: Option<String>,
pub should_quit: bool,
pub tick: u64,
pub reload_queued: bool,
pub next_request: u64,
pub root_name: String,
pub load_error: Option<String>,
pub no_watch: bool,
first_snapshot: bool,
}
impl App {
#[must_use]
pub fn new(options: &StudioOptions) -> Self {
let root_name = options
.root
.canonicalize()
.unwrap_or_else(|_| options.root.clone())
.file_name()
.map_or_else(
|| options.root.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
Self {
snapshot: None,
tab: options.initial_tab.unwrap_or(Tab::Explorer),
explorer: ExplorerState::default(),
graph: GraphState::default(),
trust: TrustState::default(),
computations: ComputationsState::default(),
overlays: Vec::new(),
toasts: VecDeque::new(),
loading: LoadPhase::Reloading,
theme: Theme::from_env(),
author: options
.author
.clone()
.unwrap_or_else(okf_core::default_author),
pending_commands: vec![Command::Reload],
editor_request: None,
copy_request: None,
should_quit: false,
tick: 0,
reload_queued: false,
next_request: 0,
root_name,
load_error: None,
no_watch: options.no_watch,
first_snapshot: true,
}
}
pub fn toast(&mut self, text: impl Into<String>, error: bool) {
self.toasts.push_back(Toast {
text: text.into(),
ttl: TOAST_TTL,
error,
});
while self.toasts.len() > 3 {
self.toasts.pop_front();
}
}
fn send(&mut self, command: Command) {
self.pending_commands.push(command);
}
#[must_use]
pub fn selected_concept(&self) -> Option<ConceptId> {
let snapshot = self.snapshot.as_ref()?;
match self.tab {
Tab::Explorer => match &self.explorer.selected {
Some(TreeSel::Concept(id)) => Some(id.clone()),
_ => None,
},
Tab::Graph => {
let key = self.graph.selected.as_ref()?;
snapshot
.graph
.nodes
.iter()
.find(|n| &n.key == key)
.and_then(|n| n.id.clone())
}
Tab::Trust => self
.filtered_queue()
.get(self.trust.queue_sel)
.map(|item| item.id.clone()),
Tab::Computations => snapshot
.contracts
.get(self.computations.selected)
.map(|c| c.id.clone()),
}
}
#[must_use]
pub fn selected_path(&self) -> Option<PathBuf> {
let snapshot = self.snapshot.as_ref()?;
if self.tab == Tab::Explorer {
match &self.explorer.selected {
Some(TreeSel::File(path)) => return Some(path.clone()),
Some(TreeSel::Dir(_)) | None => return None,
Some(TreeSel::Concept(_)) => {}
}
}
self.selected_concept()
.map(|id| id.to_path(snapshot.bundle.root()))
}
#[must_use]
pub fn tree_rows(&self) -> Vec<TreeRow> {
let mut rows = Vec::new();
if let Some(snapshot) = &self.snapshot {
flatten_tree(&snapshot.tree.roots, 0, &self.explorer.collapsed, &mut rows);
}
rows
}
#[must_use]
pub fn filtered_queue(&self) -> Vec<crate::snapshot::AttentionItem> {
let Some(snapshot) = &self.snapshot else {
return Vec::new();
};
let mut items: Vec<crate::snapshot::AttentionItem> = snapshot
.attention
.iter()
.filter(|item| {
let Some(meta) = snapshot.meta(&item.id) else {
return false;
};
match self.trust.cohort {
None => true,
Some(Cohort::Tier(tier)) => meta.tier == tier,
Some(Cohort::Status(ix)) => {
ix == match meta.status {
okf_core::Status::Draft => 0,
okf_core::Status::Stable => 1,
okf_core::Status::Deprecated => 2,
okf_core::Status::Other(_) => 3,
}
}
Some(Cohort::Fresh(ix)) => match ix {
1 => meta.stale,
2 => meta.stale_in_days.is_some(),
_ => !meta.stale && meta.stale_in_days.is_none(),
},
}
})
.cloned()
.collect();
match self.trust.sort {
QueueSort::Risk => {}
QueueSort::Staleness => items.sort_by_key(|item| {
std::cmp::Reverse(
self.snapshot
.as_ref()
.and_then(|s| s.meta(&item.id))
.and_then(|m| m.overdue_days)
.unwrap_or(i64::MIN),
)
}),
QueueSort::Backlinks => items.sort_by_key(|item| {
std::cmp::Reverse(
self.snapshot
.as_ref()
.and_then(|s| s.meta(&item.id))
.map_or(0, |m| m.in_degree),
)
}),
}
items
}
pub fn update(&mut self, msg: Msg) {
match msg {
Msg::Key(key) => self.on_key(key),
Msg::Mouse(_) | Msg::Resize => {}
Msg::Tick => self.on_tick(),
Msg::FilesChanged => {
if self.loading == LoadPhase::Idle {
self.loading = LoadPhase::Reloading;
self.send(Command::Reload);
} else {
self.reload_queued = true;
}
}
Msg::SnapshotReady(snapshot) => self.on_snapshot(snapshot),
Msg::SnapshotFailed(e) => {
self.load_error = Some(e);
self.loading = LoadPhase::Idle;
}
Msg::PreviewReady(request, result) => {
if let Some(Overlay::Refactor(state)) = self.overlays.last_mut()
&& state.request == request
{
if let Err(RefactorError::HasInboundLinks { .. }) = &result
&& state.verb == VerbKind::Remove
&& state.choice == RemoveChoice::Plain
{
}
state.preview = Some(result);
}
}
Msg::Applied(result) => {
self.loading = LoadPhase::Idle;
match result {
Ok(text) => self.toast(text, false),
Err(e) => self.toast(format!("✗ {e}"), true),
}
}
Msg::FixReportReady(report) => {
if report.is_empty() {
self.toast("✔ nothing to fix", false);
} else {
self.overlays.push(Overlay::FixPreview(FixPreviewState {
report,
file_sel: 0,
scroll: 0,
}));
}
}
Msg::Error(e) => self.toast(format!("✗ {e}"), true),
}
}
fn on_tick(&mut self) {
self.tick += 1;
for toast in &mut self.toasts {
toast.ttl = toast.ttl.saturating_sub(1);
}
self.toasts.retain(|t| t.ttl > 0);
let mut to_send: Option<(u64, RefactorOp)> = None;
if let Some(Overlay::Refactor(state)) = self.overlays.last_mut()
&& state.needs_preview
{
state.needs_preview = false;
if let Some(op) = build_op(state) {
self.next_request += 1;
state.request = self.next_request;
to_send = Some((state.request, op));
} else {
state.preview = None;
}
}
if let Some((request, op)) = to_send {
self.send(Command::Preview { request, op });
}
if self.tab == Tab::Graph
&& self.graph.mode == LayoutMode::Force
&& let Some(snapshot) = self.snapshot.clone()
{
let included = self.graph_included(&snapshot);
self.graph.layout.step(&snapshot.graph, &included, 12);
}
}
#[must_use]
pub fn graph_included(&self, snapshot: &Snapshot) -> Vec<bool> {
let model = &snapshot.graph;
let mut included: Vec<bool> = model
.nodes
.iter()
.map(|node| match node.kind {
crate::graph::NodeKind::Source => self.graph.show_sources,
crate::graph::NodeKind::Phantom => self.graph.show_broken,
_ => true,
})
.collect();
if let Some((center_key, k)) = &self.graph.focus
&& let Some(center) = model.nodes.iter().position(|n| &n.key == center_key)
{
let hood = model.neighborhood(center, *k);
for (i, inc) in included.iter_mut().enumerate() {
*inc = *inc && hood[i];
}
}
included
}
fn on_snapshot(&mut self, snapshot: Arc<Snapshot>) {
self.load_error = None;
self.loading = LoadPhase::Idle;
if let Some(TreeSel::Concept(id)) = &self.explorer.selected
&& !snapshot.bundle.contains(id)
{
self.explorer.selected = None;
}
if self.explorer.selected.is_none() {
self.explorer.selected = snapshot
.bundle
.concepts()
.first()
.map(|c| TreeSel::Concept(c.id.clone()));
}
if let Some(key) = &self.graph.selected
&& !snapshot.graph.nodes.iter().any(|n| &n.key == key)
{
self.graph.selected = None;
}
self.computations.selected = self
.computations
.selected
.min(snapshot.contracts.len().saturating_sub(1));
self.graph.layout.seed(&snapshot.graph);
if self.graph.mode == LayoutMode::Radial {
self.graph.layout.radial(&snapshot.graph);
}
if self.first_snapshot {
self.first_snapshot = false;
if snapshot.graph.nodes.len() > 500 {
let center = snapshot.bundle.concepts().first().map(|c| c.id.to_string());
if let Some(center) = center {
self.graph.focus = Some((center, 1));
}
}
}
self.snapshot = Some(snapshot);
if self.reload_queued {
self.reload_queued = false;
self.loading = LoadPhase::Reloading;
self.send(Command::Reload);
}
}
}
fn flatten_tree(
nodes: &[TreeNode],
depth: usize,
collapsed: &HashSet<String>,
out: &mut Vec<TreeRow>,
) {
for node in nodes {
match node {
TreeNode::Dir {
name,
path,
concept_count,
children,
} => {
let is_collapsed = collapsed.contains(path);
out.push(TreeRow {
depth,
sel: TreeSel::Dir(path.clone()),
name: name.clone(),
kind: TreeRowKind::Dir {
count: *concept_count,
collapsed: is_collapsed,
},
});
if !is_collapsed {
flatten_tree(children, depth + 1, collapsed, out);
}
}
TreeNode::Leaf { name, kind } => {
let sel = match kind {
LeafKind::Concept(id) => TreeSel::Concept(id.clone()),
LeafKind::Index(p) | LeafKind::Log(p) | LeafKind::Broken(p) => {
TreeSel::File(p.clone())
}
};
out.push(TreeRow {
depth,
sel,
name: name.clone(),
kind: TreeRowKind::Leaf(kind.clone()),
});
}
}
}
}
fn build_op(state: &RefactorState) -> Option<RefactorOp> {
match state.verb {
VerbKind::Move => {
let target = ConceptId::parse(state.input.trim()).ok()?;
Some(RefactorOp::Move {
source: state.subject.clone(),
target,
force: state.force,
})
}
VerbKind::Remove => {
let redirect_to = if state.choice == RemoveChoice::Redirect {
Some(ConceptId::parse(state.extra.trim()).ok()?)
} else {
None
};
Some(RefactorOp::Remove {
target: state.subject.clone(),
redirect_to,
unlink: state.choice == RemoveChoice::Unlink,
force: state.choice == RemoveChoice::Force,
})
}
VerbKind::Merge => {
let target = ConceptId::parse(state.input.trim()).ok()?;
if target == state.subject {
return None;
}
Some(RefactorOp::Merge {
source: state.subject.clone(),
target,
})
}
VerbKind::Split => {
let target = ConceptId::parse(state.input.trim()).ok()?;
let title = if state.extra.trim().is_empty() {
None
} else {
Some(state.extra.trim().to_string())
};
Some(RefactorOp::Split {
source: state.subject.clone(),
target,
section: state.section.clone()?,
title,
force: state.force,
})
}
VerbKind::RenameSection => {
let new = state.input.trim();
if new.is_empty() {
return None;
}
Some(RefactorOp::RenameSection {
concept: state.subject.clone(),
old: state.section.clone()?,
new: new.to_string(),
})
}
}
}
impl App {
fn on_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if self.overlays.is_empty() {
if self.tab == Tab::Graph && self.graph.filter_input.is_some() {
self.graph_filter_key(key);
return;
}
if self.tab == Tab::Computations
&& self.computations.pane == CompPane::Form
&& self.playground_form_key(&key)
{
return;
}
let mut contexts: Vec<Context> = vec![match (self.tab, self.explorer.pane) {
(Tab::Explorer, ExplorerPane::Tree) => Context::Tree,
(Tab::Explorer, ExplorerPane::Viewer) => Context::Viewer,
(Tab::Graph, _) => Context::Graph,
(Tab::Trust, _) => Context::Trust,
(Tab::Computations, _) => Context::Computations,
}];
if self.selected_concept().is_some() {
contexts.push(Context::Concept);
}
contexts.push(Context::Global);
if let Some(action) = resolve(&contexts, &key) {
self.run_action(action);
}
return;
}
self.overlay_key(key);
}
#[allow(clippy::too_many_lines)]
pub fn run_action(&mut self, action: Action) {
match action {
Action::SwitchTab(ix) => {
self.tab = match ix {
1 => Tab::Graph,
2 => Tab::Trust,
3 => Tab::Computations,
_ => Tab::Explorer,
};
}
Action::OpenPalette => self
.overlays
.push(Overlay::Palette(PaletteState::default())),
Action::OpenDiagnostics => self
.overlays
.push(Overlay::Diagnostics(DiagnosticsState::default())),
Action::OpenHelp => self.overlays.push(Overlay::Help(0)),
Action::Reload => {
self.loading = LoadPhase::Reloading;
self.send(Command::Reload);
}
Action::OpenEditor => {
if let Some(path) = self.selected_path() {
self.editor_request = Some(path);
} else {
self.toast("no file selected", true);
}
}
Action::Quit => self.should_quit = true,
Action::Verify => {
if let Some(id) = self.selected_concept() {
self.overlays.push(Overlay::Confirm(ConfirmState {
title: format!("Stamp verification on {id}"),
body: vec![
format!("by {}", self.author),
"Appends a { by, at } event to `verified` and logs it.".to_string(),
],
action: ConfirmAction::Verify(id),
}));
}
}
Action::ExtendStale => {
if let Some(id) = self.selected_concept() {
let current = self
.snapshot
.as_ref()
.and_then(|s| s.meta(&id))
.and_then(|m| m.stale_after.as_ref())
.and_then(|f| f.datetime.map(|dt| dt.date));
let today = self.snapshot.as_ref().map_or(
Date {
year: 2026,
month: 1,
day: 1,
},
|s| s.today,
);
let default = Date::from_days_since_epoch(
current
.unwrap_or(today)
.days_since_epoch()
.max(today.days_since_epoch())
+ 90,
);
self.overlays.push(Overlay::DatePicker(DatePickerState {
id: Some(id),
input: default.to_string(),
}));
}
}
Action::MoveOrRename => {
if let Some(id) = self.selected_concept() {
self.open_refactor(VerbKind::Move, id, None);
}
}
Action::Remove => {
if let Some(id) = self.selected_concept() {
self.open_refactor(VerbKind::Remove, id, None);
}
}
Action::Merge => {
if let Some(id) = self.selected_concept() {
self.open_refactor(VerbKind::Merge, id, None);
}
}
Action::SplitSection | Action::Outline => self.open_outline(),
Action::NewConcept => {
let dir = match &self.explorer.selected {
Some(TreeSel::Dir(path)) => path.clone(),
Some(TreeSel::Concept(id)) => {
id.parent().map(|p| p.to_string()).unwrap_or_default()
}
_ => String::new(),
};
let path = if dir.is_empty() {
String::new()
} else {
format!("{dir}/")
};
self.overlays.push(Overlay::NewConcept(NewConceptState {
fields: [path, "Concept".to_string(), String::new()],
field: 0,
}));
}
Action::CycleInspector => {
self.explorer.inspector_tab = (self.explorer.inspector_tab + 1) % 4;
}
Action::ToggleRawYaml => self.explorer.raw_yaml = !self.explorer.raw_yaml,
Action::NextLink | Action::PrevLink => self.cycle_link(action == Action::NextLink),
Action::Back => self.nav_back(),
Action::Forward => self.nav_forward(),
Action::ZoomIn => self.graph.zoom = (self.graph.zoom * 1.25).min(40.0),
Action::ZoomOut => self.graph.zoom = (self.graph.zoom / 1.25).max(0.05),
Action::NextNode | Action::PrevNode => self.cycle_node(action == Action::NextNode),
Action::FocusMode => self.cycle_focus_mode(),
Action::CycleColor => self.graph.color_by = self.graph.color_by.next(),
Action::ToggleSources => self.graph.show_sources = !self.graph.show_sources,
Action::ToggleBroken => self.graph.show_broken = !self.graph.show_broken,
Action::ToggleDerivations => {
self.graph.show_derivations = !self.graph.show_derivations;
}
Action::PauseLayout => self.graph.layout.toggle_running(),
Action::GraphFilter => {
self.graph.filter_input = Some(self.graph.filter.clone());
}
Action::CycleLayout => {
self.graph.mode = self.graph.mode.next();
if let Some(snapshot) = &self.snapshot {
match self.graph.mode {
LayoutMode::Radial => self.graph.layout.radial(&snapshot.graph),
LayoutMode::Force => {
self.graph.layout.running = true;
self.graph.layout.toggle_running();
self.graph.layout.toggle_running();
}
}
}
}
Action::CyclePanel => match self.tab {
Tab::Trust => {
self.trust.panel = self.trust.panel.next();
self.trust.bar_sel = 0;
}
Tab::Computations => {
self.computations.pane = match self.computations.pane {
CompPane::List => CompPane::Form,
CompPane::Form => CompPane::List,
};
}
_ => {}
},
Action::CycleSort => self.trust.sort = self.trust.sort.next(),
Action::CopySketch => self.copy_sketch(),
Action::FixAll => self.send(Command::PreviewFix),
Action::PinToday => {
let today = self
.snapshot
.as_ref()
.map_or_else(String::new, |s| s.today.to_string());
self.overlays.push(Overlay::DatePicker(DatePickerState {
id: None,
input: today,
}));
}
Action::OpenLog => self.overlays.push(Overlay::LogView(0)),
Action::Up
| Action::Down
| Action::Left
| Action::Right
| Action::PageUp
| Action::PageDown
| Action::HalfUp
| Action::HalfDown
| Action::Home
| Action::End
| Action::Activate => self.navigate(action),
}
}
fn open_refactor(&mut self, verb: VerbKind, subject: ConceptId, section: Option<String>) {
let input = match verb {
VerbKind::Move => subject.to_string(),
VerbKind::RenameSection => section.clone().unwrap_or_default(),
VerbKind::Split => section
.as_deref()
.map(|s| {
let slug = okf_core::heading_slug(s);
subject
.parent()
.map_or_else(|| slug.clone(), |p| format!("{p}/{slug}"))
})
.unwrap_or_default(),
_ => String::new(),
};
let extra = match verb {
VerbKind::Split => section.clone().unwrap_or_default(),
_ => String::new(),
};
let mut state = RefactorState {
verb,
subject,
section,
input,
extra,
field: 0,
choice: RemoveChoice::Plain,
force: false,
preview: None,
request: 0,
needs_preview: true,
};
if verb == VerbKind::Remove {
state.needs_preview = true;
}
self.overlays.push(Overlay::Refactor(Box::new(state)));
}
fn open_outline(&mut self) {
if self.tab != Tab::Explorer {
return;
}
if let Some(TreeSel::Concept(id)) = &self.explorer.selected {
self.overlays.push(Overlay::Outline(OutlineState {
id: id.clone(),
sel: 0,
rename: None,
}));
}
}
fn copy_sketch(&mut self) {
let Some(snapshot) = &self.snapshot else {
return;
};
let Some(info) = snapshot.contracts.get(self.computations.selected) else {
return;
};
let mut args: Vec<String> = Vec::new();
for parameter in &info.contract.parameters {
let name = parameter.name.clone().unwrap_or_default();
let key = format!("{}\u{0}{}", info.id, name);
let value = self
.computations
.values
.get(&key)
.cloned()
.unwrap_or_default();
if !value.is_empty() || parameter.is_required() {
args.push(format!("{name}={value}"));
}
}
let sketch = format!("{}({})", info.id.name(), args.join(", "));
self.copy_request = Some(sketch.clone());
self.toast(format!("✔ copied: {sketch}"), false);
}
fn graph_filter_key(&mut self, key: KeyEvent) {
let Some(input) = self.graph.filter_input.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => {
self.graph.filter_input = None;
self.graph.filter.clear();
}
KeyCode::Enter => {
self.graph.filter = self.graph.filter_input.take().unwrap_or_default();
}
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) => input.push(c),
_ => {}
}
}
fn playground_form_key(&mut self, key: &KeyEvent) -> bool {
let Some(snapshot) = &self.snapshot else {
return false;
};
let Some(info) = snapshot.contracts.get(self.computations.selected) else {
return false;
};
let params: Vec<String> = info
.contract
.parameters
.iter()
.map(|p| p.name.clone().unwrap_or_default())
.collect();
if params.is_empty() {
return false;
}
let field = self.computations.field.min(params.len() - 1);
let value_key = format!("{}\u{0}{}", info.id, params[field]);
match key.code {
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& c != '?'
&& c != '!'
&& c != '/' =>
{
self.computations
.values
.entry(value_key)
.or_default()
.push(c);
true
}
KeyCode::Backspace => {
self.computations.values.entry(value_key).or_default().pop();
true
}
KeyCode::Up => {
self.computations.field = field.saturating_sub(1);
true
}
KeyCode::Down => {
self.computations.field = (field + 1).min(params.len() - 1);
true
}
_ => false,
}
}
#[allow(clippy::too_many_lines)]
fn navigate(&mut self, action: Action) {
match self.tab {
Tab::Explorer => match self.explorer.pane {
ExplorerPane::Tree => self.tree_navigate(action),
ExplorerPane::Viewer => self.viewer_navigate(action),
},
Tab::Graph => self.graph_navigate(action),
Tab::Trust => self.trust_navigate(action),
Tab::Computations => self.computations_navigate(action),
}
}
fn tree_navigate(&mut self, action: Action) {
let rows = self.tree_rows();
if rows.is_empty() {
return;
}
let current = self
.explorer
.selected
.as_ref()
.and_then(|sel| rows.iter().position(|row| &row.sel == sel))
.unwrap_or(0);
let mut next = current;
match action {
Action::Up => next = current.saturating_sub(1),
Action::Down => next = (current + 1).min(rows.len() - 1),
Action::PageUp => next = current.saturating_sub(10),
Action::PageDown => next = (current + 10).min(rows.len() - 1),
Action::Home => next = 0,
Action::End => next = rows.len() - 1,
Action::Left => {
match &rows[current].sel {
TreeSel::Dir(path) if !self.explorer.collapsed.contains(path) => {
self.explorer.collapsed.insert(path.clone());
}
sel => {
let parent = match sel {
TreeSel::Concept(id) => id.parent().map(|p| p.to_string()),
TreeSel::Dir(path) => {
path.rsplit_once('/').map(|(parent, _)| parent.to_string())
}
TreeSel::File(_) => None,
};
if let Some(parent) = parent
&& let Some(ix) = rows
.iter()
.position(|row| row.sel == TreeSel::Dir(parent.clone()))
{
next = ix;
}
}
}
}
Action::Right => match &rows[current].sel {
TreeSel::Dir(path) => {
self.explorer.collapsed.remove(path);
}
_ => self.explorer.pane = ExplorerPane::Viewer,
},
Action::Activate => match &rows[current].sel {
TreeSel::Dir(path) => {
if self.explorer.collapsed.contains(path) {
self.explorer.collapsed.remove(path);
} else {
self.explorer.collapsed.insert(path.clone());
}
}
_ => self.explorer.pane = ExplorerPane::Viewer,
},
_ => {}
}
if next != current {
self.select_row(&rows[next].sel);
}
}
fn select_row(&mut self, sel: &TreeSel) {
self.explorer.selected = Some(sel.clone());
self.explorer.scroll = 0;
self.explorer.focused_link = None;
}
fn viewer_navigate(&mut self, action: Action) {
let max = self.explorer.max_scroll.get();
let scroll = &mut self.explorer.scroll;
match action {
Action::Up => *scroll = scroll.saturating_sub(1),
Action::Down => *scroll = (*scroll + 1).min(max),
Action::PageUp => *scroll = scroll.saturating_sub(20),
Action::PageDown => *scroll = (*scroll + 20).min(max),
Action::HalfUp => *scroll = scroll.saturating_sub(10),
Action::HalfDown => *scroll = (*scroll + 10).min(max),
Action::Home => *scroll = 0,
Action::End => *scroll = max,
Action::Left => self.explorer.pane = ExplorerPane::Tree,
Action::Activate => self.follow_focused_link(),
_ => {}
}
}
fn cycle_link(&mut self, forward: bool) {
if self.tab != Tab::Explorer {
return;
}
self.explorer.pane = ExplorerPane::Viewer;
let count = self.explorer_link_count();
if count == 0 {
self.explorer.focused_link = None;
return;
}
let next = match self.explorer.focused_link {
None if forward => 0,
None => count - 1,
Some(i) if forward => (i + 1) % count,
Some(i) => (i + count - 1) % count,
};
self.explorer.focused_link = Some(next);
}
fn explorer_link_count(&self) -> usize {
let Some(snapshot) = &self.snapshot else {
return 0;
};
let Some(TreeSel::Concept(id)) = &self.explorer.selected else {
return 0;
};
let Some(concept) = snapshot.bundle.get(id) else {
return 0;
};
crate::markdown::render_document(&concept.document.body, 80, &self.theme, None)
.links
.len()
}
fn follow_focused_link(&mut self) {
let Some(snapshot) = self.snapshot.clone() else {
return;
};
let Some(TreeSel::Concept(id)) = self.explorer.selected.clone() else {
return;
};
let Some(concept) = snapshot.bundle.get(&id) else {
return;
};
let Some(focus) = self.explorer.focused_link else {
return;
};
let rendered =
crate::markdown::render_document(&concept.document.body, 80, &self.theme, None);
let Some(target) = rendered.links.get(focus) else {
return;
};
match &target.kind {
crate::markdown::FocusKind::Footnote(label) => {
if let Some(&line) = rendered.footnote_defs.get(label) {
self.explorer.scroll = line;
}
}
crate::markdown::FocusKind::Link { target, kind, .. } => {
let link = okf_core::Link {
text: String::new(),
target: target.clone(),
kind: *kind,
};
if let Some(resolved) = link
.resolve_all(&id)
.into_iter()
.find(|t| snapshot.bundle.contains(t))
{
self.open_concept(&resolved);
} else if *kind == okf_core::LinkKind::External {
self.toast(format!("external: {target}"), false);
} else {
self.toast(format!("✗ broken link: {target}"), true);
}
}
}
}
pub fn open_concept(&mut self, id: &ConceptId) {
if let Some(TreeSel::Concept(current)) = &self.explorer.selected
&& current != id
{
self.explorer.history.push(current.clone());
self.explorer.future.clear();
}
self.tab = Tab::Explorer;
self.explorer.pane = ExplorerPane::Viewer;
self.select_row(&TreeSel::Concept(id.clone()));
self.reveal_in_tree(id);
}
fn reveal_in_tree(&mut self, id: &ConceptId) {
let mut prefix = String::new();
let segments = id.segments();
for segment in &segments[..segments.len().saturating_sub(1)] {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(segment);
self.explorer.collapsed.remove(&prefix);
}
}
fn nav_back(&mut self) {
if let Some(previous) = self.explorer.history.pop() {
if let Some(TreeSel::Concept(current)) = &self.explorer.selected {
self.explorer.future.push(current.clone());
}
self.select_row(&TreeSel::Concept(previous.clone()));
self.reveal_in_tree(&previous);
}
}
fn nav_forward(&mut self) {
if let Some(next) = self.explorer.future.pop() {
if let Some(TreeSel::Concept(current)) = &self.explorer.selected {
self.explorer.history.push(current.clone());
}
self.select_row(&TreeSel::Concept(next.clone()));
self.reveal_in_tree(&next);
}
}
fn graph_navigate(&mut self, action: Action) {
let step = 0.15 / self.graph.zoom;
match action {
Action::Up => self.graph.pan.1 += step,
Action::Down => self.graph.pan.1 -= step,
Action::Left => self.graph.pan.0 -= step,
Action::Right => self.graph.pan.0 += step,
Action::Activate => {
if let Some(id) = self.selected_concept() {
self.open_concept(&id);
}
}
_ => {}
}
}
fn cycle_node(&mut self, forward: bool) {
let Some(snapshot) = self.snapshot.clone() else {
return;
};
let included = self.graph_included(&snapshot);
let mut visible: Vec<(usize, (f64, f64))> = snapshot
.graph
.nodes
.iter()
.enumerate()
.filter(|(i, _)| included[*i])
.map(|(i, node)| {
(
i,
self.graph
.layout
.positions
.get(&node.key)
.copied()
.unwrap_or((0.0, 0.0)),
)
})
.collect();
if visible.is_empty() {
return;
}
visible.sort_by(|a, b| {
b.1.1
.partial_cmp(&a.1.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.1.0
.partial_cmp(&b.1.0)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
let current = self.graph.selected.as_ref().and_then(|key| {
visible
.iter()
.position(|(i, _)| &snapshot.graph.nodes[*i].key == key)
});
let next_pos = match current {
None => 0,
Some(p) if forward => (p + 1) % visible.len(),
Some(p) => (p + visible.len() - 1) % visible.len(),
};
let node = &snapshot.graph.nodes[visible[next_pos].0];
self.graph.selected = Some(node.key.clone());
if let Some(&(x, y)) = self.graph.layout.positions.get(&node.key) {
self.graph.pan = (x, y);
}
}
fn cycle_focus_mode(&mut self) {
let center = self
.graph
.selected
.clone()
.or_else(|| self.graph.focus.as_ref().map(|(key, _)| key.clone()));
let Some(center) = center else {
self.toast("select a node first (Tab)", false);
return;
};
self.graph.focus = match &self.graph.focus {
None => Some((center, 1)),
Some((_, k)) if *k < 3 => Some((center, k + 1)),
Some(_) => None,
};
}
fn trust_navigate(&mut self, action: Action) {
let queue_len = self.filtered_queue().len();
match self.trust.panel {
TrustPanel::Queue => match action {
Action::Up => self.trust.queue_sel = self.trust.queue_sel.saturating_sub(1),
Action::Down => {
self.trust.queue_sel =
(self.trust.queue_sel + 1).min(queue_len.saturating_sub(1));
}
Action::Home => self.trust.queue_sel = 0,
Action::End => self.trust.queue_sel = queue_len.saturating_sub(1),
Action::Activate => {
if let Some(item) = self.filtered_queue().get(self.trust.queue_sel) {
let id = item.id.clone();
self.open_concept(&id);
}
}
_ => {}
},
TrustPanel::Activity => {
if action == Action::Activate {
self.overlays.push(Overlay::LogView(0));
}
}
panel => {
let rows = match panel {
TrustPanel::TrustBars | TrustPanel::FreshnessBars => 3,
_ => 4,
};
match action {
Action::Up => self.trust.bar_sel = self.trust.bar_sel.saturating_sub(1),
Action::Down => self.trust.bar_sel = (self.trust.bar_sel + 1).min(rows - 1),
Action::Activate => {
let cohort = match panel {
TrustPanel::TrustBars => Cohort::Tier(match self.trust.bar_sel {
2 => TrustTier::Unverified,
1 => TrustTier::MachineConfirmed,
_ => TrustTier::HumanReviewed,
}),
TrustPanel::LifecycleBars => Cohort::Status(match self.trust.bar_sel {
0 => 1, 1 => 0,
other => other,
}),
_ => Cohort::Fresh(self.trust.bar_sel),
};
self.trust.cohort = if self.trust.cohort == Some(cohort) {
None
} else {
Some(cohort)
};
self.trust.queue_sel = 0;
}
_ => {}
}
}
}
}
fn computations_navigate(&mut self, action: Action) {
let Some(snapshot) = &self.snapshot else {
return;
};
let count = snapshot.contracts.len();
match self.computations.pane {
CompPane::List => match action {
Action::Up => {
self.computations.selected = self.computations.selected.saturating_sub(1);
}
Action::Down => {
self.computations.selected =
(self.computations.selected + 1).min(count.saturating_sub(1));
}
Action::Activate => {
if let Some(id) = self.selected_concept() {
self.open_concept(&id);
}
}
_ => {}
},
CompPane::Form => {}
}
}
}
#[derive(Clone, Debug)]
pub struct DiagRow {
pub from_lint: bool,
pub severity: okf_validator::Severity,
pub concept: Option<ConceptId>,
pub path: Option<PathBuf>,
pub message: String,
pub fixable: bool,
}
#[derive(Debug)]
pub enum PaletteResults {
Search(Vec<crate::search::SearchHit>),
Commands(Vec<(String, String, Action)>),
}
fn command_entries() -> Vec<(&'static str, Action)> {
vec![
("stamp verification", Action::Verify),
("extend stale_after…", Action::ExtendStale),
("move concept…", Action::MoveOrRename),
("remove concept…", Action::Remove),
("merge concept into…", Action::Merge),
("split section…", Action::SplitSection),
("new concept…", Action::NewConcept),
("fix all safe issues", Action::FixAll),
("diagnostics", Action::OpenDiagnostics),
("pin evaluation date (--today)…", Action::PinToday),
("open activity log", Action::OpenLog),
("open in $EDITOR", Action::OpenEditor),
("reload bundle", Action::Reload),
("toggle: color graph by next dimension", Action::CycleColor),
("toggle: graph source nodes", Action::ToggleSources),
("toggle: graph broken targets", Action::ToggleBroken),
("toggle: graph derivation edges", Action::ToggleDerivations),
("cycle graph layout", Action::CycleLayout),
("toggle raw YAML frontmatter", Action::ToggleRawYaml),
("help / cheatsheet", Action::OpenHelp),
("quit", Action::Quit),
]
}
impl App {
#[must_use]
pub fn palette_results(&self, state: &PaletteState) -> PaletteResults {
if state.mode() == PaletteMode::Command {
let query = state.input.trim_start().trim_start_matches('>').trim();
let mut rows: Vec<(i32, (String, String, Action))> = Vec::new();
for (label, action) in command_entries() {
let score = if query.is_empty() {
Some((0, Vec::new()))
} else {
crate::search::fuzzy_match(query, label)
};
if let Some((score, _)) = score {
let key = crate::keymap::bindings()
.iter()
.find(|b| b.action == action && b.primary)
.map_or(String::new(), |b| b.label.to_string());
rows.push((score, (label.to_string(), key, action)));
}
}
rows.sort_by_key(|a| std::cmp::Reverse(a.0));
PaletteResults::Commands(rows.into_iter().map(|(_, row)| row).collect())
} else {
let hits = self.snapshot.as_ref().map_or_else(Vec::new, |snapshot| {
snapshot.search.search(&state.input, 50)
});
PaletteResults::Search(hits)
}
}
#[must_use]
pub fn diag_rows(&self, filter: DiagFilter) -> Vec<DiagRow> {
let Some(snapshot) = &self.snapshot else {
return Vec::new();
};
let mut rows = Vec::new();
if filter != DiagFilter::Lint {
for d in &snapshot.validation.diagnostics {
let include = match filter {
DiagFilter::Errors => d.severity == okf_validator::Severity::Error,
DiagFilter::Warnings => d.severity == okf_validator::Severity::Warning,
_ => true,
};
if include {
rows.push(DiagRow {
from_lint: false,
severity: d.severity,
concept: d.concept.clone(),
path: d.path.clone(),
message: d.message.clone(),
fixable: d.fixable,
});
}
}
}
if matches!(filter, DiagFilter::All | DiagFilter::Lint) {
for d in &snapshot.lint.diagnostics {
rows.push(DiagRow {
from_lint: true,
severity: d.severity,
concept: d.concept.clone(),
path: d.path.clone(),
message: d.message.clone(),
fixable: d.fixable,
});
}
}
rows.sort_by_key(|a| std::cmp::Reverse(a.severity));
rows
}
#[allow(clippy::too_many_lines)]
fn overlay_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Esc {
self.overlays.pop();
return;
}
let Some(overlay) = self.overlays.pop() else {
return;
};
match overlay {
Overlay::Palette(state) => self.palette_key(state, key),
Overlay::Diagnostics(state) => self.diagnostics_key(state, key),
Overlay::Help(scroll) => {
let next = match key.code {
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => scroll + 1,
KeyCode::PageUp => scroll.saturating_sub(20),
KeyCode::PageDown => scroll + 20,
KeyCode::Char('q' | '?') | KeyCode::Enter => {
return;
}
_ => scroll,
};
self.overlays.push(Overlay::Help(next));
}
Overlay::Confirm(state) => {
if key.code == KeyCode::Enter {
match &state.action {
ConfirmAction::Verify(id) => {
self.loading = LoadPhase::Applying;
self.send(Command::StampVerification(id.clone()));
}
}
} else {
self.overlays.push(Overlay::Confirm(state));
}
}
Overlay::DatePicker(state) => self.date_picker_key(state, key),
Overlay::Refactor(state) => self.refactor_key(*state, key),
Overlay::NewConcept(state) => self.new_concept_key(state, key),
Overlay::FixPreview(state) => self.fix_preview_key(state, key),
Overlay::LogView(scroll) => {
let next = match key.code {
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => scroll + 1,
KeyCode::PageUp => scroll.saturating_sub(20),
KeyCode::PageDown => scroll + 20,
KeyCode::Char('q') => return,
_ => scroll,
};
self.overlays.push(Overlay::LogView(next));
}
Overlay::Outline(state) => self.outline_key(state, key),
}
}
fn palette_key(&mut self, mut state: PaletteState, key: KeyEvent) {
match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
state.input.push(c);
state.sel = 0;
self.overlays.push(Overlay::Palette(state));
}
KeyCode::Backspace => {
state.input.pop();
state.sel = 0;
self.overlays.push(Overlay::Palette(state));
}
KeyCode::Up => {
state.sel = state.sel.saturating_sub(1);
self.overlays.push(Overlay::Palette(state));
}
KeyCode::Down => {
state.sel += 1;
self.overlays.push(Overlay::Palette(state));
}
KeyCode::Enter => match self.palette_results(&state) {
PaletteResults::Search(hits) => {
if let Some(hit) = hits.get(state.sel.min(hits.len().saturating_sub(1))) {
let id = hit.id.clone();
if self.tab == Tab::Graph {
let key = id.to_string();
if let Some(&(x, y)) = self.graph.layout.positions.get(&key) {
self.graph.pan = (x, y);
}
self.graph.selected = Some(key);
} else {
self.open_concept(&id);
}
}
}
PaletteResults::Commands(commands) => {
if let Some((_, _, action)) =
commands.get(state.sel.min(commands.len().saturating_sub(1)))
{
self.run_action(*action);
}
}
},
_ => self.overlays.push(Overlay::Palette(state)),
}
}
fn diagnostics_key(&mut self, mut state: DiagnosticsState, key: KeyEvent) {
let rows = self.diag_rows(state.filter);
match key.code {
KeyCode::Char('a') => state.filter = DiagFilter::All,
KeyCode::Char('e') => state.filter = DiagFilter::Errors,
KeyCode::Char('w') => state.filter = DiagFilter::Warnings,
KeyCode::Char('l') => state.filter = DiagFilter::Lint,
KeyCode::Up | KeyCode::Char('k') => state.sel = state.sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
state.sel = (state.sel + 1).min(rows.len().saturating_sub(1));
}
KeyCode::Enter => {
if let Some(row) = rows.get(state.sel)
&& let Some(id) = row.concept.clone()
{
self.open_concept(&id);
return;
}
}
KeyCode::Char('f') => {
if let Some(row) = rows.get(state.sel) {
let path = row.path.clone().or_else(|| {
self.snapshot.as_ref().and_then(|s| {
row.concept.as_ref().map(|id| id.to_path(s.bundle.root()))
})
});
if let Some(path) = path {
self.loading = LoadPhase::Applying;
self.send(Command::ApplyFixFile(path));
} else {
self.toast("no file to fix", true);
}
}
}
KeyCode::Char('F') => self.send(Command::PreviewFix),
KeyCode::Char('t') => {
let today = self
.snapshot
.as_ref()
.map_or_else(String::new, |s| s.today.to_string());
self.overlays.push(Overlay::Diagnostics(state));
self.overlays.push(Overlay::DatePicker(DatePickerState {
id: None,
input: today,
}));
return;
}
_ => {}
}
state.sel = state
.sel
.min(self.diag_rows(state.filter).len().saturating_sub(1));
self.overlays.push(Overlay::Diagnostics(state));
}
fn date_picker_key(&mut self, mut state: DatePickerState, key: KeyEvent) {
let shift = |input: &str, days: i64| -> Option<String> {
Date::parse(input.trim())
.map(|d| Date::from_days_since_epoch(d.days_since_epoch() + days).to_string())
};
match key.code {
KeyCode::Char(c) if c.is_ascii_digit() || c == '-' => state.input.push(c),
KeyCode::Backspace => {
state.input.pop();
}
KeyCode::Up => {
if let Some(next) = shift(&state.input, 1) {
state.input = next;
}
}
KeyCode::Down => {
if let Some(next) = shift(&state.input, -1) {
state.input = next;
}
}
KeyCode::PageUp => {
if let Some(next) = shift(&state.input, 30) {
state.input = next;
}
}
KeyCode::PageDown => {
if let Some(next) = shift(&state.input, -30) {
state.input = next;
}
}
KeyCode::Enter => {
match (&state.id, Date::parse(state.input.trim())) {
(Some(id), Some(date)) => {
self.loading = LoadPhase::Applying;
self.send(Command::SetStaleAfter(id.clone(), date));
}
(None, Some(date)) => {
self.loading = LoadPhase::Reloading;
self.send(Command::SetToday(Some(date)));
self.toast(format!("today pinned to {date}"), false);
}
(None, None) if state.input.trim().is_empty() => {
self.loading = LoadPhase::Reloading;
self.send(Command::SetToday(None));
self.toast("today unpinned (wall clock)", false);
}
_ => {
self.toast("not a YYYY-MM-DD date", true);
self.overlays.push(Overlay::DatePicker(state));
}
}
return;
}
_ => {}
}
self.overlays.push(Overlay::DatePicker(state));
}
#[allow(clippy::too_many_lines)]
fn refactor_key(&mut self, mut state: RefactorState, key: KeyEvent) {
let awaiting_decision = matches!(
state.preview,
Some(Err(RefactorError::HasInboundLinks { .. }))
) || state.choice != RemoveChoice::Plain;
let target_exists = matches!(
state.preview,
Some(Err(RefactorError::ConceptAlreadyExists(_)))
);
match key.code {
KeyCode::Enter => {
if matches!(state.preview, Some(Ok(_)))
&& let Some(op) = build_op(&state)
{
self.loading = LoadPhase::Applying;
self.send(Command::Apply(op));
return;
}
state.needs_preview = true;
}
KeyCode::Tab | KeyCode::BackTab => {
let fields = match state.verb {
VerbKind::Split => 2,
VerbKind::Remove if state.choice == RemoveChoice::Redirect => 2,
_ => 1,
};
state.field = (state.field + 1) % fields;
}
KeyCode::Char('r') if state.verb == VerbKind::Remove && awaiting_decision => {
state.choice = RemoveChoice::Redirect;
state.field = 1;
state.needs_preview = true;
}
KeyCode::Char('u') if state.verb == VerbKind::Remove && awaiting_decision => {
state.choice = RemoveChoice::Unlink;
state.needs_preview = true;
}
KeyCode::Char('f') if state.verb == VerbKind::Remove && awaiting_decision => {
state.choice = RemoveChoice::Force;
state.needs_preview = true;
}
KeyCode::Char('o')
if matches!(state.verb, VerbKind::Move | VerbKind::Split) && target_exists =>
{
state.force = true;
state.needs_preview = true;
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let field = if state.field == 0 {
&mut state.input
} else {
&mut state.extra
};
field.push(c);
state.force = false;
state.needs_preview = true;
}
KeyCode::Backspace => {
let field = if state.field == 0 {
&mut state.input
} else {
&mut state.extra
};
field.pop();
state.force = false;
state.needs_preview = true;
}
_ => {}
}
self.overlays.push(Overlay::Refactor(Box::new(state)));
}
fn new_concept_key(&mut self, mut state: NewConceptState, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => state.field = (state.field + 1) % 3,
KeyCode::BackTab | KeyCode::Up => state.field = (state.field + 2) % 3,
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
state.fields[state.field].push(c);
}
KeyCode::Backspace => {
state.fields[state.field].pop();
}
KeyCode::Enter => {
let path = state.fields[0].trim().to_string();
if path.is_empty() || path.ends_with('/') {
self.toast("enter a concept path", true);
self.overlays.push(Overlay::NewConcept(state));
return;
}
let type_ = if state.fields[1].trim().is_empty() {
"Concept".to_string()
} else {
state.fields[1].trim().to_string()
};
let title = if state.fields[2].trim().is_empty() {
None
} else {
Some(state.fields[2].trim().to_string())
};
self.loading = LoadPhase::Applying;
self.send(Command::CreateConcept {
rel_path: path,
type_,
title,
});
return;
}
_ => {}
}
self.overlays.push(Overlay::NewConcept(state));
}
fn fix_preview_key(&mut self, mut state: FixPreviewState, key: KeyEvent) {
let changed_count = state.report.files.iter().filter(|f| f.changed).count();
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.scroll = state.scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
KeyCode::PageUp => state.scroll = state.scroll.saturating_sub(20),
KeyCode::PageDown => state.scroll += 20,
KeyCode::Left | KeyCode::BackTab => {
state.file_sel = state.file_sel.saturating_sub(1);
state.scroll = 0;
}
KeyCode::Right | KeyCode::Tab => {
state.file_sel = (state.file_sel + 1).min(changed_count.saturating_sub(1));
state.scroll = 0;
}
KeyCode::Enter => {
self.loading = LoadPhase::Applying;
self.send(Command::ApplyFix);
return;
}
_ => {}
}
self.overlays.push(Overlay::FixPreview(state));
}
fn outline_key(&mut self, mut state: OutlineState, key: KeyEvent) {
let headings: Vec<(usize, String)> = self
.snapshot
.as_ref()
.and_then(|s| s.meta(&state.id))
.map(|m| m.headings.clone())
.unwrap_or_default();
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.sel = state.sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
state.sel = (state.sel + 1).min(headings.len().saturating_sub(1));
}
KeyCode::Enter => {
if let Some((_, text)) = headings.get(state.sel) {
self.scroll_to_heading(&state.id, text);
}
return;
}
KeyCode::Char('s') => {
if let Some((_, text)) = headings.get(state.sel) {
let id = state.id.clone();
self.open_refactor(VerbKind::Split, id, Some(text.clone()));
}
return;
}
KeyCode::Char('r') | KeyCode::F(2) => {
if let Some((_, text)) = headings.get(state.sel) {
let id = state.id.clone();
self.open_refactor(VerbKind::RenameSection, id, Some(text.clone()));
}
return;
}
_ => {}
}
self.overlays.push(Overlay::Outline(state));
}
fn scroll_to_heading(&mut self, id: &ConceptId, heading: &str) {
let Some(snapshot) = &self.snapshot else {
return;
};
let Some(concept) = snapshot.bundle.get(id) else {
return;
};
let rendered =
crate::markdown::render_document(&concept.document.body, 80, &self.theme, None);
if let Some(pos) = rendered.headings.iter().find(|h| h.text == heading) {
self.explorer.pane = ExplorerPane::Viewer;
self.explorer.scroll = pos.line;
}
}
}