From 55c4361b6ed5b70aad8b8e9c6beafba4b4f2ca2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:30:30 -0600 Subject: [PATCH] fix: improve explore TUI navigation, rendering, and usability (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recursive file scanning to find docs in nested user directories - Collapsible subgroups and user directories in navigation tree - Display document titles instead of filenames with type badges and dates - Multiline table cells that wrap within available width - Navigation panel scroll to keep selection visible - Sort by title or date with visual indicator - Inline code styled with neutral white-on-gray - Document viewer title centered - Title fallback: frontmatter → H1 heading → humanized filename Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/tui/app.rs | 163 ++++++++++++++++++--- cli/src/tui/index.rs | 219 +++++++++++++++++++++++++--- cli/src/tui/markdown.rs | 155 ++++++++++++++++---- cli/src/tui/widgets/doc_viewer.rs | 3 +- cli/src/tui/widgets/nav_tree.rs | 232 ++++++++++++++++++++++-------- 5 files changed, 645 insertions(+), 127 deletions(-) diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs index c8da840..9160abd 100644 --- a/cli/src/tui/app.rs +++ b/cli/src/tui/app.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::document::Document; @@ -32,8 +32,12 @@ pub enum NavSelection { Subgroup(usize, usize), /// A file in a group's direct files GroupFile(usize, usize), - /// A file within a subgroup + /// A file within a subgroup's direct files SubgroupFile(usize, usize, usize), + /// A user-created directory within a subgroup + UserDir(usize, usize, usize), + /// A file within a user-created directory + UserDirFile(usize, usize, usize, usize), } /// Sort order for file listings @@ -57,6 +61,8 @@ pub struct App { pub selection: NavSelection, /// Which groups are expanded in the tree pub expanded_groups: Vec, + /// Which subgroups/user_dirs are expanded: (gi, si) for subgroups, (gi, si, di) encoded as string + pub expanded_nodes: HashSet, /// Scroll offset for the document viewer pub doc_scroll: u16, /// Total lines in current document (for scroll bounds) @@ -95,6 +101,7 @@ impl App { view_mode: ViewMode::Normal, selection: NavSelection::Group(0), expanded_groups: vec![false; num_groups], + expanded_nodes: HashSet::new(), doc_scroll: 0, doc_total_lines: 0, current_doc: None, @@ -144,10 +151,13 @@ impl App { let gi = *gi; self.expanded_groups[gi] = !self.expanded_groups[gi]; } - NavSelection::Subgroup(gi, _si) => { - // Subgroups are always expanded when visible, so treat as entering - let gi = *gi; - self.expanded_groups[gi] = true; + NavSelection::Subgroup(gi, si) => { + let key = format!("sg:{gi}:{si}"); + if self.expanded_nodes.contains(&key) { + self.expanded_nodes.remove(&key); + } else { + self.expanded_nodes.insert(key); + } } NavSelection::GroupFile(gi, fi) => { let gi = *gi; @@ -170,6 +180,30 @@ impl App { self.load_document(&entry.path.clone()); } } + NavSelection::UserDir(gi, si, di) => { + let key = format!("ud:{gi}:{si}:{di}"); + if self.expanded_nodes.contains(&key) { + self.expanded_nodes.remove(&key); + } else { + self.expanded_nodes.insert(key); + } + } + NavSelection::UserDirFile(gi, si, di, fi) => { + let gi = *gi; + let si = *si; + let di = *di; + let fi = *fi; + if let Some(entry) = self + .index + .groups + .get(gi) + .and_then(|g| g.subgroups.get(si)) + .and_then(|sg| sg.user_dirs.get(di)) + .and_then(|ud| ud.files.get(fi)) + { + self.load_document(&entry.path.clone()); + } + } } } @@ -214,11 +248,17 @@ impl App { let gi = *gi; self.selection = NavSelection::Group(gi); } - NavSelection::SubgroupFile(gi, si, _) => { + NavSelection::SubgroupFile(gi, si, _) | NavSelection::UserDir(gi, si, _) => { let gi = *gi; let si = *si; self.selection = NavSelection::Subgroup(gi, si); } + NavSelection::UserDirFile(gi, si, di, _) => { + let gi = *gi; + let si = *si; + let di = *di; + self.selection = NavSelection::UserDir(gi, si, di); + } NavSelection::Group(gi) => { let gi = *gi; self.expanded_groups[gi] = false; @@ -344,12 +384,34 @@ impl App { } } - /// Cycle sort order + pub fn is_subgroup_expanded(&self, gi: usize, si: usize) -> bool { + self.expanded_nodes.contains(&format!("sg:{gi}:{si}")) + } + + pub fn is_userdir_expanded(&self, gi: usize, si: usize, di: usize) -> bool { + self.expanded_nodes.contains(&format!("ud:{gi}:{si}:{di}")) + } + + /// Cycle sort order and re-sort all files in the index pub fn cycle_sort(&mut self) { self.sort_order = match self.sort_order { SortOrder::Name => SortOrder::Date, SortOrder::Date => SortOrder::Name, }; + self.apply_sort(); + } + + fn apply_sort(&mut self) { + let sort = self.sort_order; + for group in &mut self.index.groups { + sort_entries(&mut group.files, sort); + for sg in &mut group.subgroups { + sort_entries(&mut sg.files, sort); + for ud in &mut sg.user_dirs { + sort_entries(&mut ud.files, sort); + } + } + } } /// Navigate to a document by its ID (hyperlinked navigation) @@ -375,7 +437,9 @@ impl App { match &self.selection { NavSelection::GroupFile(gi, _) | NavSelection::Subgroup(gi, _) - | NavSelection::SubgroupFile(gi, _, _) => { + | NavSelection::SubgroupFile(gi, _, _) + | NavSelection::UserDir(gi, _, _) + | NavSelection::UserDirFile(gi, _, _, _) => { self.expanded_groups[*gi] = true; } _ => {} @@ -459,6 +523,22 @@ impl App { let path = self.index.groups[gi].subgroups[si].files[new_fi].path.clone(); self.load_document(&path); } + NavSelection::UserDirFile(gi, si, di, fi) => { + let gi = *gi; + let si = *si; + let di = *di; + let fi = *fi; + let len = self.index.groups[gi].subgroups[si].user_dirs[di].files.len(); + if len == 0 { + return; + } + let new_fi = (fi as i32 + direction).rem_euclid(len as i32) as usize; + self.selection = NavSelection::UserDirFile(gi, si, di, new_fi); + let path = self.index.groups[gi].subgroups[si].user_dirs[di].files[new_fi] + .path + .clone(); + self.load_document(&path); + } _ => {} } } @@ -476,6 +556,13 @@ impl App { return Some(NavSelection::SubgroupFile(gi, si, fi)); } } + for (di, ud) in sg.user_dirs.iter().enumerate() { + for (fi, entry) in ud.files.iter().enumerate() { + if entry.path == target { + return Some(NavSelection::UserDirFile(gi, si, di, fi)); + } + } + } } } None @@ -509,19 +596,41 @@ impl App { } items.push(NavSelection::GroupFile(gi, fi)); } - // Subgroups and their files + // Subgroups, their files, and user dirs for (si, sg) in group.subgroups.iter().enumerate() { - let sg_has_matches = - sg.files.iter().any(|e| entry_matches_search(e, search)); + let sg_has_matches = subgroup_has_matches(sg, search); if has_search && !sg_has_matches { continue; } items.push(NavSelection::Subgroup(gi, si)); - for (fi, entry) in sg.files.iter().enumerate() { - if !entry_matches_search(entry, search) { - continue; + + let sg_expanded = has_search || self.is_subgroup_expanded(gi, si); + if sg_expanded { + for (fi, entry) in sg.files.iter().enumerate() { + if !entry_matches_search(entry, search) { + continue; + } + items.push(NavSelection::SubgroupFile(gi, si, fi)); + } + for (di, ud) in sg.user_dirs.iter().enumerate() { + let ud_has_matches = + ud.files.iter().any(|e| entry_matches_search(e, search)); + if has_search && !ud_has_matches { + continue; + } + items.push(NavSelection::UserDir(gi, si, di)); + + let ud_expanded = + has_search || self.is_userdir_expanded(gi, si, di); + if ud_expanded { + for (fi, entry) in ud.files.iter().enumerate() { + if !entry_matches_search(entry, search) { + continue; + } + items.push(NavSelection::UserDirFile(gi, si, di, fi)); + } + } } - items.push(NavSelection::SubgroupFile(gi, si, fi)); } } } @@ -538,7 +647,27 @@ fn group_has_matches( || group .subgroups .iter() - .any(|sg| sg.files.iter().any(|e| entry_matches_search(e, search))) + .any(|sg| subgroup_has_matches(sg, search)) +} + +fn subgroup_has_matches( + sg: &crate::tui::index::DocSubgroup, + search: Option<&str>, +) -> bool { + sg.files.iter().any(|e| entry_matches_search(e, search)) + || sg + .user_dirs + .iter() + .any(|ud| ud.files.iter().any(|e| entry_matches_search(e, search))) +} + +fn sort_entries(entries: &mut Vec, order: SortOrder) { + match order { + SortOrder::Name => { + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())) + } + SortOrder::Date => entries.sort_by(|a, b| b.created.cmp(&a.created)), + } } fn entry_matches_search( diff --git a/cli/src/tui/index.rs b/cli/src/tui/index.rs index 1b6df36..5aaa9ed 100644 --- a/cli/src/tui/index.rs +++ b/cli/src/tui/index.rs @@ -26,6 +26,18 @@ pub struct DocSubgroup { /// Display label (e.g., "Technical debt") pub label: String, pub path: PathBuf, + /// Files directly in this subgroup + pub files: Vec, + /// User-created subdirectories within this subgroup + pub user_dirs: Vec, +} + +/// A user-created subdirectory within a subgroup (e.g., "daemon" under "agent-logs") +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct UserDir { + pub name: String, + pub path: PathBuf, pub files: Vec, } @@ -35,8 +47,11 @@ pub struct DocSubgroup { pub struct DocEntry { pub filename: String, pub path: PathBuf, + /// Display title (from frontmatter, H1, or humanized filename) pub title: String, pub id: String, + /// Short type badge: "AI", "DC", "AD", "ET", "RQ", "TS", "IN", "TD" + pub doc_type: String, pub tags: Vec, pub created: String, pub has_frontmatter: bool, @@ -111,22 +126,50 @@ impl DocIndex { continue; } - // Scan files directly in the group dir - let files = scan_md_files(&group_path, &mut relations); + // Scan files directly in the group dir (non-recursive, subgroups scanned separately) + let files = scan_md_files_flat(&group_path, &mut relations); total_docs += files.len(); - // Scan subgroups + // Scan subgroups and their user-created subdirectories let mut subgroups = Vec::new(); for &(sg_name, sg_label) in subgroup_defs { let sg_path = group_path.join(sg_name); if sg_path.exists() { - let sg_files = scan_md_files(&sg_path, &mut relations); + // Files directly in the subgroup + let sg_files = scan_md_files_flat(&sg_path, &mut relations); total_docs += sg_files.len(); + + // Scan user-created subdirectories + let mut user_dirs = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&sg_path) { + let mut dirs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + for dir_path in dirs { + let dir_name = dir_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + let dir_files = scan_md_files(&dir_path, &mut relations); + total_docs += dir_files.len(); + user_dirs.push(UserDir { + name: dir_name, + path: dir_path, + files: dir_files, + }); + } + } + subgroups.push(DocSubgroup { name: sg_name.to_string(), label: sg_label.to_string(), path: sg_path, files: sg_files, + user_dirs, }); } else { subgroups.push(DocSubgroup { @@ -134,6 +177,7 @@ impl DocIndex { label: sg_label.to_string(), path: sg_path, files: Vec::new(), + user_dirs: Vec::new(), }); } } @@ -191,6 +235,13 @@ impl DocIndex { candidates.push(&entry.path); } } + for ud in &sg.user_dirs { + for entry in &ud.files { + if entry_matches(&entry.filename, &entry.path, ref_filename, clean_ref) { + candidates.push(&entry.path); + } + } + } } } @@ -226,7 +277,8 @@ fn entry_matches(filename: &str, path: &Path, ref_filename: &str, clean_ref: &st false } -fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { +/// Scan only direct .md files in a directory (non-recursive, for group root dirs) +fn scan_md_files_flat(dir: &Path, relations: &mut RelationIndex) -> Vec { let mut entries = Vec::new(); let Ok(read_dir) = std::fs::read_dir(dir) else { @@ -247,7 +299,67 @@ fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { }) .collect(); - paths.sort(); + paths.sort_by(|a, b| { + let name_a = a.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let name_b = b.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name_a.cmp(name_b) + }); + + for path in paths { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + let meta = quick_scan_frontmatter(&path, relations); + + entries.push(DocEntry { + filename, + path, + title: meta.title, + id: meta.id, + doc_type: meta.doc_type, + tags: meta.tags, + created: meta.created, + has_frontmatter: meta.has_frontmatter, + }); + } + + entries +} + +/// Scan .md files recursively (for subgroups that may have nested subdirectories) +fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { + let mut entries = Vec::new(); + let mut paths = Vec::new(); + collect_md_files(dir, &mut paths); + paths.sort_by(|a, b| { + let name_a = a.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let name_b = b.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name_a.cmp(name_b) + }); + + fn collect_md_files(dir: &Path, paths: &mut Vec) { + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_files(&path, paths); + } else if path.is_file() + && path.extension().and_then(|e| e.to_str()) == Some("md") + && !path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("TEMPLATE-") || n.starts_with('.')) + .unwrap_or(true) + { + paths.push(path); + } + } + } for path in paths { let filename = path @@ -264,6 +376,7 @@ fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { path, title: meta.title, id: meta.id, + doc_type: meta.doc_type, tags: meta.tags, created: meta.created, has_frontmatter: meta.has_frontmatter, @@ -276,19 +389,70 @@ fn scan_md_files(dir: &Path, relations: &mut RelationIndex) -> Vec { struct ScannedMeta { title: String, id: String, + doc_type: String, tags: Vec, created: String, has_frontmatter: bool, } -fn fallback_meta(path: &Path) -> ScannedMeta { +/// Extract doc type badge from filename prefix +fn doc_type_badge(filename: &str) -> String { + let badges: &[(&str, &str)] = &[ + ("AILOG-", "AI"), + ("AIDEC-", "DC"), + ("ADR-", "AD"), + ("ETH-", "ET"), + ("REQ-", "RQ"), + ("TES-", "TS"), + ("INC-", "IN"), + ("TDE-", "TD"), + ]; + for &(prefix, badge) in badges { + if filename.starts_with(prefix) { + return badge.to_string(); + } + } + String::new() +} + +/// Try to find the first H1 title (# Title) in markdown content +fn find_h1_title(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("# ") { + let title = title.trim(); + if !title.is_empty() { + return Some(title.to_string()); + } + } + } + None +} + +/// Convert a filename stem to a human-readable title +fn humanize_filename(stem: &str) -> String { + stem.replace('-', " ").replace('_', " ") +} + +fn fallback_meta(path: &Path, content: Option<&str>) -> ScannedMeta { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + let stem = path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"); + + // Try H1 from content, then humanize filename + let title = content + .and_then(find_h1_title) + .unwrap_or_else(|| humanize_filename(stem)); + ScannedMeta { - title: path - .file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown") - .to_string(), + title, id: String::new(), + doc_type: doc_type_badge(filename), tags: Vec::new(), created: String::new(), has_frontmatter: false, @@ -298,32 +462,42 @@ fn fallback_meta(path: &Path) -> ScannedMeta { fn quick_scan_frontmatter(path: &Path, relations: &mut RelationIndex) -> ScannedMeta { let content = match std::fs::read_to_string(path) { Ok(c) => c, - Err(_) => return fallback_meta(path), + Err(_) => return fallback_meta(path, None), }; let trimmed = content.trim_start(); if !trimmed.starts_with("---") { - return fallback_meta(path); + return fallback_meta(path, Some(&content)); } let after = &trimmed[3..]; let Some(end) = after.find("\n---") else { - return fallback_meta(path); + return fallback_meta(path, Some(&content)); }; let yaml_str = &after[..end]; + let body = &after[end + 4..]; // content after closing --- let fm: Option = serde_yaml::from_str(yaml_str).ok(); + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + match fm { Some(fm) => { let id = fm.id.clone(); - let title = if fm.title.is_empty() { - path.file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown") - .to_string() - } else { + let title = if !fm.title.is_empty() { fm.title.clone() + } else { + // Try H1 from body, then humanize filename + find_h1_title(body).unwrap_or_else(|| { + humanize_filename( + path.file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown"), + ) + }) }; let tags = fm.tags.clone(); let created = fm.created.clone().unwrap_or_default(); @@ -351,11 +525,12 @@ fn quick_scan_frontmatter(path: &Path, relations: &mut RelationIndex) -> Scanned ScannedMeta { title, id, + doc_type: doc_type_badge(filename), tags, created, has_frontmatter: true, } } - None => fallback_meta(path), + None => fallback_meta(path, Some(body)), } } diff --git a/cli/src/tui/markdown.rs b/cli/src/tui/markdown.rs index 7f336a8..f415417 100644 --- a/cli/src/tui/markdown.rs +++ b/cli/src/tui/markdown.rs @@ -3,7 +3,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; /// Convert markdown text to styled ratatui Lines -pub fn markdown_to_lines(markdown: &str) -> Vec> { +pub fn markdown_to_lines(markdown: &str, available_width: usize) -> Vec> { let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; @@ -193,12 +193,16 @@ pub fn markdown_to_lines(markdown: &str) -> Vec> { table_row.clear(); } TagEnd::Table => { - // Calculate column widths from all rows - let col_widths = compute_column_widths(&table_header_row, &table_body_rows); - // Render header + // Calculate column widths, fitting to available width + let col_widths = compute_column_widths( + &table_header_row, + &table_body_rows, + available_width.saturating_sub(content_indent), + ); + // Render header (multiline cells) render_table_row(&table_header_row, &col_widths, true, &mut lines, content_indent); render_table_separator(&col_widths, &mut lines, content_indent); - // Render body rows + // Render body rows (multiline cells) for row in &table_body_rows { render_table_row(row, &col_widths, false, &mut lines, content_indent); } @@ -323,31 +327,106 @@ fn heading_style(level: HeadingLevel) -> Style { Style::default().fg(color).add_modifier(Modifier::BOLD) } -fn compute_column_widths(header: &[String], body: &[Vec]) -> Vec { - let num_cols = header.len().max( - body.iter().map(|r| r.len()).max().unwrap_or(0), - ); - let mut widths = vec![0usize; num_cols]; +fn compute_column_widths( + header: &[String], + body: &[Vec], + available_width: usize, +) -> Vec { + let num_cols = header + .len() + .max(body.iter().map(|r| r.len()).max().unwrap_or(0)); + if num_cols == 0 { + return Vec::new(); + } + // Calculate natural (max content) width per column + let mut natural = vec![0usize; num_cols]; for (i, cell) in header.iter().enumerate() { - widths[i] = widths[i].max(cell.len()); + natural[i] = natural[i].max(cell.len()); } for row in body { for (i, cell) in row.iter().enumerate() { if i < num_cols { - widths[i] = widths[i].max(cell.len()); + natural[i] = natural[i].max(cell.len()); } } } - - // Minimum width of 3 per column - for w in &mut widths { + for w in &mut natural { *w = (*w).max(3); } + // Overhead: indent is handled outside; here we account for borders + // "│ " + (" │ " between cols) + " │" = 2 + (num_cols - 1) * 3 + 2 + let border_overhead = 2 + (num_cols.saturating_sub(1)) * 3 + 2; + let content_budget = available_width.saturating_sub(border_overhead); + + let total_natural: usize = natural.iter().sum(); + if total_natural <= content_budget { + return natural; + } + + // Distribute available width proportionally + let mut widths = vec![0usize; num_cols]; + for (i, &nat) in natural.iter().enumerate() { + widths[i] = ((nat as f64 / total_natural as f64) * content_budget as f64).floor() as usize; + widths[i] = widths[i].max(3); + } + + // Distribute any remaining space to the largest columns + let assigned: usize = widths.iter().sum(); + let mut remaining = content_budget.saturating_sub(assigned); + while remaining > 0 { + // Find column with largest deficit + let mut best = 0; + let mut best_deficit = 0usize; + for (i, (&nat, &w)) in natural.iter().zip(widths.iter()).enumerate() { + let deficit = nat.saturating_sub(w); + if deficit > best_deficit { + best_deficit = deficit; + best = i; + } + } + if best_deficit == 0 { + break; + } + widths[best] += 1; + remaining -= 1; + } + widths } +/// Wrap text into lines of at most `width` characters, breaking at word boundaries +fn wrap_cell_text(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + if text.len() <= width { + return vec![text.to_string()]; + } + + let mut result = Vec::new(); + let mut remaining = text; + + while !remaining.is_empty() { + if remaining.len() <= width { + result.push(remaining.to_string()); + break; + } + // Find last space within width + let chunk = &remaining[..width]; + let break_at = chunk.rfind(' ').unwrap_or(width); + let break_at = if break_at == 0 { width } else { break_at }; + result.push(remaining[..break_at].to_string()); + remaining = remaining[break_at..].trim_start(); + } + + if result.is_empty() { + result.push(String::new()); + } + result +} + fn render_table_row( cells: &[String], col_widths: &[usize], @@ -364,20 +443,42 @@ fn render_table_row( }; let border = Style::default().fg(Color::DarkGray); - let mut spans: Vec> = Vec::new(); - if indent > 0 { - spans.push(Span::raw(" ".repeat(indent))); - } - spans.push(Span::styled("│ ", border)); - for (i, width) in col_widths.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" │ ", border)); + // Wrap each cell's content and determine how many visual lines this row needs + let wrapped: Vec> = col_widths + .iter() + .enumerate() + .map(|(i, &w)| { + let text = cells.get(i).map(|s| s.as_str()).unwrap_or(""); + wrap_cell_text(text, w) + }) + .collect(); + + let max_lines = wrapped.iter().map(|w| w.len()).max().unwrap_or(1); + + // Render each visual line of the row + for line_idx in 0..max_lines { + let mut spans: Vec> = Vec::new(); + if indent > 0 { + spans.push(Span::raw(" ".repeat(indent))); + } + spans.push(Span::styled("│ ", border)); + for (col, width) in col_widths.iter().enumerate() { + if col > 0 { + spans.push(Span::styled(" │ ", border)); + } + let text = wrapped + .get(col) + .and_then(|w| w.get(line_idx)) + .map(|s| s.as_str()) + .unwrap_or(""); + spans.push(Span::styled( + format!("{: DocViewer<'a> { let block = Block::default() .title(title) + .title_alignment(ratatui::layout::Alignment::Center) .title_style( Style::default() .fg(Color::Cyan) @@ -53,7 +54,7 @@ impl<'a> DocViewer<'a> { // Render markdown body only (metadata is in separate panel) let mut all_lines = vec![Line::from(""); 1]; - all_lines.extend(markdown_to_lines(&doc.body)); + all_lines.extend(markdown_to_lines(&doc.body, inner.width as usize)); // Estimate total lines accounting for wrapping let width = inner.width.max(1) as usize; diff --git a/cli/src/tui/widgets/nav_tree.rs b/cli/src/tui/widgets/nav_tree.rs index 061b75d..c1f73c8 100644 --- a/cli/src/tui/widgets/nav_tree.rs +++ b/cli/src/tui/widgets/nav_tree.rs @@ -4,7 +4,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Widget}; -use crate::tui::app::{ActivePanel, App, NavSelection}; +use crate::tui::app::{ActivePanel, App, NavSelection, SortOrder}; use crate::tui::index::DocEntry; pub struct NavTree<'a> { @@ -27,7 +27,10 @@ impl Widget for NavTree<'_> { }; let block = Block::default() - .title(" Navigation ") + .title(format!(" Navigation {} ", match self.app.sort_order { + SortOrder::Name => "[s:sort ↓name]", + SortOrder::Date => "[s:sort ↓date]", + })) .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_style(border_style); @@ -36,22 +39,20 @@ impl Widget for NavTree<'_> { block.render(area, buf); let mut lines: Vec> = Vec::new(); + let mut selected_line: Option = None; let search = self.app.search_query.as_deref(); - let has_search = search.is_some(); for (gi, group) in self.app.index.groups.iter().enumerate() { let is_expanded = self.app.expanded_groups[gi]; let is_selected = self.app.selection == NavSelection::Group(gi); - // When searching, auto-expand groups that have matches let show_children = if has_search { group_has_matches(group, search) } else { is_expanded }; - // When searching, skip groups with no matches if has_search && !show_children { continue; } @@ -74,6 +75,9 @@ impl Widget for NavTree<'_> { Style::default().fg(Color::White) }; + if is_selected { + selected_line = Some(lines.len()); + } lines.push(Line::from(vec![ Span::styled(format!(" {arrow} "), Style::default().fg(Color::Cyan)), Span::styled(group.label.clone(), style), @@ -81,29 +85,28 @@ impl Widget for NavTree<'_> { ])); if show_children { - // Direct files in group for (fi, entry) in group.files.iter().enumerate() { if !matches_search(entry, search) { continue; } let is_sel = self.app.selection == NavSelection::GroupFile(gi, fi); - let style = file_style(is_sel); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(truncate_filename(&entry.filename, inner.width as usize - 6), style), - ])); + if is_sel { + selected_line = Some(lines.len()); + } + lines.push(file_entry_line(entry, " ", inner.width as usize, is_sel)); } - // Subgroups for (si, sg) in group.subgroups.iter().enumerate() { - // When searching, skip subgroups with no matches - let sg_has_matches = sg.files.iter().any(|e| matches_search(e, search)); - if has_search && !sg_has_matches { + let sg_matches = subgroup_has_search_matches(sg, search); + if has_search && !sg_matches { continue; } let is_sel = self.app.selection == NavSelection::Subgroup(gi, si); - let sg_count = sg.files.len(); + let sg_expanded = has_search || self.app.is_subgroup_expanded(gi, si); + let sg_count = sg.files.len() + + sg.user_dirs.iter().map(|ud| ud.files.len()).sum::(); + let sg_arrow = if sg_expanded { "▾" } else { "▸" }; let sg_style = if is_sel { Style::default() .bg(Color::DarkGray) @@ -113,9 +116,12 @@ impl Widget for NavTree<'_> { Style::default().fg(Color::Yellow) }; + if is_sel { + selected_line = Some(lines.len()); + } lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("▸ ", Style::default().fg(Color::Yellow)), + Span::styled(format!("{sg_arrow} "), Style::default().fg(Color::Yellow)), Span::styled(format!("{}/", sg.label), sg_style), Span::styled( format!(" ({sg_count})"), @@ -123,31 +129,133 @@ impl Widget for NavTree<'_> { ), ])); - // Files in subgroup - for (fi, entry) in sg.files.iter().enumerate() { - if !matches_search(entry, search) { - continue; + if sg_expanded { + // Direct files in subgroup + for (fi, entry) in sg.files.iter().enumerate() { + if !matches_search(entry, search) { + continue; + } + let is_sel = + self.app.selection == NavSelection::SubgroupFile(gi, si, fi); + if is_sel { + selected_line = Some(lines.len()); + } + lines.push(file_entry_line(entry, " ", inner.width as usize, is_sel)); + } + + // User-created subdirectories + for (di, ud) in sg.user_dirs.iter().enumerate() { + let ud_has_matches = + ud.files.iter().any(|e| matches_search(e, search)); + if has_search && !ud_has_matches { + continue; + } + + let is_sel = + self.app.selection == NavSelection::UserDir(gi, si, di); + let ud_expanded = + has_search || self.app.is_userdir_expanded(gi, si, di); + let ud_count = ud.files.len(); + let ud_arrow = if ud_expanded { "▾" } else { "▸" }; + let ud_style = if is_sel { + Style::default() + .bg(Color::DarkGray) + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Magenta) + }; + + if is_sel { + selected_line = Some(lines.len()); + } + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{ud_arrow} "), + Style::default().fg(Color::Magenta), + ), + Span::styled(format!("{}/", ud.name), ud_style), + Span::styled( + format!(" ({ud_count})"), + Style::default().fg(Color::DarkGray), + ), + ])); + + if ud_expanded { + // Files in user dir + for (fi, entry) in ud.files.iter().enumerate() { + if !matches_search(entry, search) { + continue; + } + let is_sel = self.app.selection + == NavSelection::UserDirFile(gi, si, di, fi); + if is_sel { + selected_line = Some(lines.len()); + } + lines.push(file_entry_line(entry, " ", inner.width as usize, is_sel)); + } + } } - let is_sel = - self.app.selection == NavSelection::SubgroupFile(gi, si, fi); - let style = file_style(is_sel); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - truncate_filename(&entry.filename, inner.width as usize - 8), - style, - ), - ])); } } } } - let paragraph = Paragraph::new(lines); + // Calculate scroll to keep selected item visible + let visible_height = inner.height as usize; + let scroll = if let Some(sel) = selected_line { + if sel >= visible_height { + // Keep selected item near the bottom with some margin + (sel - visible_height + 3).min(lines.len().saturating_sub(visible_height)) + } else { + 0 + } + } else { + 0 + }; + + let paragraph = Paragraph::new(lines).scroll((scroll as u16, 0)); paragraph.render(inner, buf); } } +fn file_entry_line(entry: &DocEntry, indent: &str, max_width: usize, selected: bool) -> Line<'static> { + let style = file_style(selected); + let badge_style = Style::default().fg(Color::DarkGray); + let date_style = Style::default().fg(Color::DarkGray); + + let badge = if entry.doc_type.is_empty() { + String::from(" ") + } else { + format!("{} ", entry.doc_type) + }; + let badge_len = badge.len(); + + // Compact date: show MM-DD from YYYY-MM-DD + let date = if entry.created.len() >= 10 { + format!(" {}", &entry.created[5..10]) + } else { + String::new() + }; + let date_len = date.len(); + + let indent_len = indent.len(); + let title_budget = max_width + .saturating_sub(indent_len) + .saturating_sub(badge_len) + .saturating_sub(date_len); + + let title = truncate_str(&entry.title, title_budget); + + Line::from(vec![ + Span::raw(indent.to_string()), + Span::styled(badge, badge_style), + Span::styled(title, style), + Span::styled(date, date_style), + ]) +} + fn file_style(selected: bool) -> Style { if selected { Style::default() @@ -161,11 +269,18 @@ fn file_style(selected: bool) -> Style { fn count_group_docs(group: &crate::tui::index::DocGroup) -> usize { let direct = group.files.len(); - let sub: usize = group.subgroups.iter().map(|sg| sg.files.len()).sum(); + let sub: usize = group + .subgroups + .iter() + .map(|sg| { + sg.files.len() + + sg.user_dirs.iter().map(|ud| ud.files.len()).sum::() + }) + .sum(); direct + sub } -fn truncate_filename(name: &str, max_width: usize) -> String { +fn truncate_str(name: &str, max_width: usize) -> String { if name.len() <= max_width { name.to_string() } else if max_width > 3 { @@ -175,14 +290,28 @@ fn truncate_filename(name: &str, max_width: usize) -> String { } } +fn subgroup_has_search_matches( + sg: &crate::tui::index::DocSubgroup, + search: Option<&str>, +) -> bool { + sg.files.iter().any(|e| matches_search(e, search)) + || sg + .user_dirs + .iter() + .any(|ud| ud.files.iter().any(|e| matches_search(e, search))) +} + fn group_has_matches(group: &crate::tui::index::DocGroup, search: Option<&str>) -> bool { if group.files.iter().any(|e| matches_search(e, search)) { return true; } - group - .subgroups - .iter() - .any(|sg| sg.files.iter().any(|e| matches_search(e, search))) + group.subgroups.iter().any(|sg| { + sg.files.iter().any(|e| matches_search(e, search)) + || sg + .user_dirs + .iter() + .any(|ud| ud.files.iter().any(|e| matches_search(e, search))) + }) } fn matches_search(entry: &DocEntry, search: Option<&str>) -> bool { @@ -191,26 +320,9 @@ fn matches_search(entry: &DocEntry, search: Option<&str>) -> bool { }; let query = q.to_lowercase(); - // Search in filename - if entry.filename.to_lowercase().contains(&query) { - return true; - } - // Search in title - if entry.title.to_lowercase().contains(&query) { - return true; - } - // Search in tags - if entry.tags.iter().any(|t| t.to_lowercase().contains(&query)) { - return true; - } - // Search in created date - if !entry.created.is_empty() && entry.created.contains(&query) { - return true; - } - // Search in id - if entry.id.to_lowercase().contains(&query) { - return true; - } - - false + entry.filename.to_lowercase().contains(&query) + || entry.title.to_lowercase().contains(&query) + || entry.tags.iter().any(|t| t.to_lowercase().contains(&query)) + || (!entry.created.is_empty() && entry.created.contains(&query)) + || entry.id.to_lowercase().contains(&query) }