diff --git a/rust/leanspec-cli/src/commands/tui/app.rs b/rust/leanspec-cli/src/commands/tui/app.rs index 2a610a27..1e8e8d4f 100644 --- a/rust/leanspec-cli/src/commands/tui/app.rs +++ b/rust/leanspec-cli/src/commands/tui/app.rs @@ -1,10 +1,15 @@ //! App struct, state machine, and data management for the TUI. - -use leanspec_core::{ - search_specs, DependencyGraph, SpecInfo, SpecLoader, SpecPriority, SpecStats, SpecStatus, -}; +//! +//! All persistent application state — the active adapter handle, the resolved +//! schema, the loaded `SpecDoc`s, navigation cursors, and overlay state — +//! lives here. Status / priority / filter / sort dispatch is schema-driven +//! (see `FilterState`, `SortOption`, `BoardGroup`) so the TUI runs uniformly +//! against any adapter, not just markdown. + +use leanspec_core::adapters::{Adapter, ListFilter}; +use leanspec_core::model::{semantic, FieldKind, FieldValue, SpecDoc, SpecSchema}; use ratatui::layout::Rect; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::error::Error; /// Per-project UI preferences persisted across sessions. @@ -31,100 +36,188 @@ pub enum AppMode { } /// Sort order for the spec list. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// +/// `FieldDesc(key)` sorts by an enum field's declared option order in the +/// schema (e.g. priority). Documents whose value is missing or unknown sort +/// last. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum SortOption { #[default] IdDesc, IdAsc, - PriorityDesc, + FieldDesc(String), TitleAsc, UpdatedDesc, } impl SortOption { - pub fn label(self) -> &'static str { + pub fn label(&self) -> String { match self { - SortOption::IdDesc => "ID ↓", - SortOption::IdAsc => "ID ↑", - SortOption::PriorityDesc => "Priority ↓", - SortOption::TitleAsc => "Title A-Z", - SortOption::UpdatedDesc => "Updated ↓", + SortOption::IdDesc => "ID ↓".into(), + SortOption::IdAsc => "ID ↑".into(), + SortOption::FieldDesc(key) => format!("{} ↓", capitalize(key)), + SortOption::TitleAsc => "Title A-Z".into(), + SortOption::UpdatedDesc => "Updated ↓".into(), } } - pub fn next(self) -> SortOption { + /// Cycle order: Id↓ → Id↑ → (priority↓ if schema has one) → Title → Updated → … + pub fn next(&self, schema: &SpecSchema) -> SortOption { + let priority_key = schema.key_for_semantic(semantic::PRIORITY); match self { SortOption::IdDesc => SortOption::IdAsc, - SortOption::IdAsc => SortOption::PriorityDesc, - SortOption::PriorityDesc => SortOption::TitleAsc, + SortOption::IdAsc => match priority_key { + Some(k) => SortOption::FieldDesc(k.to_string()), + None => SortOption::TitleAsc, + }, + SortOption::FieldDesc(_) => SortOption::TitleAsc, SortOption::TitleAsc => SortOption::UpdatedDesc, SortOption::UpdatedDesc => SortOption::IdDesc, } } } +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } +} + /// Active filter state for the spec list. -#[derive(Debug, Clone)] +/// +/// `fields` maps field key → list of currently *selected* enum values for +/// that field. The default state (built by [`FilterState::from_schema`]) has +/// every enum field pre-populated with all of its declared values — so the +/// filter popup renders every row as `[x]` and `matches()` lets every doc +/// pass. Toggling a value off narrows the visible set. +#[derive(Debug, Clone, Default)] pub struct FilterState { - pub statuses: Vec, - pub priorities: Vec, - pub tags: Vec, + pub fields: HashMap>, pub hide_archived: bool, } -impl Default for FilterState { - fn default() -> Self { +impl FilterState { + /// Build a filter pre-populated with every enum value declared by the + /// schema. Matches the design in spec #269: "Default: all values selected." + pub fn from_schema(schema: &SpecSchema) -> Self { + let mut fields: HashMap> = HashMap::new(); + for field in &schema.fields { + if let FieldKind::Enum { options, .. } = &field.kind { + if options.is_empty() { + continue; + } + let values: Vec = options.iter().map(|o| o.value.clone()).collect(); + fields.insert(field.key.clone(), values); + } + } Self { - statuses: Vec::new(), - priorities: Vec::new(), - tags: Vec::new(), + fields, hide_archived: true, } } -} -impl FilterState { - pub fn is_empty(&self) -> bool { - self.statuses.is_empty() && self.priorities.is_empty() && self.tags.is_empty() + /// True when the filter is at its schema-derived default (every enum + /// field has all its values selected) — i.e. no user-applied narrowing. + /// `hide_archived` is a separate UX toggle and not counted here. + pub fn is_empty(&self, schema: &SpecSchema) -> bool { + for field in &schema.fields { + if let FieldKind::Enum { options, .. } = &field.kind { + if options.is_empty() { + continue; + } + let selected = match self.fields.get(&field.key) { + Some(s) => s, + None => return false, + }; + if selected.len() != options.len() { + return false; + } + } + } + true } - pub fn matches(&self, spec: &SpecInfo) -> bool { - if self.hide_archived && spec.frontmatter.status == SpecStatus::Archived { - return false; + /// Toggle a value in the filter for `key`. Adds it if absent, removes + /// it if present. + pub fn toggle(&mut self, key: &str, value: &str) { + let entry = self.fields.entry(key.to_string()).or_default(); + if let Some(pos) = entry.iter().position(|v| v == value) { + entry.remove(pos); + } else { + entry.push(value.to_string()); } - if !self.statuses.is_empty() && !self.statuses.contains(&spec.frontmatter.status) { - return false; + } + + pub fn is_selected(&self, key: &str, value: &str) -> bool { + self.fields + .get(key) + .map(|v| v.iter().any(|s| s == value)) + .unwrap_or(false) + } + + pub fn matches(&self, doc: &SpecDoc, status_key: Option<&str>) -> bool { + if self.hide_archived { + if let Some(sk) = status_key { + if doc.field_str(sk) == Some("archived") { + return false; + } + } } - if !self.priorities.is_empty() { - match spec.frontmatter.priority { - Some(ref p) if !self.priorities.contains(p) => return false, - None => return false, + for (key, selected) in &self.fields { + // An empty selection means "user has deselected every value for + // this field" — nothing should pass for it. + if selected.is_empty() { + if doc.fields.contains_key(key) { + return false; + } + continue; + } + match doc.fields.get(key) { + Some(FieldValue::String(s)) if !selected.iter().any(|v| v == s) => { + return false; + } + Some(FieldValue::Strings(values)) + if !values.iter().any(|v| selected.contains(v)) => + { + return false; + } + // Doc has no value for this field — pass; the user can't + // express "absent" in the popup, so it's never a hard filter. _ => {} } } - if !self.tags.is_empty() && !self.tags.iter().any(|t| spec.frontmatter.tags.contains(t)) { - return false; - } true } } -/// Ordered statuses shown in the filter popup. -pub const FILTER_STATUSES: &[SpecStatus] = &[ - SpecStatus::InProgress, - SpecStatus::Planned, - SpecStatus::Draft, - SpecStatus::Complete, - SpecStatus::Archived, -]; +/// Enumerated values declared by the schema for `field_key`, in declaration +/// order. Returns empty when the field isn't an enum. +pub fn filter_values_for_field(key: &str, schema: &SpecSchema) -> Vec { + schema + .field(key) + .map(|f| match &f.kind { + FieldKind::Enum { options, .. } => options.iter().map(|o| o.value.clone()).collect(), + _ => Vec::new(), + }) + .unwrap_or_default() +} -/// Ordered priorities shown in the filter popup. -pub const FILTER_PRIORITIES: &[SpecPriority] = &[ - SpecPriority::Critical, - SpecPriority::High, - SpecPriority::Medium, - SpecPriority::Low, -]; +/// Human-readable label for `value` in `field_key`, falling back to the raw +/// value when the schema declares no label. +pub fn field_label<'a>(value: &'a str, field_key: &str, schema: &'a SpecSchema) -> String { + schema + .field(field_key) + .and_then(|f| match &f.kind { + FieldKind::Enum { options, .. } => options + .iter() + .find(|o| o.value == value) + .map(|o| o.label.clone()), + _ => None, + }) + .unwrap_or_else(|| value.to_string()) +} /// One row in the tree view of the list pane. #[derive(Debug, Clone)] @@ -156,10 +249,12 @@ pub enum DetailMode { Dependencies, } -/// A group of specs sharing the same status, used for the board view. +/// A group of specs sharing the same value of the grouping field, used for +/// the board view. `value` is the raw enum value (e.g. `"in-progress"`); +/// `label` is the human-readable form from the schema. #[derive(Debug, Clone)] pub struct BoardGroup { - pub status: SpecStatus, + pub value: String, pub label: String, pub indices: Vec, pub collapsed: bool, @@ -168,15 +263,10 @@ pub struct BoardGroup { /// State for the project switcher popup. #[derive(Debug, Clone, Default)] pub struct ProjectSwitcherState { - /// All projects loaded from registry, sorted favorites-first then last_accessed. pub projects: Vec, - /// Currently highlighted row. pub selected: usize, - /// Inline search query (activated by `/`). pub search: String, - /// Indices into `projects` that match the current search. pub filtered: Vec, - /// Whether the search input is active. pub searching: bool, } @@ -207,7 +297,6 @@ impl ProjectSwitcherState { .map(|(i, _)| i) .collect(); } - // Clamp selection if !self.filtered.is_empty() && self.selected >= self.filtered.len() { self.selected = self.filtered.len() - 1; } @@ -220,7 +309,6 @@ impl ProjectSwitcherState { } } -/// Preset project colors: (name, hex_value). pub const PRESET_COLORS: &[(&str, &str)] = &[ ("none", ""), ("blue", "#4080ff"), @@ -234,41 +322,31 @@ pub const PRESET_COLORS: &[(&str, &str)] = &[ ("gray", "#808090"), ]; -/// Action requested from the project management view. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProjectMgmtAction { None, - /// Inline rename — holds the project id and current edit buffer. Renaming { id: String, buffer: String, }, - /// Delete confirmation — holds project id. ConfirmDelete { id: String, }, - /// Add project — holds path input buffer. AddingProject { buffer: String, message: Option, }, - /// Color picker — holds project id and current color index. ChangingColor { id: String, color_idx: usize, }, } -/// State for the project management view. #[derive(Debug, Clone)] pub struct ProjectMgmtState { - /// Projects loaded from registry. pub projects: Vec, - /// Selected row. pub selected: usize, - /// Current interactive action. pub action: ProjectMgmtAction, - /// Status / feedback message. pub message: Option, } @@ -310,17 +388,26 @@ pub struct BoardGroupDebug { } /// Core application state. +/// +/// Holds the active adapter, the resolved schema, and all loaded `SpecDoc`s, +/// plus all derived caches (filtered indices, board groups, tree rows). The +/// derived state is rebuilt by `apply_filter_and_sort` after any change. pub struct App { // Data - pub specs: Vec, + pub adapter: Box, + pub schema: SpecSchema, + pub status_key: Option, + pub priority_key: Option, + + pub specs: Vec, pub filtered_specs: Vec, - pub selected_detail: Option, + pub selected_detail: Option, + pub selected_body: String, pub board_groups: Vec, - pub dep_graph: DependencyGraph, - pub stats: SpecStats, - // Loader for lazy detail loading - loader: SpecLoader, + /// Dependency graph derived from `doc.links` of type `depends_on`, + /// mapping doc id → list of dependency ids and reverse map. + pub deps: DepsIndex, // State machine pub mode: AppMode, @@ -339,7 +426,6 @@ pub struct App { // Detail scroll pub detail_scroll: u16, - /// Upper bound for detail_scroll estimated from content line count. pub detail_content_lines: u16, // Search @@ -358,7 +444,8 @@ pub struct App { // Sort & filter pub sort_option: SortOption, pub filter: FilterState, - /// Cursor position in the filter popup (0..FILTER_STATUSES.len() = status, then priorities). + /// Cursor offset into the flattened (status values + priority values) list + /// rendered by the filter popup. pub filter_cursor: usize, // Tree view @@ -367,24 +454,86 @@ pub struct App { pub tree_rows: Vec, // TOC overlay - /// Headings extracted from the currently displayed spec: (line_idx, level, text) pub detail_toc: Vec<(usize, u8, String)>, - /// Cursor position in the TOC overlay. pub toc_selected: usize, // Project management - /// The currently active project (if loaded from registry). pub current_project: Option, - /// Project switcher popup state. pub project_switcher: Option, - /// Project management view state. pub project_mgmt: Option, +} - // File watch / auto-reload - /// When the last auto-reload from file watch occurred (used for debounce). - pub last_reload: Option, - /// If Some, display the [↺] indicator until this instant. - pub reload_flash_until: Option, +/// Dependency lookups derived from `doc.links` (link_type = `depends_on`). +#[derive(Debug, Default, Clone)] +pub struct DepsIndex { + /// id → list of ids it depends on. + pub depends_on: HashMap>, + /// id → list of ids that depend on it. + pub required_by: HashMap>, +} + +impl DepsIndex { + pub fn build(docs: &[SpecDoc]) -> Self { + let mut depends_on: HashMap> = HashMap::new(); + let mut required_by: HashMap> = HashMap::new(); + for doc in docs { + let deps: Vec = doc + .links + .iter() + .filter(|l| l.link_type == "depends_on") + .map(|l| l.target_id.clone()) + .collect(); + for dep_id in &deps { + required_by + .entry(dep_id.clone()) + .or_default() + .push(doc.id.clone()); + } + if !deps.is_empty() { + depends_on.insert(doc.id.clone(), deps); + } + } + Self { + depends_on, + required_by, + } + } + + pub fn dep_count(&self, id: &str) -> usize { + self.depends_on.get(id).map(|v| v.len()).unwrap_or(0) + } + + pub fn req_count(&self, id: &str) -> usize { + self.required_by.get(id).map(|v| v.len()).unwrap_or(0) + } +} + +/// Resolve the adapter for a project in the registry. +/// +/// Inspects the project's root directory for an `leanspec.adapter.yaml` / +/// `.lean-spec/adapter.yaml` (or the legacy `provider.yaml` variants) and +/// instantiates the configured adapter. Projects without such a file fall +/// back to the markdown adapter pointed at `project.specs_dir`. +pub fn resolve_adapter_for_project( + project: &leanspec_core::storage::Project, +) -> Result, Box> { + use leanspec_core::adapters::markdown::MarkdownAdapter; + use leanspec_core::adapters::AdapterRegistry; + + let candidates = [ + "leanspec.adapter.yaml", + ".lean-spec/adapter.yaml", + "leanspec.provider.yaml", + ".lean-spec/provider.yaml", + ]; + for candidate in candidates { + let path = project.path.join(candidate); + if path.exists() { + let config = AdapterRegistry::load_config(&path)?; + return Ok(AdapterRegistry::create(&config)?); + } + } + Ok(Box::new(MarkdownAdapter::new(&project.specs_dir))) } /// Load projects from the registry sorted favorites-first, then by last_accessed desc. @@ -394,7 +543,6 @@ pub fn load_projects_sorted() -> Vec { }; let mut projects: Vec = registry.all().into_iter().cloned().collect(); - // Sort: favorites first, then by last_accessed descending projects.sort_by(|a, b| { b.favorite .cmp(&a.favorite) @@ -403,35 +551,83 @@ pub fn load_projects_sorted() -> Vec { projects } -fn priority_sort_key(p: Option) -> u8 { - match p { - Some(SpecPriority::Critical) => 0, - Some(SpecPriority::High) => 1, - Some(SpecPriority::Medium) => 2, - Some(SpecPriority::Low) => 3, - None => 4, +/// Sort key for an enum field: lower = sorts first. Unknown values sort last. +fn enum_sort_key(field_key: &str, doc: &SpecDoc, schema: &SpecSchema) -> u32 { + let value = match doc.field_str(field_key) { + Some(v) => v, + None => return u32::MAX, + }; + schema + .field(field_key) + .and_then(|f| match &f.kind { + FieldKind::Enum { options, .. } => options + .iter() + .position(|o| o.value == value) + .map(|i| i as u32), + _ => None, + }) + .unwrap_or(u32::MAX) +} + +/// Ordering key for `IdDesc` / `IdAsc`. For markdown-style IDs like +/// `"007-foo"` we sort by the leading number; for adapters with non-numeric +/// IDs (e.g. Jira `"ABC-123"`) we fall back to a case-insensitive string +/// compare so the order is still sensible. +/// +/// The returned tuple sorts numerics-first (`has_number: false` > `true` +/// because `false < true` puts numeric IDs ahead of non-numeric), then by +/// extracted number for ties, then by the rest of the id. +fn id_order(doc: &SpecDoc) -> (bool, u32, String) { + let id = doc.id.trim_start_matches('#'); + let digits: String = id.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = digits.parse::() { + let rest = id[digits.len()..].to_lowercase(); + (false, n, rest) + } else { + (true, 0, id.to_lowercase()) } } impl App { + /// Construct the app against a resolved adapter. Loads all specs once and + /// derives the initial filter, sort, and board view. pub fn new( - specs_dir: &str, + adapter: Box, initial_view: PrimaryView, initial_project: Option, ) -> Result> { - let loader = SpecLoader::new(specs_dir); - let specs = loader.load_all_metadata()?; - let dep_graph = DependencyGraph::new(&specs); - let stats = SpecStats::compute(&specs); + let mut schema = adapter.schema().clone(); + adapter.resolve_schema(&mut schema)?; + let status_key = schema + .key_for_semantic(semantic::STATUS) + .map(|k| k.to_string()); + let priority_key = schema + .key_for_semantic(semantic::PRIORITY) + .map(|k| k.to_string()); + + // Surface backend errors (auth, network, schema) up front rather than + // showing an empty project — `App::new` already returns `Result`. + let specs: Vec = adapter.list(&ListFilter { + fields: HashMap::new(), + text: None, + include_archived: true, + raw: None, + })?; + let deps = DepsIndex::build(&specs); + + let filter = FilterState::from_schema(&schema); let mut app = Self { filtered_specs: (0..specs.len()).collect(), + adapter, + schema, + status_key, + priority_key, specs, selected_detail: None, + selected_body: String::new(), board_groups: Vec::new(), - dep_graph, - stats, - loader, + deps, mode: AppMode::Normal, primary_view: initial_view, focus: FocusPane::Left, @@ -453,7 +649,7 @@ impl App { last_frame_width: 0, last_frame_height: 0, sort_option: SortOption::default(), - filter: FilterState::default(), + filter, filter_cursor: 0, tree_mode: false, tree_collapsed: HashSet::new(), @@ -463,11 +659,8 @@ impl App { current_project: initial_project, project_switcher: None, project_mgmt: None, - last_reload: None, - reload_flash_until: None, }; - // Load per-project prefs if we have a project if let Some(ref p) = app.current_project.clone() { let prefs = Self::load_prefs(&p.id); app.apply_prefs(&prefs); @@ -479,22 +672,19 @@ impl App { Ok(app) } - /// Open the project switcher popup (loads projects from registry). pub fn open_project_switcher(&mut self) { let projects = load_projects_sorted(); self.project_switcher = Some(ProjectSwitcherState::new(projects)); self.mode = AppMode::ProjectSwitcher; } - /// Open the project management view. pub fn open_project_management(&mut self) { let projects = load_projects_sorted(); self.project_mgmt = Some(ProjectMgmtState::new(projects)); self.mode = AppMode::ProjectManagement; } - /// Open the project management view with the add-project prompt pre-filled. - /// Used on first launch when no projects are registered. + #[allow(dead_code)] pub fn open_first_launch_prompt(&mut self) { let projects = load_projects_sorted(); self.project_mgmt = Some(ProjectMgmtState { @@ -509,7 +699,6 @@ impl App { self.mode = AppMode::ProjectManagement; } - /// Return the path to the per-project prefs file. pub fn prefs_path(project_id: &str) -> Option { let home = std::env::var("HOME").ok()?; let dir = std::path::PathBuf::from(home) @@ -519,7 +708,6 @@ impl App { Some(dir.join(format!("{}.json", project_id))) } - /// Load per-project prefs from disk, returning defaults if not found. pub fn load_prefs(project_id: &str) -> TuiPrefs { let Some(path) = Self::prefs_path(project_id) else { return TuiPrefs::default(); @@ -530,7 +718,6 @@ impl App { .unwrap_or_default() } - /// Save current UI prefs for the current project. pub fn save_prefs(&self) { let Some(ref project) = self.current_project else { return; @@ -538,17 +725,15 @@ impl App { let Some(path) = Self::prefs_path(&project.id) else { return; }; + let sort_str = match &self.sort_option { + SortOption::IdDesc => "id_desc".to_string(), + SortOption::IdAsc => "id_asc".to_string(), + SortOption::FieldDesc(k) => format!("field_desc:{}", k), + SortOption::TitleAsc => "title_asc".to_string(), + SortOption::UpdatedDesc => "updated_desc".to_string(), + }; let prefs = TuiPrefs { - sort_option: Some( - match self.sort_option { - SortOption::IdDesc => "id_desc", - SortOption::IdAsc => "id_asc", - SortOption::PriorityDesc => "priority_desc", - SortOption::TitleAsc => "title_asc", - SortOption::UpdatedDesc => "updated_desc", - } - .to_string(), - ), + sort_option: Some(sort_str), sidebar_width_pct: Some(self.sidebar_width_pct), sidebar_collapsed: Some(self.sidebar_collapsed), hide_archived: Some(self.filter.hide_archived), @@ -558,14 +743,20 @@ impl App { } } - /// Apply loaded prefs to this app state. pub fn apply_prefs(&mut self, prefs: &TuiPrefs) { if let Some(ref sort_str) = prefs.sort_option { self.sort_option = match sort_str.as_str() { "id_asc" => SortOption::IdAsc, - "priority_desc" => SortOption::PriorityDesc, "title_asc" => SortOption::TitleAsc, "updated_desc" => SortOption::UpdatedDesc, + s if s.starts_with("field_desc:") => { + SortOption::FieldDesc(s.trim_start_matches("field_desc:").to_string()) + } + // Back-compat for the pre-394 "priority_desc" preset. + "priority_desc" => match &self.priority_key { + Some(k) => SortOption::FieldDesc(k.clone()), + None => SortOption::IdDesc, + }, _ => SortOption::IdDesc, }; } @@ -580,193 +771,117 @@ impl App { } } - /// Close any overlay and return to Normal mode. pub fn close_overlay(&mut self) { self.mode = AppMode::Normal; self.project_switcher = None; - // Keep project_mgmt around so state survives a re-open? No — clear it. self.project_mgmt = None; } - /// Switch to a project: reload specs from its specs_dir, update last_accessed. + /// Switch to a project: resolve its adapter (honouring an + /// `leanspec.adapter.yaml` in the project root, so GitHub / ADO / Jira + /// projects use their configured adapter) and reload state. On error, + /// stash a message on the project management overlay rather than + /// crashing the TUI. pub fn switch_project(&mut self, project: leanspec_core::storage::Project) { - // Save prefs for the current project before switching self.save_prefs(); - - // Update last_accessed in registry. if let Ok(mut registry) = leanspec_core::storage::ProjectRegistry::new() { let _ = registry.touch_last_accessed(&project.id); } - let specs_dir = project.specs_dir.to_string_lossy().into_owned(); - self.current_project = Some(project); + let adapter = match resolve_adapter_for_project(&project) { + Ok(a) => a, + Err(e) => { + if let Some(ref mut mgmt) = self.project_mgmt { + mgmt.message = Some(format!("Failed to open project: {}", e)); + } + return; + } + }; - // Load prefs for the new project + self.current_project = Some(project); if let Some(ref p) = self.current_project.clone() { let prefs = Self::load_prefs(&p.id); self.apply_prefs(&prefs); } - - self.reload_specs(&specs_dir); + if let Err(e) = self.reload_with_adapter(adapter) { + if let Some(ref mut mgmt) = self.project_mgmt { + mgmt.message = Some(format!("Failed to load specs: {}", e)); + } + return; + } self.close_overlay(); } - /// Reload specs from a new directory, resetting navigation state. - pub fn reload_specs(&mut self, specs_dir: &str) { - self.loader = SpecLoader::new(specs_dir); - self.specs = self.loader.load_all_metadata().unwrap_or_default(); - self.dep_graph = DependencyGraph::new(&self.specs); - self.stats = SpecStats::compute(&self.specs); + /// Replace the active adapter and reload all derived state from it. + /// Used both by the project switcher and by any test code that wants to + /// swap the backing store without rebuilding the whole `App`. + pub fn reload_with_adapter(&mut self, adapter: Box) -> Result<(), Box> { + let mut schema = adapter.schema().clone(); + adapter.resolve_schema(&mut schema)?; + let specs: Vec = adapter.list(&ListFilter { + fields: HashMap::new(), + text: None, + include_archived: true, + raw: None, + })?; + + self.adapter = adapter; + self.schema = schema; + self.status_key = self + .schema + .key_for_semantic(semantic::STATUS) + .map(|k| k.to_string()); + self.priority_key = self + .schema + .key_for_semantic(semantic::PRIORITY) + .map(|k| k.to_string()); + self.specs = specs; + self.deps = DepsIndex::build(&self.specs); self.filtered_specs = (0..self.specs.len()).collect(); self.board_group_idx = 0; self.board_item_idx = 0; self.list_selected = 0; self.selected_detail = None; + self.selected_body.clear(); self.detail_scroll = 0; - self.filter = FilterState::default(); + self.filter = FilterState::from_schema(&self.schema); self.sort_option = SortOption::default(); self.tree_mode = false; self.tree_collapsed = HashSet::new(); self.tree_rows = Vec::new(); self.apply_filter_and_sort(); self.load_selected_detail(); - } - - /// Reload specs from disk in response to a file-system change. - /// Preserves filter, sort, tree mode, board collapse state, and selection. - /// Debounces: no-ops if called within 300ms of the last reload. - pub fn reload_from_watch(&mut self) { - let now = std::time::Instant::now(); - if let Some(last) = self.last_reload { - if now.duration_since(last).as_millis() < 300 { - return; - } - } - self.last_reload = Some(now); - - // Save selection so we can restore it after the reload - let prev_path = self.selected_detail.as_ref().map(|s| s.path.clone()); - - // Reload spec metadata from disk (reuses existing loader / specs_dir) - self.specs = self.loader.load_all_metadata().unwrap_or_default(); - self.dep_graph = DependencyGraph::new(&self.specs); - self.stats = SpecStats::compute(&self.specs); - - // Rebuild filtered/sorted/board/tree views, preserving filter + sort + collapse state - self.apply_filter_and_sort(); - - // Restore selection by path (falls back to clamped index if spec was deleted) - if let Some(ref path) = prev_path { - self.restore_selection_by_path(path); - } - - // Reload detail pane without resetting scroll (user may be mid-read) - self.reload_detail_preserve_scroll(); - - // Flash [↺] indicator for 1 second - self.reload_flash_until = Some(now + std::time::Duration::from_secs(1)); - } - - /// Find the spec at `path` in the current filtered views and update nav indices. - fn restore_selection_by_path(&mut self, path: &str) { - if self.tree_mode { - if let Some(pos) = self - .tree_rows - .iter() - .position(|r| self.specs[r.spec_idx].path == path) - { - self.list_selected = pos; - } - } else if let Some(pos) = self - .filtered_specs - .iter() - .position(|&i| self.specs[i].path == path) - { - self.list_selected = pos; - } - for (gi, group) in self.board_groups.iter().enumerate() { - if let Some(ii) = group - .indices - .iter() - .position(|&i| self.specs[i].path == path) - { - self.board_group_idx = gi; - self.board_item_idx = ii; - return; - } - } - } - - /// Reload the detail pane content, clamping scroll to the new content length. - fn reload_detail_preserve_scroll(&mut self) { - let idx = match self.selected_spec_index() { - Some(i) => i, - None => { - self.selected_detail = None; - self.detail_content_lines = u16::MAX; - self.detail_toc = Vec::new(); - self.detail_scroll = 0; - return; - } - }; - let path = match self.specs.get(idx) { - Some(s) => s.path.clone(), - None => { - self.selected_detail = None; - self.detail_content_lines = u16::MAX; - self.detail_toc = Vec::new(); - self.detail_scroll = 0; - return; - } - }; - if let Ok(Some(full)) = self.loader.load(&path) { - let new_lines = full.content.lines().count() as u16; - self.detail_content_lines = new_lines; - self.detail_toc = Self::extract_headings_inner(&full.content); - self.detail_scroll = self.detail_scroll.min(new_lines.saturating_sub(1)); - self.selected_detail = Some(full); - } else { - self.selected_detail = None; - self.detail_content_lines = u16::MAX; - self.detail_toc = Vec::new(); - self.detail_scroll = 0; - } + Ok(()) } /// Apply current filter + sort to rebuild `filtered_specs`, board groups, and tree rows. pub fn apply_filter_and_sort(&mut self) { - // 1. Filter + let status_key = self.status_key.clone(); self.filtered_specs = (0..self.specs.len()) - .filter(|&i| self.filter.matches(&self.specs[i])) + .filter(|&i| self.filter.matches(&self.specs[i], status_key.as_deref())) .collect(); - // 2. Sort + // Sort let specs = &self.specs; - let sort = self.sort_option; + let schema = &self.schema; + let sort = self.sort_option.clone(); self.filtered_specs.sort_by(|&a, &b| { let sa = &specs[a]; let sb = &specs[b]; - match sort { - SortOption::IdDesc => sb.number().cmp(&sa.number()), - SortOption::IdAsc => sa.number().cmp(&sb.number()), - SortOption::PriorityDesc => priority_sort_key(sb.frontmatter.priority) - .cmp(&priority_sort_key(sa.frontmatter.priority)), - SortOption::TitleAsc => sa.title.to_lowercase().cmp(&sb.title.to_lowercase()), - SortOption::UpdatedDesc => { - let ta = sa.frontmatter.updated_at.or(sa.frontmatter.created_at); - let tb = sb.frontmatter.updated_at.or(sb.frontmatter.created_at); - tb.cmp(&ta) + match &sort { + SortOption::IdDesc => id_order(sb).cmp(&id_order(sa)), + SortOption::IdAsc => id_order(sa).cmp(&id_order(sb)), + SortOption::FieldDesc(key) => { + enum_sort_key(key, sa, schema).cmp(&enum_sort_key(key, sb, schema)) } + SortOption::TitleAsc => sa.title.to_lowercase().cmp(&sb.title.to_lowercase()), + SortOption::UpdatedDesc => sb.updated_at.cmp(&sa.updated_at), } }); - // 3. Rebuild board groups self.rebuild_board_groups_from_filtered(); - - // 4. Rebuild tree rows self.rebuild_tree_rows(); - // 5. Clamp navigation indices let max_group = self.board_groups.len().saturating_sub(1); self.board_group_idx = self.board_group_idx.min(max_group); if let Some(group) = self.board_groups.get(self.board_group_idx) { @@ -780,37 +895,40 @@ impl App { } fn rebuild_board_groups_from_filtered(&mut self) { - // Preserve existing collapsed state by status - let prev_collapsed: std::collections::HashMap = self + let prev_collapsed: HashMap = self .board_groups .iter() - .map(|g| (g.status, g.collapsed)) + .map(|g| (g.value.clone(), g.collapsed)) .collect(); - let statuses = [ - (SpecStatus::InProgress, "In Progress"), - (SpecStatus::Planned, "Planned"), - (SpecStatus::Draft, "Draft"), - (SpecStatus::Complete, "Complete"), - (SpecStatus::Archived, "Archived"), - ]; + let Some(status_key) = self.status_key.clone() else { + // No status field on this adapter — single anonymous group. + self.board_groups = vec![BoardGroup { + value: String::new(), + label: "All".to_string(), + indices: self.filtered_specs.clone(), + collapsed: false, + }]; + return; + }; - self.board_groups = statuses + let values = filter_values_for_field(&status_key, &self.schema); + self.board_groups = values .iter() - .filter_map(|(status, label)| { + .filter_map(|value| { let indices: Vec = self .filtered_specs .iter() - .filter(|&&i| self.specs[i].frontmatter.status == *status) + .filter(|&&i| self.specs[i].field_str(&status_key) == Some(value)) .copied() .collect(); if indices.is_empty() { None } else { - let collapsed = prev_collapsed.get(status).copied().unwrap_or(false); + let collapsed = prev_collapsed.get(value).copied().unwrap_or(false); Some(BoardGroup { - status: *status, - label: label.to_string(), + value: value.clone(), + label: field_label(value, &status_key, &self.schema), indices, collapsed, }) @@ -819,34 +937,32 @@ impl App { .collect(); } - /// Rebuild tree_rows from filtered_specs and parent relationships. pub fn rebuild_tree_rows(&mut self) { - // Build a path → index map for the filtered set - let path_to_idx: std::collections::HashMap<&str, usize> = self + let id_to_idx: HashMap<&str, usize> = self .filtered_specs .iter() - .map(|&i| (self.specs[i].path.as_str(), i)) + .map(|&i| (self.specs[i].id.as_str(), i)) .collect(); - let mut children_map: std::collections::HashMap> = - std::collections::HashMap::new(); + let mut children_map: HashMap> = HashMap::new(); let mut root_indices: Vec = Vec::new(); for &i in &self.filtered_specs { - let spec = &self.specs[i]; - if let Some(parent_path) = spec.frontmatter.parent.as_deref() { - if path_to_idx.contains_key(parent_path) { - children_map - .entry(parent_path.to_string()) - .or_default() - .push(i); + let doc = &self.specs[i]; + let parent_id = doc + .links + .iter() + .find(|l| l.link_type == "parent") + .map(|l| l.target_id.as_str()); + if let Some(pid) = parent_id { + if id_to_idx.contains_key(pid) { + children_map.entry(pid.to_string()).or_default().push(i); continue; } } root_indices.push(i); } - // DFS traversal to build tree_rows let mut rows: Vec = Vec::new(); for root_idx in root_indices { Self::dfs_tree( @@ -864,17 +980,17 @@ impl App { fn dfs_tree( spec_idx: usize, depth: usize, - specs: &[SpecInfo], - children_map: &std::collections::HashMap>, + specs: &[SpecDoc], + children_map: &HashMap>, collapsed: &HashSet, rows: &mut Vec, ) { - let path = &specs[spec_idx].path; + let id = &specs[spec_idx].id; let children = children_map - .get(path) + .get(id) .map_or(&[] as &[usize], |v| v.as_slice()); let has_children = !children.is_empty(); - let is_collapsed = collapsed.contains(path); + let is_collapsed = collapsed.contains(id); rows.push(TreeRow { spec_idx, @@ -890,7 +1006,6 @@ impl App { } } - /// Length of the visible list (flat or tree). pub fn visible_list_len(&self) -> usize { if self.tree_mode { self.tree_rows.len() @@ -899,7 +1014,6 @@ impl App { } } - /// Update list_scroll_offset to keep list_selected within SCROLLOFF=3 rows of the viewport edges. pub fn update_list_scroll(&mut self) { const SCROLLOFF: usize = 3; let total = self.visible_list_len(); @@ -907,19 +1021,14 @@ impl App { self.list_scroll_offset = 0; return; } - // Estimate visible rows from frame height: frame - statusbar - borders - headers let visible_rows = (self.last_frame_height as usize).saturating_sub(6).max(1); - - // Cursor too close to top: scroll up if self.list_selected < self.list_scroll_offset + SCROLLOFF { self.list_scroll_offset = self.list_selected.saturating_sub(SCROLLOFF); } - // Cursor too close to bottom: scroll down if self.list_selected + SCROLLOFF + 1 > self.list_scroll_offset + visible_rows { self.list_scroll_offset = (self.list_selected + SCROLLOFF + 1).saturating_sub(visible_rows); } - // Clamp offset to valid range let max_offset = total.saturating_sub(visible_rows); self.list_scroll_offset = self.list_scroll_offset.min(max_offset); } @@ -927,7 +1036,7 @@ impl App { // -- Sort & filter -- pub fn cycle_sort(&mut self) { - self.sort_option = self.sort_option.next(); + self.sort_option = self.sort_option.next(&self.schema); self.apply_filter_and_sort(); self.load_selected_detail(); } @@ -943,43 +1052,46 @@ impl App { } pub fn clear_filters(&mut self) { - self.filter = FilterState::default(); + let hide_archived = self.filter.hide_archived; + self.filter = FilterState::from_schema(&self.schema); + self.filter.hide_archived = hide_archived; self.apply_filter_and_sort(); self.load_selected_detail(); } - /// Move cursor down in the filter popup. + /// Flattened list of filterable values shown by the filter popup — + /// status values first, then priority values. Used by both the renderer + /// and the keybinding handler to keep cursor math in sync. + pub fn filter_entries(&self) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(ref k) = self.status_key { + for v in filter_values_for_field(k, &self.schema) { + out.push((k.clone(), v)); + } + } + if let Some(ref k) = self.priority_key { + for v in filter_values_for_field(k, &self.schema) { + out.push((k.clone(), v)); + } + } + out + } + pub fn filter_cursor_down(&mut self) { - let total = FILTER_STATUSES.len() + FILTER_PRIORITIES.len(); + let total = self.filter_entries().len(); if self.filter_cursor + 1 < total { self.filter_cursor += 1; } } - /// Move cursor up in the filter popup. pub fn filter_cursor_up(&mut self) { self.filter_cursor = self.filter_cursor.saturating_sub(1); } - /// Toggle the item at the current filter cursor position. pub fn filter_toggle_current(&mut self) { - let n_statuses = FILTER_STATUSES.len(); - if self.filter_cursor < n_statuses { - let status = FILTER_STATUSES[self.filter_cursor]; - if let Some(pos) = self.filter.statuses.iter().position(|&s| s == status) { - self.filter.statuses.remove(pos); - } else { - self.filter.statuses.push(status); - } - } else { - let pri_idx = self.filter_cursor - n_statuses; - if let Some(&priority) = FILTER_PRIORITIES.get(pri_idx) { - if let Some(pos) = self.filter.priorities.iter().position(|&p| p == priority) { - self.filter.priorities.remove(pos); - } else { - self.filter.priorities.push(priority); - } - } + let entries = self.filter_entries(); + if let Some((key, value)) = entries.get(self.filter_cursor) { + self.filter.toggle(key, value); } } @@ -992,14 +1104,19 @@ impl App { } pub fn collapse_all(&mut self) { - // Collect paths of all specs that have children in the filtered set - let children_parents: HashSet = self + let parent_ids: HashSet = self .filtered_specs .iter() - .filter_map(|&i| self.specs[i].frontmatter.parent.clone()) + .filter_map(|&i| { + self.specs[i] + .links + .iter() + .find(|l| l.link_type == "parent") + .map(|l| l.target_id.clone()) + }) .collect(); - for path in children_parents { - self.tree_collapsed.insert(path); + for id in parent_ids { + self.tree_collapsed.insert(id); } self.rebuild_tree_rows(); self.list_selected = self @@ -1020,11 +1137,11 @@ impl App { if !row.has_children { return; } - let path = self.specs[row.spec_idx].path.clone(); - if self.tree_collapsed.contains(&path) { - self.tree_collapsed.remove(&path); + let id = self.specs[row.spec_idx].id.clone(); + if self.tree_collapsed.contains(&id) { + self.tree_collapsed.remove(&id); } else { - self.tree_collapsed.insert(path); + self.tree_collapsed.insert(id); } self.rebuild_tree_rows(); self.list_selected = self @@ -1033,7 +1150,6 @@ impl App { } } - /// Get the currently selected spec index based on the active view. pub fn selected_spec_index(&self) -> Option { match self.primary_view { PrimaryView::Board => { @@ -1053,35 +1169,35 @@ impl App { } } - /// Lazily load the full content of the currently selected spec. + /// Load the full document (with body) for the current selection. Markdown + /// stores body in the `content` field; for other adapters the body falls + /// back to whatever section fields are present. pub fn load_selected_detail(&mut self) { if let Some(idx) = self.selected_spec_index() { - let Some(spec) = self.specs.get(idx) else { - self.selected_detail = None; - self.detail_toc = Vec::new(); + let Some(doc) = self.specs.get(idx).cloned() else { + self.clear_detail(); return; }; - let path = &spec.path; - if let Ok(Some(full)) = self.loader.load(path) { - self.detail_content_lines = full.content.lines().count() as u16; - self.detail_toc = Self::extract_headings_inner(&full.content); - self.selected_detail = Some(full); - } else { - self.selected_detail = None; - self.detail_content_lines = u16::MAX; - self.detail_toc = Vec::new(); - } + let full = self.adapter.get(&doc.id).unwrap_or(doc); + let body = body_content(&full, &self.schema); + self.detail_content_lines = body.lines().count() as u16; + self.detail_toc = Self::extract_headings_inner(&body); + self.selected_detail = Some(full); + self.selected_body = body; } else { - self.selected_detail = None; - self.detail_content_lines = u16::MAX; - self.detail_toc = Vec::new(); + self.clear_detail(); } self.detail_scroll = 0; self.toc_selected = 0; } - /// Extract ## and ### headings from markdown content. - /// Returns (line_index, level, heading_text). + fn clear_detail(&mut self) { + self.selected_detail = None; + self.selected_body.clear(); + self.detail_content_lines = u16::MAX; + self.detail_toc = Vec::new(); + } + fn extract_headings_inner(content: &str) -> Vec<(usize, u8, String)> { let mut headings: Vec<(usize, u8, String)> = Vec::new(); let mut line_idx: usize = 0; @@ -1090,10 +1206,8 @@ impl App { for raw_line in content.lines() { let trimmed = raw_line.trim_end(); - if trimmed.starts_with("```") { if in_code_block { - // End of code block — count its rendered lines line_idx += code_len + 2; in_code_block = false; code_len = 0; @@ -1102,21 +1216,17 @@ impl App { } continue; } - if in_code_block { code_len += 1; continue; } - if let Some(rest) = trimmed.strip_prefix("## ") { headings.push((line_idx, 2, rest.to_string())); } else if let Some(rest) = trimmed.strip_prefix("### ") { headings.push((line_idx, 3, rest.to_string())); } - line_idx += 1; } - headings } @@ -1133,7 +1243,6 @@ impl App { if self.board_item_idx + 1 < group_len { self.board_item_idx += 1; } else if self.board_group_idx + 1 < self.board_groups.len() { - // Move to next group self.board_group_idx += 1; self.board_item_idx = 0; } @@ -1155,7 +1264,6 @@ impl App { if self.board_item_idx > 0 { self.board_item_idx -= 1; } else if self.board_group_idx > 0 { - // Move to previous group self.board_group_idx -= 1; let prev_len = self .board_groups @@ -1173,21 +1281,15 @@ impl App { self.load_selected_detail(); } - /// Jump to the first item. pub fn move_first(&mut self) { match self.primary_view { - PrimaryView::Board => { - self.board_item_idx = 0; - } - PrimaryView::List => { - self.list_selected = 0; - } + PrimaryView::Board => self.board_item_idx = 0, + PrimaryView::List => self.list_selected = 0, } self.update_list_scroll(); self.load_selected_detail(); } - /// Jump to the last item. pub fn move_last(&mut self) { match self.primary_view { PrimaryView::Board => { @@ -1203,7 +1305,6 @@ impl App { self.load_selected_detail(); } - /// Move down by page_size rows. pub fn page_down(&mut self, page_size: usize) { match self.primary_view { PrimaryView::Board => { @@ -1221,7 +1322,6 @@ impl App { self.load_selected_detail(); } - /// Move up by page_size rows. pub fn page_up(&mut self, page_size: usize) { match self.primary_view { PrimaryView::Board => { @@ -1255,18 +1355,15 @@ impl App { } } - /// Toggle collapse/expand of the current board group. pub fn toggle_current_board_group(&mut self) { if let Some(group) = self.board_groups.get_mut(self.board_group_idx) { group.collapsed = !group.collapsed; - // If now collapsed, reset item cursor to 0 so it doesn't point past end if group.collapsed { self.board_item_idx = 0; } } } - /// Collapse all board groups. pub fn collapse_all_board_groups(&mut self) { for group in &mut self.board_groups { group.collapsed = true; @@ -1274,7 +1371,6 @@ impl App { self.board_item_idx = 0; } - /// Expand all board groups. pub fn expand_all_board_groups(&mut self) { for group in &mut self.board_groups { group.collapsed = false; @@ -1341,7 +1437,6 @@ impl App { pub fn open_toc(&mut self) { if !self.detail_toc.is_empty() { - // Position cursor at currently visible section self.toc_selected = self.current_toc_section(); self.mode = AppMode::Toc; } @@ -1361,7 +1456,6 @@ impl App { self.toc_selected = self.toc_selected.saturating_sub(1); } - /// Jump to the TOC entry at `toc_selected` and close the overlay. pub fn toc_jump(&mut self) { if let Some(&(line_idx, _, _)) = self.detail_toc.get(self.toc_selected) { self.detail_scroll = line_idx as u16; @@ -1369,8 +1463,6 @@ impl App { self.close_toc(); } - /// Return the index into `detail_toc` of the section currently visible - /// (last heading whose line_idx <= detail_scroll). pub fn current_toc_section(&self) -> usize { let scroll = self.detail_scroll as usize; let mut best = 0; @@ -1396,10 +1488,8 @@ impl App { pub fn search_select(&mut self) { if let Some(&idx) = self.search_results.first() { - // Navigate to the found spec match self.primary_view { PrimaryView::Board => { - // Find the board group and item for this spec index for (gi, group) in self.board_groups.iter().enumerate() { if let Some(ii) = group.indices.iter().position(|&i| i == idx) { self.board_group_idx = gi; @@ -1433,13 +1523,11 @@ impl App { self.sidebar_collapsed = !self.sidebar_collapsed; } - /// Compute the board scroll offset needed to keep the current selection visible. - /// Mirrors the logic in board.rs render but as an App method so click_sidebar can use it. pub fn board_scroll(&self, visible_height: usize) -> usize { let mut selected_row: usize = 0; let mut found = false; 'outer: for (gi, group) in self.board_groups.iter().enumerate() { - selected_row += 1; // group header + selected_row += 1; if gi == self.board_group_idx && group.collapsed { found = true; break 'outer; @@ -1453,7 +1541,7 @@ impl App { selected_row += 1; } } - selected_row += 1; // blank line + selected_row += 1; } if !found || visible_height == 0 { return 0; @@ -1461,7 +1549,6 @@ impl App { selected_row.saturating_sub(visible_height.saturating_sub(1)) } - /// Select a spec in the sidebar by clicking at a terminal row. pub fn click_sidebar(&mut self, row: u16) { self.focus = FocusPane::Left; match self.primary_view { @@ -1477,26 +1564,22 @@ impl App { } } PrimaryView::Board => { - let inner_y = self.layout_left.y + 1; // first row inside border + let inner_y = self.layout_left.y + 1; if row < inner_y { return; } let visible_height = self.layout_left.height.saturating_sub(2) as usize; let scroll = self.board_scroll(visible_height); - // Map clicked terminal row → virtual content line index let content_line = (row - inner_y) as usize + scroll; let mut line: usize = 0; for (gi, group) in self.board_groups.iter().enumerate() { - // Group header line if content_line == line { - // Click on group header → toggle collapse self.board_group_idx = gi; self.toggle_current_board_group(); return; } line += 1; - if !group.collapsed { for (ii, _) in group.indices.iter().enumerate() { if content_line == line { @@ -1508,14 +1591,12 @@ impl App { line += 1; } } - // Blank line between groups line += 1; } } } } - /// Update sidebar width based on a mouse drag to column `col`. pub fn resize_drag_to(&mut self, col: u16) { if self.last_frame_width == 0 { return; @@ -1524,15 +1605,24 @@ impl App { self.sidebar_width_pct = new_pct.clamp(15, 60); } + /// Simple substring match over `id` and `title` — works uniformly against + /// any adapter. Could be upgraded to `adapter.search()` for backends with + /// richer search support. fn update_search_results(&mut self) { if self.search_query.is_empty() { self.search_results.clear(); return; } - let results = search_specs(&self.specs, &self.search_query, 20); - self.search_results = results + let q = self.search_query.to_lowercase(); + self.search_results = self + .specs .iter() - .filter_map(|r| self.specs.iter().position(|s| s.path == r.path)) + .enumerate() + .filter(|(_, doc)| { + doc.id.to_lowercase().contains(&q) || doc.title.to_lowercase().contains(&q) + }) + .map(|(i, _)| i) + .take(20) .collect(); } @@ -1550,14 +1640,14 @@ impl App { } else { self.filtered_specs.len() }, - sort: self.sort_option.label().to_string(), + sort: self.sort_option.label(), search_query: self.search_query.clone(), - selected_path: self.selected_detail.as_ref().map(|s| s.path.clone()), + selected_path: self.selected_detail.as_ref().map(|s| s.id.clone()), board_groups: self .board_groups .iter() .map(|g| BoardGroupDebug { - status: format!("{:?}", g.status), + status: g.value.clone(), count: g.indices.len(), collapsed: g.collapsed, }) @@ -1567,17 +1657,34 @@ impl App { } } - /// Create an empty App for testing (no filesystem access needed). + /// Create an empty App for testing (no adapter I/O needed). + /// + /// Uses an in-memory markdown adapter pointed at a non-existent directory; + /// `list()` returns an empty vec so no files are touched. #[cfg(test)] pub fn empty_for_test() -> Self { + use leanspec_core::adapters::markdown::MarkdownAdapter; + let adapter: Box = Box::new(MarkdownAdapter::new("/nonexistent-tui-test")); + let mut schema = adapter.schema().clone(); + let _ = adapter.resolve_schema(&mut schema); + let status_key = schema + .key_for_semantic(semantic::STATUS) + .map(|k| k.to_string()); + let priority_key = schema + .key_for_semantic(semantic::PRIORITY) + .map(|k| k.to_string()); + let filter = FilterState::from_schema(&schema); App { + adapter, + schema, + status_key, + priority_key, specs: Vec::new(), filtered_specs: Vec::new(), selected_detail: None, + selected_body: String::new(), board_groups: Vec::new(), - dep_graph: DependencyGraph::new(&[]), - stats: SpecStats::compute(&[]), - loader: SpecLoader::new("/nonexistent"), + deps: DepsIndex::default(), mode: AppMode::Normal, primary_view: PrimaryView::List, focus: FocusPane::Left, @@ -1599,7 +1706,7 @@ impl App { last_frame_width: 0, last_frame_height: 0, sort_option: SortOption::default(), - filter: FilterState::default(), + filter, filter_cursor: 0, tree_mode: false, tree_collapsed: HashSet::new(), @@ -1609,12 +1716,31 @@ impl App { current_project: None, project_switcher: None, project_mgmt: None, - last_reload: None, - reload_flash_until: None, } } } +/// Pull the body content out of a doc. Prefers the `content` section field +/// (markdown adapter), falling back to concatenating section fields for +/// remote adapters. +pub fn body_content(doc: &SpecDoc, schema: &SpecSchema) -> String { + if let Some(s) = doc.field_str("content") { + return s.to_string(); + } + use leanspec_core::model::FieldDisplay; + let mut parts: Vec = Vec::new(); + for field in &schema.fields { + if field.display == FieldDisplay::Section { + if let Some(s) = doc.fields.get(&field.key).and_then(|v| v.as_str()) { + if !s.is_empty() { + parts.push(format!("## {}\n\n{}", field.label, s)); + } + } + } + } + parts.join("\n\n") +} + #[cfg(test)] mod tests { use super::*; @@ -1644,16 +1770,13 @@ mod tests { #[test] fn test_view_switching() { let mut app = make_test_app(); - // Default view is now List assert_eq!(app.primary_view, PrimaryView::List); app.set_board_view(); assert_eq!(app.primary_view, PrimaryView::Board); - assert_eq!(app.focus, FocusPane::Left); app.set_list_view(); assert_eq!(app.primary_view, PrimaryView::List); - assert_eq!(app.focus, FocusPane::Left); } #[test] @@ -1681,93 +1804,36 @@ mod tests { } #[test] - fn test_board_navigation_empty() { + fn test_filter_toggle_updates_fields() { let mut app = make_test_app(); - // Should not panic with empty groups - app.move_down(); - app.move_up(); - app.next_group(); - app.prev_group(); - assert_eq!(app.board_group_idx, 0); - assert_eq!(app.board_item_idx, 0); - } - - #[test] - fn test_list_navigation_empty() { - let mut app = make_test_app(); - app.set_list_view(); - // Should not panic with empty list - app.move_down(); - app.move_up(); - assert_eq!(app.list_selected, 0); - } - - #[test] - fn test_board_navigation_wraps_groups() { - let mut app = make_test_app(); - app.primary_view = PrimaryView::Board; - app.board_groups = vec![ - BoardGroup { - status: SpecStatus::InProgress, - label: "In Progress".to_string(), - indices: vec![0], - collapsed: false, - }, - BoardGroup { - status: SpecStatus::Draft, - label: "Draft".to_string(), - indices: vec![1], - collapsed: false, - }, - ]; + // Markdown schema has both status and priority — entries are populated. + let entries = app.filter_entries(); + assert!(!entries.is_empty(), "schema should expose filter entries"); - app.next_group(); - assert_eq!(app.board_group_idx, 1); + // Default state pre-populates every enum value, so the first toggle + // deselects it; the second re-selects. + let (key, value) = entries[0].clone(); + assert!(app.filter.is_selected(&key, &value)); - app.next_group(); - assert_eq!(app.board_group_idx, 0); // wraps + app.filter_cursor = 0; + app.filter_toggle_current(); + assert!(!app.filter.is_selected(&key, &value)); - app.prev_group(); - assert_eq!(app.board_group_idx, 1); // wraps back + app.filter_toggle_current(); + assert!(app.filter.is_selected(&key, &value)); } #[test] - fn test_list_navigation_bounds() { + fn test_sort_cycles_with_priority() { let mut app = make_test_app(); - app.set_list_view(); - app.filtered_specs = vec![0, 1, 2]; - - app.move_down(); - assert_eq!(app.list_selected, 1); - app.move_down(); - assert_eq!(app.list_selected, 2); - app.move_down(); - assert_eq!(app.list_selected, 2); // stays at end - - app.move_up(); - assert_eq!(app.list_selected, 1); - app.move_up(); - assert_eq!(app.list_selected, 0); - app.move_up(); - assert_eq!(app.list_selected, 0); // stays at start - } - - #[test] - fn test_detail_scroll() { - let mut app = make_test_app(); - assert_eq!(app.detail_scroll, 0); + assert_eq!(app.sort_option, SortOption::IdDesc); - app.scroll_detail_down(); - assert_eq!(app.detail_scroll, 1); - app.scroll_detail_down(); - assert_eq!(app.detail_scroll, 2); + app.cycle_sort(); + assert_eq!(app.sort_option, SortOption::IdAsc); - app.scroll_detail_up(); - assert_eq!(app.detail_scroll, 1); - app.scroll_detail_up(); - assert_eq!(app.detail_scroll, 0); - app.scroll_detail_up(); - assert_eq!(app.detail_scroll, 0); // doesn't go negative + app.cycle_sort(); + // Markdown schema declares a priority field, so cycle lands there. + assert!(matches!(app.sort_option, SortOption::FieldDesc(_))); } #[test] @@ -1778,20 +1844,11 @@ mod tests { app.sidebar_widen(); assert_eq!(app.sidebar_width_pct, 35); - app.sidebar_widen(); - app.sidebar_widen(); - app.sidebar_widen(); - app.sidebar_widen(); - app.sidebar_widen(); // would be 60 - assert_eq!(app.sidebar_width_pct, 60); - - app.sidebar_widen(); // capped at 60 + for _ in 0..10 { + app.sidebar_widen(); + } assert_eq!(app.sidebar_width_pct, 60); - app.sidebar_narrow(); - assert_eq!(app.sidebar_width_pct, 55); - - // Narrow to minimum for _ in 0..20 { app.sidebar_narrow(); } @@ -1799,57 +1856,28 @@ mod tests { } #[test] - fn test_sidebar_toggle_collapse() { - let mut app = make_test_app(); - assert!(!app.sidebar_collapsed); - - app.sidebar_toggle_collapse(); - assert!(app.sidebar_collapsed); - - app.sidebar_toggle_collapse(); - assert!(!app.sidebar_collapsed); - } - - #[test] - fn test_resize_drag_to() { - let mut app = make_test_app(); - app.last_frame_width = 100; - - app.resize_drag_to(30); - assert_eq!(app.sidebar_width_pct, 30); - - app.resize_drag_to(5); // below minimum, clamped to 15 - assert_eq!(app.sidebar_width_pct, 15); - - app.resize_drag_to(70); // above maximum, clamped to 60 - assert_eq!(app.sidebar_width_pct, 60); - } - - #[test] - fn test_resize_drag_zero_width_noop() { - let mut app = make_test_app(); - app.last_frame_width = 0; - app.sidebar_width_pct = 30; - app.resize_drag_to(50); - assert_eq!(app.sidebar_width_pct, 30); // unchanged - } + fn test_filter_state_matches_hides_archived() { + use leanspec_core::model::FieldValue; + let mut filter = FilterState { + hide_archived: true, + ..FilterState::default() + }; + let mut doc = SpecDoc { + id: "1".into(), + title: "x".into(), + schema_id: "y".into(), + fields: HashMap::new(), + links: vec![], + created_at: None, + updated_at: None, + url: None, + raw: None, + }; + doc.fields + .insert("status".into(), FieldValue::String("archived".into())); + assert!(!filter.matches(&doc, Some("status"))); - #[test] - fn test_search_char_and_backspace() { - let mut app = make_test_app(); - app.enter_search(); - assert_eq!(app.search_query, ""); - - app.search_type_char('a'); - assert_eq!(app.search_query, "a"); - app.search_type_char('b'); - assert_eq!(app.search_query, "ab"); - - app.search_backspace(); - assert_eq!(app.search_query, "a"); - app.search_backspace(); - assert_eq!(app.search_query, ""); - app.search_backspace(); // no panic on empty - assert_eq!(app.search_query, ""); + filter.hide_archived = false; + assert!(filter.matches(&doc, Some("status"))); } } diff --git a/rust/leanspec-cli/src/commands/tui/board.rs b/rust/leanspec-cli/src/commands/tui/board.rs index d4fcbd4f..96c0fa4f 100644 --- a/rust/leanspec-cli/src/commands/tui/board.rs +++ b/rust/leanspec-cli/src/commands/tui/board.rs @@ -1,4 +1,4 @@ -//! Board view widget — specs grouped by status. +//! Board view widget — specs grouped by the schema's `status` semantic field. use ratatui::{ buffer::Buffer, @@ -9,7 +9,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Widget}, }; -use leanspec_core::SpecInfo; +use leanspec_core::model::SpecDoc; use super::app::{App, FocusPane, PrimaryView}; use super::theme; @@ -22,7 +22,11 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { theme::border_unfocused_style() }; - let filter_indicator = if !app.filter.is_empty() { " [F]" } else { "" }; + let filter_indicator = if !app.filter.is_empty(&app.schema) { + " [F]" + } else { + "" + }; let board_title = format!(" Board [{}]{} ", app.sort_option.label(), filter_indicator); let block = Block::default() @@ -33,11 +37,13 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { block.render(area, buf); let mut lines: Vec = Vec::new(); + let status_key = app.status_key.as_deref().unwrap_or("status"); + let priority_key = app.priority_key.as_deref(); for (gi, group) in app.board_groups.iter().enumerate() { - // Group header with collapse indicator - let header_style = theme::status_style(&group.status).add_modifier(Modifier::BOLD); - let symbol = theme::status_symbol(&group.status); + let header_color = theme::field_style(&group.value, status_key, &app.schema); + let header_style = header_color.add_modifier(Modifier::BOLD); + let symbol = theme::field_symbol(&group.value, status_key, &app.schema); let collapse_indicator = if group.collapsed { "▶" } else { "▼" }; let collapsed_label = if group.collapsed { " [collapsed]" } else { "" }; @@ -63,9 +69,8 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { ])); if !group.collapsed { - // Items in group for (ii, &spec_idx) in group.indices.iter().enumerate() { - let spec = &app.specs[spec_idx]; + let doc = &app.specs[spec_idx]; let is_current = gi == app.board_group_idx && ii == app.board_item_idx; let style = if is_current && is_focused { @@ -76,17 +81,16 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { Style::default() }; - let pri = theme::priority_symbol(spec.frontmatter.priority.as_ref()); - let dep_count = app - .dep_graph - .get_complete_graph(&spec.path) - .map_or(0, |g| g.depends_on.len()); - let line = format_spec_line(pri, spec, dep_count); + let pri_sym = priority_key + .and_then(|k| doc.field_str(k).map(|v| (k, v))) + .map(|(k, v)| theme::field_symbol(v, k, &app.schema).to_string()) + .unwrap_or_else(|| " ".to_string()); + let dep_count = app.deps.dep_count(&doc.id); + let line = format_spec_line(&pri_sym, doc, dep_count); lines.push(Line::styled(line, style)); } } - // Blank line between groups lines.push(Line::from("")); } @@ -97,12 +101,10 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { let total_lines = lines.len(); let viewport_height = inner.height as usize; - // Compute scroll offset to keep the selected item visible let scroll = app.board_scroll(viewport_height); let paragraph = Paragraph::new(lines).scroll((scroll as u16, 0)); paragraph.render(inner, buf); - // Scrollbar — only render if content exceeds viewport if total_lines > viewport_height { let mut scrollbar_state = ScrollbarState::new(total_lines) .position(scroll) @@ -114,19 +116,19 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { } } -fn format_spec_line(priority: &str, spec: &SpecInfo, dep_count: usize) -> String { - let title = if spec.title.chars().count() > 36 { - let truncated: String = spec.title.chars().take(33).collect(); +fn format_spec_line(priority: &str, doc: &SpecDoc, dep_count: usize) -> String { + let title = if doc.title.chars().count() > 36 { + let truncated: String = doc.title.chars().take(33).collect(); format!("{}...", truncated) } else { - spec.title.clone() + doc.title.clone() }; let dep_str = if dep_count > 0 { format!(" deps:{}", dep_count) } else { String::new() }; - format!(" {} {} {}{}", priority, spec.path, title, dep_str) + format!(" {} {} {}{}", priority, doc.id, title, dep_str) } #[cfg(test)] @@ -135,8 +137,6 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::Terminal; - use super::super::app::App; - fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { buf.content().iter().map(|c| c.symbol()).collect() } @@ -145,7 +145,7 @@ mod tests { fn test_board_renders_group_headers() { let mut app = App::empty_for_test(); app.board_groups = vec![super::super::app::BoardGroup { - status: leanspec_core::SpecStatus::Draft, + value: "draft".to_string(), label: "Draft".to_string(), indices: vec![], collapsed: false, @@ -168,7 +168,7 @@ mod tests { fn test_board_collapsed_group_shows_indicator() { let mut app = App::empty_for_test(); app.board_groups = vec![super::super::app::BoardGroup { - status: leanspec_core::SpecStatus::Planned, + value: "planned".to_string(), label: "Planned".to_string(), indices: vec![], collapsed: true, diff --git a/rust/leanspec-cli/src/commands/tui/deps.rs b/rust/leanspec-cli/src/commands/tui/deps.rs index cb3f583c..ff632223 100644 --- a/rust/leanspec-cli/src/commands/tui/deps.rs +++ b/rust/leanspec-cli/src/commands/tui/deps.rs @@ -1,4 +1,4 @@ -//! Dependency tree widget — upstream and downstream deps for selected spec. +//! Dependency tree widget — upstream and downstream deps for the selected spec. use ratatui::{ buffer::Buffer, @@ -18,7 +18,7 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { let inner = block.inner(area); block.render(area, buf); - let Some(spec) = &app.selected_detail else { + let Some(doc) = &app.selected_detail else { let msg = Paragraph::new(" Select a spec to view dependencies").style(theme::dimmed_style()); msg.render(inner, buf); @@ -26,61 +26,71 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { }; let mut lines: Vec = Vec::new(); + let status_key = app.status_key.as_deref(); - // Upstream (depends on) - let upstream = app.dep_graph.get_upstream(&spec.path, 3); + let upstream_ids = app + .deps + .depends_on + .get(&doc.id) + .cloned() + .unwrap_or_default(); lines.push(Line::from(Span::styled( " Upstream (depends on):", theme::header_style(), ))); - if upstream.is_empty() { + if upstream_ids.is_empty() { lines.push(Line::styled(" (none)", theme::dimmed_style())); } else { - for dep in &upstream { - let sym = theme::status_symbol(&dep.frontmatter.status); - let style = theme::status_style(&dep.frontmatter.status); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(sym, style), - Span::raw(format!(" {} - {}", dep.path, dep.title)), - ])); + for dep_id in &upstream_ids { + push_dep_line(&mut lines, dep_id, app, status_key); } } lines.push(Line::from("")); - // Downstream (required by) - let downstream = app.dep_graph.get_downstream(&spec.path, 3); + let downstream_ids = app + .deps + .required_by + .get(&doc.id) + .cloned() + .unwrap_or_default(); lines.push(Line::from(Span::styled( " Downstream (required by):", theme::header_style(), ))); - if downstream.is_empty() { + if downstream_ids.is_empty() { lines.push(Line::styled(" (none)", theme::dimmed_style())); } else { - for dep in &downstream { - let sym = theme::status_symbol(&dep.frontmatter.status); - let style = theme::status_style(&dep.frontmatter.status); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(sym, style), - Span::raw(format!(" {} - {}", dep.path, dep.title)), - ])); - } - } - - // Direct dependencies from frontmatter - if !spec.frontmatter.depends_on.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Direct dependencies (frontmatter):", - theme::header_style(), - ))); - for dep_path in &spec.frontmatter.depends_on { - lines.push(Line::from(format!(" -> {}", dep_path))); + for dep_id in &downstream_ids { + push_dep_line(&mut lines, dep_id, app, status_key); } } let paragraph = Paragraph::new(lines); paragraph.render(inner, buf); } + +fn push_dep_line(lines: &mut Vec, dep_id: &str, app: &App, status_key: Option<&str>) { + let dep_doc = app.specs.iter().find(|d| d.id == dep_id); + let (sym, style, title) = match dep_doc { + Some(d) => { + let (s, st) = status_key + .and_then(|k| { + d.field_str(k).map(|v| { + ( + theme::field_symbol(v, k, &app.schema).to_string(), + theme::field_style(v, k, &app.schema), + ) + }) + }) + .unwrap_or_else(|| (" ".to_string(), theme::dimmed_style())); + (s, st, d.title.clone()) + } + None => (" ".to_string(), theme::dimmed_style(), String::new()), + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(sym, style), + Span::raw(format!(" {} - {}", dep_id, title)), + ])); +} diff --git a/rust/leanspec-cli/src/commands/tui/detail.rs b/rust/leanspec-cli/src/commands/tui/detail.rs index 4b6f25a9..ed6bdfb6 100644 --- a/rust/leanspec-cli/src/commands/tui/detail.rs +++ b/rust/leanspec-cli/src/commands/tui/detail.rs @@ -1,17 +1,21 @@ -//! Detail pane widget — spec metadata header + scrollable content body. +//! Detail pane widget — schema-driven metadata header + scrollable body. +//! +//! The metadata block iterates `schema.fields` and renders each inline field +//! using `theme::field_style` / `theme::field_symbol`. The body is the +//! `selected_body` string loaded from the adapter (markdown stores it in the +//! `content` field; remote adapters synthesise it from section fields). use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, prelude::StatefulWidget, - style::Style, text::{Line, Span}, widgets::{ Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Widget, Wrap, }, }; -use leanspec_core::SpecInfo; +use leanspec_core::model::{FieldDisplay, FieldKind, FieldValue, SpecDoc, SpecSchema}; use super::app::{App, DetailMode, FocusPane}; use super::markdown; @@ -47,95 +51,122 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { } } -fn render_spec_detail(area: Rect, buf: &mut Buffer, spec: &SpecInfo, app: &App) { - // Split into header (fixed) and body (scrollable) - let chunks = Layout::vertical([Constraint::Length(6), Constraint::Min(1)]).split(area); +fn render_spec_detail(area: Rect, buf: &mut Buffer, doc: &SpecDoc, app: &App) { + let metadata_lines = build_metadata_lines(doc, app, area.width); + let header_height = (metadata_lines.len() as u16).min(area.height); - // Metadata header - render_metadata(chunks[0], buf, spec, app); + let chunks = + Layout::vertical([Constraint::Length(header_height), Constraint::Min(1)]).split(area); - // Content body (scrollable) - render_content(chunks[1], buf, spec, app); + Paragraph::new(metadata_lines).render(chunks[0], buf); + render_content(chunks[1], buf, app); } -fn render_metadata(area: Rect, buf: &mut Buffer, spec: &SpecInfo, app: &App) { - let status_style = theme::status_style(&spec.frontmatter.status); - let status_sym = theme::status_symbol(&spec.frontmatter.status); - let status_label = spec.frontmatter.status_label(); - - let priority_sym = theme::priority_symbol(spec.frontmatter.priority.as_ref()); - let priority_str = spec - .frontmatter - .priority - .map(|p| p.to_string()) - .unwrap_or_else(|| "-".to_string()); - - // Dependency counts - let (dep_count, req_count) = app - .dep_graph - .get_complete_graph(&spec.path) - .map(|g| (g.depends_on.len(), g.required_by.len())) - .unwrap_or((0, 0)); +fn build_metadata_lines<'a>(doc: &'a SpecDoc, app: &'a App, width: u16) -> Vec> { + let schema = &app.schema; + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + doc.title.clone(), + theme::title_style(), + )])); + + let dep_count = app.deps.dep_count(&doc.id); + let req_count = app.deps.req_count(&doc.id); let deps_str = if dep_count > 0 || req_count > 0 { format!(" deps:{} req:{}", dep_count, req_count) } else { String::new() }; + lines.push(Line::from(vec![ + Span::styled(format!(" {}", doc.id), theme::dimmed_style()), + Span::styled(deps_str, theme::dimmed_style()), + ])); + + // One inline-field line per schema-declared inline field that has a value. + for field in &schema.fields { + if field.display != FieldDisplay::Inline { + continue; + } + if field.key == "content" { + continue; + } + let Some(value) = doc.fields.get(&field.key) else { + continue; + }; + let line = render_inline_field(field, value, schema); + lines.push(line); + } - // Tags as chips - let tags_str = if spec.frontmatter.tags.is_empty() { - "-".to_string() - } else { - spec.frontmatter - .tags - .iter() - .map(|t| format!("[{}]", t)) - .collect::>() - .join(" ") - }; - - // Dates - let created_str = spec.frontmatter.created.as_str(); - let updated_str = spec.frontmatter.updated.as_deref().unwrap_or("-"); - - let lines = vec![ - Line::from(vec![Span::styled(spec.title.clone(), theme::title_style())]), - Line::from(vec![ - Span::styled(format!(" {}", spec.path), theme::dimmed_style()), - Span::styled(deps_str, theme::dimmed_style()), - ]), - Line::from(vec![ - Span::raw(" Status: "), - Span::styled(format!("{} {}", status_sym, status_label), status_style), - Span::raw(" Priority: "), + if let Some(created) = doc.created_at { + lines.push(Line::from(vec![ + Span::raw(" Created: "), Span::styled( - format!("{} {}", priority_sym, priority_str), - Style::default(), + created.format("%Y-%m-%d").to_string(), + theme::dimmed_style(), ), - ]), - Line::from(vec![ - Span::raw(" Created: "), - Span::styled(created_str.to_string(), theme::dimmed_style()), - Span::raw(" Updated: "), - Span::styled(updated_str.to_string(), theme::dimmed_style()), - ]), - Line::from(vec![ - Span::raw(" Tags: "), - Span::styled(tags_str, theme::dimmed_style()), - ]), - Line::styled( - " ".to_string() + &"─".repeat(area.width.saturating_sub(2) as usize), - theme::dimmed_style(), - ), - ]; - - let paragraph = Paragraph::new(lines); - paragraph.render(area, buf); + ])); + } + if let Some(updated) = doc.updated_at { + lines.push(Line::from(vec![ + Span::raw(" Updated: "), + Span::styled( + updated.format("%Y-%m-%d").to_string(), + theme::dimmed_style(), + ), + ])); + } + + lines.push(Line::styled( + " ".to_string() + &"─".repeat(width.saturating_sub(2) as usize), + theme::dimmed_style(), + )); + lines } -fn render_content(area: Rect, buf: &mut Buffer, spec: &SpecInfo, app: &App) { - let lines = markdown::render_markdown(&spec.content, area.width); +fn render_inline_field<'a>( + field: &'a leanspec_core::model::FieldDef, + value: &'a FieldValue, + schema: &'a SpecSchema, +) -> Line<'a> { + let label_span = Span::raw(format!(" {}: ", field.label)); + let value_spans = match (&field.kind, value) { + (FieldKind::Enum { multi: false, .. }, FieldValue::String(s)) => { + let style = theme::field_style(s, &field.key, schema); + let sym = theme::field_symbol(s, &field.key, schema); + let label = super::app::field_label(s, &field.key, schema); + vec![Span::styled(format!("{} {}", sym, label), style)] + } + (FieldKind::Enum { multi: true, .. }, FieldValue::Strings(values)) => { + if values.is_empty() { + vec![Span::raw("-")] + } else { + values + .iter() + .map(|v| Span::raw(format!("[{}] ", v))) + .collect() + } + } + (_, FieldValue::String(s)) => vec![Span::raw(s.clone())], + (_, FieldValue::Strings(values)) => { + if values.is_empty() { + vec![Span::raw("-")] + } else { + vec![Span::raw(values.join(", "))] + } + } + (_, FieldValue::Bool(b)) => vec![Span::raw(if *b { "true" } else { "false" })], + (_, FieldValue::Number(n)) => vec![Span::raw(n.to_string())], + (_, FieldValue::Timestamp(t)) => vec![Span::raw(t.format("%Y-%m-%d").to_string())], + _ => vec![Span::raw("-")], + }; + let mut spans = vec![label_span]; + spans.extend(value_spans); + Line::from(spans) +} + +fn render_content(area: Rect, buf: &mut Buffer, app: &App) { + let lines = markdown::render_markdown(&app.selected_body, area.width); let total_lines = lines.len(); let viewport_height = area.height as usize; @@ -144,7 +175,6 @@ fn render_content(area: Rect, buf: &mut Buffer, spec: &SpecInfo, app: &App) { .scroll((app.detail_scroll, 0)); paragraph.render(area, buf); - // Scrollbar — only render when content exceeds viewport if total_lines > viewport_height { let mut scrollbar_state = ScrollbarState::new(total_lines) .position(app.detail_scroll as usize) @@ -162,8 +192,6 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::Terminal; - use super::super::app::App; - fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { buf.content().iter().map(|c| c.symbol()).collect() } diff --git a/rust/leanspec-cli/src/commands/tui/filter.rs b/rust/leanspec-cli/src/commands/tui/filter.rs index 565e30e3..88335c49 100644 --- a/rust/leanspec-cli/src/commands/tui/filter.rs +++ b/rust/leanspec-cli/src/commands/tui/filter.rs @@ -1,4 +1,8 @@ -//! Filter popup overlay — status and priority multi-select. +//! Filter popup overlay — schema-driven multi-select over enum fields. +//! +//! Rows come from `App::filter_entries()`: status values first, then +//! priority values (or whatever enum-typed semantic fields the schema +//! exposes). Each row toggles a `(key, value)` pair on `FilterState`. use ratatui::{ buffer::Buffer, @@ -8,19 +12,22 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use super::app::{App, FILTER_PRIORITIES, FILTER_STATUSES}; +use super::app::App; use super::theme; pub fn render(area: Rect, buf: &mut Buffer, app: &App) { + let entries = app.filter_entries(); + let total_rows = entries.len() as u16; + let overlay_width = (area.width * 50 / 100).max(44).min(area.width); - let overlay_height = 16.min(area.height.saturating_sub(4)); + let overlay_height = (total_rows + 6).min(area.height.saturating_sub(4)); let x = (area.width.saturating_sub(overlay_width)) / 2; let y = (area.height.saturating_sub(overlay_height)) / 2; let overlay_area = Rect::new(x, y, overlay_width, overlay_height); Clear.render(overlay_area, buf); - let filter_label = if app.filter.is_empty() { + let filter_label = if app.filter.is_empty(&app.schema) { " Filter (no active filters) " } else { " Filter (active) " @@ -32,64 +39,57 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { let inner = block.inner(overlay_area); block.render(overlay_area, buf); - let chunks = Layout::vertical([ - Constraint::Length(1), // STATUS header - Constraint::Length(FILTER_STATUSES.len() as u16), - Constraint::Length(1), // blank - Constraint::Length(1), // PRIORITY header - Constraint::Length(FILTER_PRIORITIES.len() as u16), - Constraint::Min(1), // hint - ]) - .split(inner); - - // Status section - Paragraph::new(Line::styled(" STATUS", theme::header_style())).render(chunks[0], buf); + // Group entries by field key so we can render section headers between + // them (STATUS / PRIORITY etc). + let mut grouped: Vec<(String, Vec<(usize, String)>)> = Vec::new(); + for (i, (key, value)) in entries.iter().enumerate() { + if grouped.last().map(|(k, _)| k.as_str()) != Some(key.as_str()) { + grouped.push((key.clone(), Vec::new())); + } + grouped.last_mut().unwrap().1.push((i, value.clone())); + } - let mut status_lines: Vec = Vec::new(); - for (i, &status) in FILTER_STATUSES.iter().enumerate() { - let checked = app.filter.statuses.contains(&status); - let cursor = i; - let is_cursor = app.filter_cursor == cursor; - let check = if checked { "[x]" } else { "[ ]" }; - let label = format!("{}", status); - let sym = theme::status_symbol(&status); - let style = if is_cursor { - theme::highlight_style() - } else { - Style::default() - }; - status_lines.push(Line::styled( - format!(" {} {} {} ", check, sym, label), - style, - )); + // Layout: each section header + N rows; then a hint line. + let mut constraints: Vec = Vec::new(); + for (_, rows) in &grouped { + constraints.push(Constraint::Length(1)); // header + constraints.push(Constraint::Length(rows.len() as u16)); + constraints.push(Constraint::Length(1)); // blank } - Paragraph::new(status_lines).render(chunks[1], buf); + constraints.push(Constraint::Min(1)); // hint + let chunks = Layout::vertical(constraints).split(inner); - // Priority section - Paragraph::new(Line::styled(" PRIORITY", theme::header_style())).render(chunks[3], buf); + let mut chunk_idx = 0; + for (key, rows) in &grouped { + let header_label = format!(" {}", key.to_uppercase()); + Paragraph::new(Line::styled(header_label, theme::header_style())) + .render(chunks[chunk_idx], buf); + chunk_idx += 1; - let mut priority_lines: Vec = Vec::new(); - let n_statuses = FILTER_STATUSES.len(); - for (i, &priority) in FILTER_PRIORITIES.iter().enumerate() { - let checked = app.filter.priorities.contains(&priority); - let cursor = n_statuses + i; - let is_cursor = app.filter_cursor == cursor; - let check = if checked { "[x]" } else { "[ ]" }; - let label = format!("{}", priority); - let sym = theme::priority_symbol(Some(&priority)); - let style = if is_cursor { - theme::highlight_style() - } else { - Style::default() - }; - priority_lines.push(Line::styled( - format!(" {} {} {} ", check, sym, label), - style, - )); + let mut row_lines: Vec = Vec::new(); + for (cursor_idx, value) in rows { + let checked = app.filter.is_selected(key, value); + let is_cursor = app.filter_cursor == *cursor_idx; + let check = if checked { "[x]" } else { "[ ]" }; + let label = super::app::field_label(value, key, &app.schema); + let sym = theme::field_symbol(value, key, &app.schema); + let style = if is_cursor { + theme::highlight_style() + } else { + Style::default() + }; + row_lines.push(Line::styled( + format!(" {} {} {} ", check, sym, label), + style, + )); + } + Paragraph::new(row_lines).render(chunks[chunk_idx], buf); + chunk_idx += 1; + + // skip blank chunk + chunk_idx += 1; } - Paragraph::new(priority_lines).render(chunks[4], buf); - // Hint line let archived_hint = if app.filter.hide_archived { " [A]:show archived" } else { @@ -106,5 +106,7 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { Span::raw(":close"), Span::styled(archived_hint, theme::dimmed_style()), ]); - Paragraph::new(hint).render(chunks[5], buf); + if chunk_idx < chunks.len() { + Paragraph::new(hint).render(chunks[chunk_idx], buf); + } } diff --git a/rust/leanspec-cli/src/commands/tui/keybindings.rs b/rust/leanspec-cli/src/commands/tui/keybindings.rs index 83b9e9c7..9e07735f 100644 --- a/rust/leanspec-cli/src/commands/tui/keybindings.rs +++ b/rust/leanspec-cli/src/commands/tui/keybindings.rs @@ -311,39 +311,41 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) { } /// Handle a mouse click inside the filter overlay. -/// The overlay geometry mirrors filter.rs render() so we can map (col, row) to an item. +/// +/// The overlay groups filter entries by field key (status, priority, …), +/// emitting a 1-row header before each group and a blank row after. We walk +/// the same groups here in the same order to map a clicked terminal row back +/// to a `filter_entries` index. fn handle_filter_click(app: &mut App, _col: u16, row: u16) { - use super::app::{FILTER_PRIORITIES, FILTER_STATUSES}; - - let w = app.last_frame_width; let h = app.last_frame_height; + let w = app.last_frame_width; if w == 0 || h == 0 { return; } - let _overlay_width = (w * 50 / 100).max(44).min(w); - let overlay_height = 16u16.min(h.saturating_sub(4)); + let entries = app.filter_entries(); + let total_rows = entries.len() as u16; + let overlay_height = (total_rows + 6).min(h.saturating_sub(4)); let overlay_y = h.saturating_sub(overlay_height) / 2; - // Inner area starts one row below the border let inner_y = overlay_y + 1; - // Layout within inner (mirrors filter.rs chunks): - // 0: STATUS header - // 1..n_statuses: status items - // n_statuses+1: blank - // n_statuses+2: PRIORITY header - // n_statuses+3..n_statuses+3+n_priorities: priority items - let n_statuses = FILTER_STATUSES.len() as u16; - let status_start = inner_y + 1; // skip STATUS header row - let priority_start = status_start + n_statuses + 2; // skip blank + PRIORITY header - - if row >= status_start && row < status_start + n_statuses { - let idx = (row - status_start) as usize; - app.filter_cursor = idx; - app.filter_toggle_current(); - } else if row >= priority_start && row < priority_start + FILTER_PRIORITIES.len() as u16 { - let idx = FILTER_STATUSES.len() + (row - priority_start) as usize; - app.filter_cursor = idx; - app.filter_toggle_current(); + let mut current_row = inner_y; + let mut last_key: Option = None; + for (i, (key, _)) in entries.iter().enumerate() { + let new_group = last_key.as_deref() != Some(key.as_str()); + if new_group { + // Header row, then blank row after the previous group (skipped on first group) + if last_key.is_some() { + current_row = current_row.saturating_add(1); // blank + } + current_row = current_row.saturating_add(1); // header + last_key = Some(key.clone()); + } + if row == current_row { + app.filter_cursor = i; + app.filter_toggle_current(); + return; + } + current_row = current_row.saturating_add(1); } } @@ -951,7 +953,8 @@ mod tests { assert_eq!(app.sort_option, SortOption::IdAsc); handle_key(&mut app, key(KeyCode::Char('s'))); - assert_eq!(app.sort_option, SortOption::PriorityDesc); + // Markdown schema declares a priority field so the cycle lands on it. + assert!(matches!(app.sort_option, SortOption::FieldDesc(_))); } #[test] diff --git a/rust/leanspec-cli/src/commands/tui/list.rs b/rust/leanspec-cli/src/commands/tui/list.rs index f839c29b..247a2428 100644 --- a/rust/leanspec-cli/src/commands/tui/list.rs +++ b/rust/leanspec-cli/src/commands/tui/list.rs @@ -1,4 +1,4 @@ -//! List view widget — flat table or tree with sort indicator. +//! List view widget — flat table or tree of `SpecDoc`s. use ratatui::{ buffer::Buffer, @@ -20,7 +20,11 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { theme::border_unfocused_style() }; - let filter_indicator = if !app.filter.is_empty() { " [F]" } else { "" }; + let filter_indicator = if !app.filter.is_empty(&app.schema) { + " [F]" + } else { + "" + }; let tree_indicator = if app.tree_mode { " [Tree]" } else { "" }; let title = format!( " List [{}]{}{} ", @@ -46,11 +50,10 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { let mut lines: Vec = Vec::new(); - // Header row lines.push(Line::from(vec![ Span::styled(" S ", theme::header_style()), Span::styled("P ", theme::header_style()), - Span::styled(format!("{:<30}", "Path"), theme::header_style()), + Span::styled(format!("{:<30}", "ID"), theme::header_style()), Span::styled("Title", theme::header_style()), ])); lines.push(Line::styled( @@ -60,8 +63,9 @@ fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { let visible_rows = area.height.saturating_sub(3) as usize; let total = app.filtered_specs.len(); - let offset = app.list_scroll_offset; + let status_key = app.status_key.as_deref(); + let priority_key = app.priority_key.as_deref(); for (vi, &spec_idx) in app .filtered_specs @@ -70,7 +74,7 @@ fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { .skip(offset) .take(visible_rows) { - let spec = &app.specs[spec_idx]; + let doc = &app.specs[spec_idx]; let is_current = vi == app.list_selected; let style = if is_current && is_focused { @@ -81,14 +85,21 @@ fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { Style::default() }; - let status_sym = theme::status_symbol(&spec.frontmatter.status); - let priority_sym = theme::priority_symbol(spec.frontmatter.priority.as_ref()); - let path = truncate_path(&spec.path, 28); - let title = truncate_str(&spec.title, 30); - let dep_count = app - .dep_graph - .get_complete_graph(&spec.path) - .map_or(0, |g| g.depends_on.len()); + let status_sym = status_key + .and_then(|k| { + doc.field_str(k) + .map(|v| theme::field_symbol(v, k, &app.schema)) + }) + .unwrap_or(" "); + let priority_sym = priority_key + .and_then(|k| { + doc.field_str(k) + .map(|v| theme::field_symbol(v, k, &app.schema)) + }) + .unwrap_or(" "); + let path = truncate_path(&doc.id, 28); + let title = truncate_str(&doc.title, 30); + let dep_count = app.deps.dep_count(&doc.id); let dep_str = if dep_count > 0 { format!(" d:{}", dep_count) } else { @@ -110,7 +121,6 @@ fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { Paragraph::new(lines).render(area, buf); - // Scrollbar — only render when content exceeds viewport if total > visible_rows { let mut scrollbar_state = ScrollbarState::new(total) .position(app.list_selected) @@ -125,9 +135,7 @@ fn render_flat(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { let mut lines: Vec = Vec::new(); - // Header - let mode_label = " S P Tree"; - lines.push(Line::styled(mode_label, theme::header_style())); + lines.push(Line::styled(" S P Tree", theme::header_style())); lines.push(Line::styled( " ".to_string() + &"-".repeat(area.width.saturating_sub(2) as usize), theme::dimmed_style(), @@ -135,8 +143,9 @@ fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { let visible_rows = area.height.saturating_sub(3) as usize; let total = app.tree_rows.len(); - let offset = app.list_scroll_offset; + let status_key = app.status_key.as_deref(); + let priority_key = app.priority_key.as_deref(); for (vi, row) in app .tree_rows @@ -145,7 +154,7 @@ fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { .skip(offset) .take(visible_rows) { - let spec = &app.specs[row.spec_idx]; + let doc = &app.specs[row.spec_idx]; let is_current = vi == app.list_selected; let base_style = if is_current && is_focused { @@ -167,9 +176,19 @@ fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { " " }; - let status_sym = theme::status_symbol(&spec.frontmatter.status); - let priority_sym = theme::priority_symbol(spec.frontmatter.priority.as_ref()); - let title = truncate_str(&spec.title, 35_usize.saturating_sub(row.depth * 2)); + let status_sym = status_key + .and_then(|k| { + doc.field_str(k) + .map(|v| theme::field_symbol(v, k, &app.schema)) + }) + .unwrap_or(" "); + let priority_sym = priority_key + .and_then(|k| { + doc.field_str(k) + .map(|v| theme::field_symbol(v, k, &app.schema)) + }) + .unwrap_or(" "); + let title = truncate_str(&doc.title, 35_usize.saturating_sub(row.depth * 2)); let expand_style = if row.has_children { base_style.add_modifier(Modifier::BOLD) @@ -182,7 +201,7 @@ fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { format!(" {} {} {}{}", status_sym, priority_sym, indent, expand_sym), expand_style, ), - Span::styled(format!("{} {}", spec.path, title), base_style), + Span::styled(format!("{} {}", doc.id, title), base_style), ])); } @@ -192,7 +211,6 @@ fn render_tree(area: Rect, buf: &mut Buffer, app: &App, is_focused: bool) { Paragraph::new(lines).render(area, buf); - // Scrollbar — only render when content exceeds viewport if total > visible_rows { let mut scrollbar_state = ScrollbarState::new(total) .position(app.list_selected) @@ -227,8 +245,6 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::Terminal; - use super::super::app::App; - fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { buf.content().iter().map(|c| c.symbol()).collect() } @@ -249,7 +265,7 @@ mod tests { let buf_str = buffer_text(terminal.backend().buffer()); assert!(buf_str.contains("List")); - assert!(buf_str.contains("Path")); + assert!(buf_str.contains("ID")); } #[test] @@ -267,7 +283,6 @@ mod tests { .unwrap(); let buf_str = buffer_text(terminal.backend().buffer()); - // Default sort label should appear in title assert!(buf_str.contains("ID")); } } diff --git a/rust/leanspec-cli/src/commands/tui/mod.rs b/rust/leanspec-cli/src/commands/tui/mod.rs index 5f2419ff..3c698d76 100644 --- a/rust/leanspec-cli/src/commands/tui/mod.rs +++ b/rust/leanspec-cli/src/commands/tui/mod.rs @@ -1,17 +1,236 @@ -//! TUI — migration stub. +//! Terminal UI entry point — schema-driven, adapter-agnostic. //! -//! The terminal UI is tightly coupled to the legacy `SpecLoader` path and is -//! awaiting migration to the adapter API. The submodules (`board`, `detail`, -//! `keybindings`, etc.) remain in the tree but are not wired up here so the -//! binary builds while the migration is in progress. +//! The TUI talks exclusively to the active [`leanspec_core::adapters::Adapter`] +//! and renders every status / priority / filter using the adapter's +//! [`leanspec_core::model::SpecSchema`]. There is no markdown-only guard: +//! GitHub / ADO / Jira projects open with the same code path. + +pub mod app; +pub mod board; +pub mod deps; +pub mod detail; +pub mod filter; +pub mod headless; +pub mod help; +pub mod keybindings; +pub mod list; +pub mod markdown; +pub mod project_switcher; +pub mod projects; +pub mod search; +pub mod theme; +pub mod toc; use std::error::Error; +use std::io; +use std::time::Duration; + +use leanspec_core::adapters::markdown::MarkdownAdapter; +use leanspec_core::adapters::{Adapter, AdapterRegistry}; +use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + Terminal, +}; +use app::{App, AppMode, PrimaryView}; + +/// Run the TUI. +/// +/// `specs_dir` is the markdown-only override (mirrors other commands). When +/// `None` and a project name is given, the project registry resolves the +/// adapter. When both are `None`, the active project config is used. pub fn run( - _specs_dir: Option<&str>, - _view: &str, - _project_name: Option<&str>, - _headless: Option<&str>, + specs_dir: Option<&str>, + view: &str, + project_name: Option<&str>, + headless: Option<&str>, +) -> Result<(), Box> { + let initial_view = match view { + "list" => PrimaryView::List, + _ => PrimaryView::Board, + }; + + // Resolve adapter + (optional) initial project. + let (adapter, initial_project) = resolve_startup(specs_dir, project_name)?; + + if let Some(script) = headless { + let mut app = App::new(adapter, initial_view, initial_project)?; + run_headless(&mut app, script)?; + return Ok(()); + } + + let mut terminal = setup_terminal()?; + let result = (|| -> Result<(), Box> { + let mut app = App::new(adapter, initial_view, initial_project)?; + event_loop(&mut terminal, &mut app) + })(); + restore_terminal(&mut terminal)?; + result +} + +type StartupResolution = (Box, Option); + +fn resolve_startup( + specs_dir: Option<&str>, + project_name: Option<&str>, +) -> Result> { + // 1. Explicit --specs-dir wins (markdown-only). + if let Some(dir) = specs_dir { + let config = AdapterRegistry::project_config().ok(); + if let Some(cfg) = config { + if cfg.adapter != "markdown" { + return Err(format!( + "--specs-dir is not applicable to the '{}' adapter \ + (only applies to markdown projects)", + cfg.adapter + ) + .into()); + } + } + return Ok((Box::new(MarkdownAdapter::new(dir)), None)); + } + + // 2. --project resolves through the registry. Honour the project's own + // adapter.yaml if it has one (so GitHub / ADO / Jira projects open + // with their configured backend), falling back to markdown. + if let Some(name) = project_name { + if let Ok(registry) = leanspec_core::storage::ProjectRegistry::new() { + if let Some(project) = registry + .all() + .iter() + .find(|p| p.name == name || p.id == name) + .cloned() + .cloned() + { + let adapter = app::resolve_adapter_for_project(&project)?; + return Ok((adapter, Some(project))); + } + } + return Err(format!("Project '{}' not found in registry", name).into()); + } + + // 3. Fall back to the active project config (GitHub / ADO / Jira / markdown). + let adapter = AdapterRegistry::from_project()?; + Ok((adapter, None)) +} + +fn setup_terminal() -> Result>, Box> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +fn restore_terminal( + terminal: &mut Terminal>, +) -> Result<(), Box> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn event_loop( + terminal: &mut Terminal>, + app: &mut App, ) -> Result<(), Box> { - Err("`tui` is not yet migrated to the adapter API".into()) + while !app.should_quit { + terminal.draw(|frame| draw(frame, app))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => keybindings::handle_key(app, key), + Event::Mouse(mouse) => keybindings::handle_mouse(app, mouse), + Event::Resize(_, _) => {} + _ => {} + } + } + } + app.save_prefs(); + Ok(()) +} + +fn draw(frame: &mut ratatui::Frame, app: &mut App) { + let area = frame.area(); + app.last_frame_width = area.width; + app.last_frame_height = area.height; + + let chunks = if app.sidebar_collapsed { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(100)]) + .split(area) + } else { + let split = app.sidebar_width_pct; + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(split), + Constraint::Percentage(100 - split), + ]) + .split(area) + }; + + if app.sidebar_collapsed { + app.layout_left = ratatui::layout::Rect::default(); + app.layout_right = chunks[0]; + render_right_pane(frame, app); + } else { + app.layout_left = chunks[0]; + app.layout_right = chunks[1]; + render_left_pane(frame, app); + render_right_pane(frame, app); + } + + // Overlays + match app.mode { + AppMode::Search => search::render(area, frame.buffer_mut(), app), + AppMode::Help => help::render(area, frame.buffer_mut()), + AppMode::Filter => filter::render(area, frame.buffer_mut(), app), + AppMode::Toc => toc::render(area, frame.buffer_mut(), app), + AppMode::ProjectSwitcher => project_switcher::render(area, frame.buffer_mut(), app), + AppMode::ProjectManagement => projects::render(area, frame.buffer_mut(), app), + AppMode::Normal => {} + } +} + +fn render_left_pane(frame: &mut ratatui::Frame, app: &App) { + match app.primary_view { + PrimaryView::Board => board::render(app.layout_left, frame.buffer_mut(), app), + PrimaryView::List => list::render(app.layout_left, frame.buffer_mut(), app), + } +} + +fn render_right_pane(frame: &mut ratatui::Frame, app: &App) { + match app.detail_mode { + app::DetailMode::Content => detail::render(app.layout_right, frame.buffer_mut(), app), + app::DetailMode::Dependencies => deps::render(app.layout_right, frame.buffer_mut(), app), + } +} + +/// Headless mode: replay a scripted key sequence then print app state as JSON. +/// +/// Used by integration tests (see `tests/tui_e2e.rs`) to assert TUI behaviour +/// without a real terminal. +fn run_headless(app: &mut App, script: &str) -> Result<(), Box> { + let keys = headless::parse_key_sequence(script); + for key in keys { + keybindings::handle_key(app, key); + if app.should_quit { + break; + } + } + let state = app.debug_state(); + println!("{}", serde_json::to_string_pretty(&state)?); + Ok(()) } diff --git a/rust/leanspec-cli/src/commands/tui/search.rs b/rust/leanspec-cli/src/commands/tui/search.rs index f53b99f2..3241d040 100644 --- a/rust/leanspec-cli/src/commands/tui/search.rs +++ b/rust/leanspec-cli/src/commands/tui/search.rs @@ -12,14 +12,12 @@ use super::app::App; use super::theme; pub fn render(area: Rect, buf: &mut Buffer, app: &App) { - // Center the overlay: 60% width, max 20 lines tall let overlay_width = (area.width * 60 / 100).max(40).min(area.width); let overlay_height = 16.min(area.height.saturating_sub(4)); let x = (area.width.saturating_sub(overlay_width)) / 2; let y = (area.height.saturating_sub(overlay_height)) / 2; let overlay_area = Rect::new(x, y, overlay_width, overlay_height); - // Clear background Clear.render(overlay_area, buf); let block = Block::default() @@ -36,7 +34,6 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { ]) .split(inner); - // Input line let cursor_char = if app.search_query.is_empty() { "type to search..." } else { @@ -49,14 +46,13 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { ]); Paragraph::new(input_line).render(chunks[0], buf); - // Separator Paragraph::new(Line::styled( " ".to_string() + &"-".repeat(chunks[1].width.saturating_sub(2) as usize), theme::dimmed_style(), )) .render(chunks[1], buf); - // Results + let status_key = app.status_key.as_deref(); let mut result_lines: Vec = Vec::new(); let max_results = chunks[2].height as usize; @@ -65,15 +61,20 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) { } else { for (i, &spec_idx) in app.search_results.iter().take(max_results).enumerate() { if spec_idx < app.specs.len() { - let spec = &app.specs[spec_idx]; - let sym = theme::status_symbol(&spec.frontmatter.status); + let doc = &app.specs[spec_idx]; + let sym = status_key + .and_then(|k| { + doc.field_str(k) + .map(|v| theme::field_symbol(v, k, &app.schema)) + }) + .unwrap_or(" "); let style = if i == 0 { theme::highlight_style() } else { Style::default() }; result_lines.push(Line::styled( - format!(" {} {} - {}", sym, spec.path, spec.title), + format!(" {} {} - {}", sym, doc.id, doc.title), style, )); } @@ -89,8 +90,6 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::Terminal; - use super::super::app::App; - fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { buf.content().iter().map(|c| c.symbol()).collect() } diff --git a/rust/leanspec-cli/src/commands/tui/theme.rs b/rust/leanspec-cli/src/commands/tui/theme.rs index 006c450f..798a318e 100644 --- a/rust/leanspec-cli/src/commands/tui/theme.rs +++ b/rust/leanspec-cli/src/commands/tui/theme.rs @@ -1,50 +1,106 @@ -//! Theme constants: colors, styles, and status symbols for the TUI. +//! Theme: colors, styles, and field-value symbols for the TUI. +//! +//! All field-value styling is schema-driven. Symbols and colors are looked +//! up from `EnumOption::icon` / `EnumOption::color` on the active schema, +//! with a fallback set for terminals that don't support unicode-rich icons +//! or when the schema omits a color. use ratatui::style::{Color, Modifier, Style}; use std::sync::OnceLock; -use leanspec_core::{SpecPriority, SpecStatus}; - -// Unicode symbols for status (single-cell-width, no emoji) -// Aligned with web UI Lucide icons: CircleDotDashed, Clock, PlayCircle, CheckCircle2, Archive -pub const STATUS_DRAFT: &str = "○"; -pub const STATUS_PLANNED: &str = "·"; -pub const STATUS_IN_PROGRESS: &str = "▶"; -pub const STATUS_COMPLETE: &str = "✓"; -pub const STATUS_ARCHIVED: &str = "⊘"; - -pub fn status_symbol(status: &SpecStatus) -> &'static str { - match status { - SpecStatus::Draft => STATUS_DRAFT, - SpecStatus::Planned => STATUS_PLANNED, - SpecStatus::InProgress => STATUS_IN_PROGRESS, - SpecStatus::Complete => STATUS_COMPLETE, - SpecStatus::Archived => STATUS_ARCHIVED, +use leanspec_core::model::{FieldKind, SpecSchema}; + +// Fallback single-cell-width unicode symbols, keyed by the well-known +// markdown adapter enum values. Schema-declared `icon`s are typically +// lucide-react names (e.g. "calendar") that don't render in a terminal, +// so this table maps the *value* back to a terminal-renderable glyph. +fn fallback_symbol(field_key: &str, value: &str) -> &'static str { + match (field_key, value) { + ("status", "draft") => "○", + ("status", "planned") => "·", + ("status", "in-progress") => "▶", + ("status", "complete") => "✓", + ("status", "archived") => "⊘", + ("status", "open") => "○", + ("status", "closed") => "✓", + ("priority", "critical") => "!", + ("priority", "high") => "↑", + ("priority", "medium") => "-", + ("priority", "low") => "↓", + _ => "·", } } -pub fn status_color(status: &SpecStatus) -> Color { - match status { - SpecStatus::Draft => rgb(160, 220, 220, Color::Cyan), - SpecStatus::Planned => rgb(100, 140, 255, Color::Blue), - SpecStatus::InProgress => rgb(255, 190, 50, Color::Yellow), - SpecStatus::Complete => rgb(80, 200, 120, Color::Green), - SpecStatus::Archived => rgb(90, 90, 90, Color::DarkGray), +/// Look up the styled color for `value` in the enum options of `field_key`. +/// Falls back to a dim grey when the schema declares no color. +pub fn field_style(value: &str, field_key: &str, schema: &SpecSchema) -> Style { + let color = enum_color_hex(schema, field_key, value) + .and_then(|hex| parse_hex_color(&hex)) + .unwrap_or_else(|| rgb(156, 163, 175, Color::Gray)); + Style::default().fg(color) +} + +/// Return a terminal-renderable single-cell symbol for `value` in `field_key`. +/// +/// Prefers the schema's `EnumOption::icon` only when it looks like a single +/// printable glyph (length 1 graphical code point); otherwise falls back to +/// the curated `fallback_symbol` table. +pub fn field_symbol<'a>(value: &'a str, field_key: &str, schema: &'a SpecSchema) -> &'a str { + if let Some(icon) = enum_icon(schema, field_key, value) { + if icon.chars().count() == 1 && !icon.chars().all(char::is_alphanumeric) { + return icon; + } } + fallback_symbol(field_key, value) } -pub fn status_style(status: &SpecStatus) -> Style { - Style::default().fg(status_color(status)) +fn enum_color_hex(schema: &SpecSchema, field_key: &str, value: &str) -> Option { + let field = schema.field(field_key)?; + match &field.kind { + FieldKind::Enum { options, .. } => options + .iter() + .find(|o| o.value == value) + .and_then(|o| o.color.clone()), + _ => None, + } } -// Aligned with web UI Lucide icons: AlertCircle, ArrowUp, Minus, ArrowDown -pub fn priority_symbol(priority: Option<&SpecPriority>) -> &'static str { - match priority { - Some(SpecPriority::Critical) => "! ", - Some(SpecPriority::High) => "↑ ", - Some(SpecPriority::Medium) => "- ", - Some(SpecPriority::Low) => "↓ ", - None => " ", +fn enum_icon<'a>(schema: &'a SpecSchema, field_key: &str, value: &str) -> Option<&'a str> { + let field = schema.field(field_key)?; + match &field.kind { + FieldKind::Enum { options, .. } => options + .iter() + .find(|o| o.value == value) + .and_then(|o| o.icon.as_deref()), + _ => None, + } +} + +/// Parse a CSS-style `#rrggbb` color into a ratatui `Color::Rgb`, with +/// downgrade to a basic terminal color when truecolor isn't available. +pub fn parse_hex_color(hex: &str) -> Option { + let h = hex.trim_start_matches('#'); + if h.len() != 6 { + return None; + } + let r = u8::from_str_radix(&h[0..2], 16).ok()?; + let g = u8::from_str_radix(&h[2..4], 16).ok()?; + let b = u8::from_str_radix(&h[4..6], 16).ok()?; + Some(rgb(r, g, b, downgrade_rgb(r, g, b))) +} + +/// Crude 6-color downgrade for non-truecolor terminals. +fn downgrade_rgb(r: u8, g: u8, b: u8) -> Color { + let (rh, gh, bh) = (r > 127, g > 127, b > 127); + match (rh, gh, bh) { + (true, true, true) => Color::White, + (true, true, false) => Color::Yellow, + (true, false, true) => Color::Magenta, + (true, false, false) => Color::Red, + (false, true, true) => Color::Cyan, + (false, true, false) => Color::Green, + (false, false, true) => Color::Blue, + (false, false, false) => Color::DarkGray, } } @@ -52,11 +108,9 @@ static SUPPORTS_RGB: OnceLock = OnceLock::new(); fn supports_rgb() -> bool { *SUPPORTS_RGB.get_or_init(|| { - // Explicit truecolor support if let Ok(colorterm) = std::env::var("COLORTERM") { return colorterm == "truecolor" || colorterm == "24bit"; } - // Modern terminals that support truecolor but don't set COLORTERM std::env::var("TERM") .map(|t| t.contains("kitty") || t.contains("alacritty")) .unwrap_or(false) @@ -84,7 +138,6 @@ pub fn selected_style() -> Style { .add_modifier(Modifier::BOLD) } -/// Dimmed selection style for when the sidebar is not focused but still shows which spec is active. pub fn inactive_selected_style() -> Style { Style::default().bg(rgb(35, 35, 55, Color::Reset)) } @@ -106,6 +159,7 @@ pub fn highlight_style() -> Style { .add_modifier(Modifier::BOLD) } +#[allow(dead_code)] pub fn status_bar_style() -> Style { Style::default().bg(Color::DarkGray).fg(Color::White) } @@ -118,6 +172,7 @@ pub fn border_unfocused_style() -> Style { Style::default().fg(rgb(70, 70, 90, Color::DarkGray)) } +#[allow(dead_code)] pub fn project_name_style() -> Style { Style::default() .bg(Color::DarkGray) @@ -151,22 +206,55 @@ pub fn success_style() -> Style { #[cfg(test)] mod tests { use super::*; + use leanspec_core::model::{EnumOption, FieldDef, FieldDisplay}; + + fn schema_with_status() -> SpecSchema { + SpecSchema { + id: "t".into(), + name: "T".into(), + extends: None, + fields: vec![FieldDef { + key: "status".into(), + label: "Status".into(), + kind: FieldKind::Enum { + options: vec![ + EnumOption::simple("draft", "Draft").with_color("#6b7280"), + EnumOption::simple("in-progress", "In Progress").with_color("#f59e0b"), + ], + multi: false, + allow_custom: false, + dynamic: false, + }, + display: FieldDisplay::Inline, + required: false, + semantic: None, + ai_hint: None, + placeholder: None, + }], + link_types: vec![], + } + } + + #[test] + fn parse_hex_round_trip() { + // Either truecolor Rgb(...) or a basic-color downgrade depending on + // terminal support. We just assert the round-trip succeeds. + assert!(parse_hex_color("#3b82f6").is_some()); + assert!(parse_hex_color("xyz").is_none()); + assert!(parse_hex_color("#123").is_none()); + } #[test] - fn test_status_symbols() { - assert_eq!(status_symbol(&SpecStatus::Draft), "○"); - assert_eq!(status_symbol(&SpecStatus::Planned), "·"); - assert_eq!(status_symbol(&SpecStatus::InProgress), "▶"); - assert_eq!(status_symbol(&SpecStatus::Complete), "✓"); - assert_eq!(status_symbol(&SpecStatus::Archived), "⊘"); + fn fallback_symbols_cover_markdown_statuses() { + let schema = schema_with_status(); + // No icon declared in this option, so falls back to symbol table. + assert_eq!(field_symbol("draft", "status", &schema), "○"); + assert_eq!(field_symbol("in-progress", "status", &schema), "▶"); } #[test] - fn test_priority_symbols() { - assert_eq!(priority_symbol(Some(&SpecPriority::Critical)), "! "); - assert_eq!(priority_symbol(Some(&SpecPriority::High)), "↑ "); - assert_eq!(priority_symbol(Some(&SpecPriority::Medium)), "- "); - assert_eq!(priority_symbol(Some(&SpecPriority::Low)), "↓ "); - assert_eq!(priority_symbol(None), " "); + fn unknown_value_uses_default_glyph() { + let schema = schema_with_status(); + assert_eq!(field_symbol("nonsense", "status", &schema), "·"); } }