From f0ec2edc538985d8b5ad49d5e735a21dd0283e0c Mon Sep 17 00:00:00 2001 From: ruohki Date: Sun, 1 Mar 2026 11:38:50 +0100 Subject: [PATCH] feat(quick-1): add arrow key navigation and directory descriptions - Add bare Left arrow key as alias for navigate_back (same as Backspace) - Add bare Right arrow key as alias for navigate_forward (same as Alt+Right) - Update handle_key doc comment to list new Left/Right bindings - Add description field to DirEntry struct in vault.rs - Add extract_frontmatter_description() to parse YAML frontmatter - Populate description from frontmatter in list_vault_files() - Show descriptions in DarkGray beside file entries in build_directory_lines() - Truncate descriptions to keep total line width within 78 chars - Link span_len covers only [name] portion, not the description --- src/app.rs | 48 +++++++++++++++++++++++++++++++++++------ src/vault.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index aecd340..a81917b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -450,6 +450,8 @@ impl App { /// - `Backspace` — navigate back in history /// - `Alt+Left` — navigate back in history /// - `Alt+Right` — navigate forward in history + /// - `Left` — navigate back in history (same as Backspace) + /// - `Right` — navigate forward in history (same as Alt+Right) /// - `j` / `Down` — scroll down one line /// - `k` / `Up` — scroll up one line /// - `PgDn` — scroll down one page @@ -498,6 +500,13 @@ impl App { KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => { self.navigate_forward(); } + // Bare Left/Right arrows also navigate back/forward + KeyCode::Left => { + self.navigate_back(); + } + KeyCode::Right => { + self.navigate_forward(); + } // ── Scrolling keys — do NOT dismiss the quit prompt ─────────────── KeyCode::Char('j') | KeyCode::Down => { self.scroll_down(1); @@ -1362,13 +1371,40 @@ fn build_directory_lines( let span_len = display.chars().count(); let line_index = lines.len(); // This line's index in the lines vec - lines.push(Line::from(vec![ + // Build spans: indent + [name] + optional description in DarkGray + let mut spans = vec![ Span::raw(indent), - Span::styled( - display, - Style::default().fg(Color::LightCyan), - ), - ])); + Span::styled(display.clone(), Style::default().fg(Color::LightCyan)), + ]; + + if let Some(ref desc) = entry.description { + // Truncate description so total line width stays within ~78 chars + // Total used: indent_chars + span_len + 2 (separator) + desc + let budget = 78usize + .saturating_sub(indent_chars) + .saturating_sub(span_len) + .saturating_sub(2); // 2 spaces separator + let truncated = if desc.chars().count() > budget { + if budget > 3 { + let trimmed: String = desc.chars().take(budget - 3).collect(); + format!("{}...", trimmed) + } else { + String::new() + } + } else { + desc.clone() + }; + + if !truncated.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + truncated, + Style::default().fg(Color::DarkGray), + )); + } + } + + lines.push(Line::from(spans)); link_records.push(crate::renderer::LinkRecord { line_index, diff --git a/src/vault.rs b/src/vault.rs index 7373beb..5337d36 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1,4 +1,4 @@ -use std::io; +use std::io::{self, BufRead}; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -201,6 +201,62 @@ pub struct DirEntry { pub is_dir: bool, /// Vault-relative path (only for .md files; None for directories). pub vault_path: Option, + /// Short description from the file's YAML frontmatter `description:` field. + /// None if the file has no frontmatter or no description key. + pub description: Option, +} + +// ── Frontmatter parsing ─────────────────────────────────────────────────────── + +/// Extract the `description` value from a YAML frontmatter block at the top of a file. +/// +/// Reads only the first 20 lines to avoid loading large files entirely. +/// The frontmatter must start with `---` on the first line and be closed by +/// another `---` line. The `description:` key is matched case-sensitively. +/// +/// Handles both bare values (`description: some text`) and quoted values +/// (`description: "some text"`). +/// +/// Returns `Some(description)` if found, `None` otherwise. +fn extract_frontmatter_description(path: &Path) -> Option { + let file = std::fs::File::open(path).ok()?; + let reader = io::BufReader::new(file); + + let mut lines = reader.lines().take(20); + + // First line must be exactly "---" + let first = lines.next()?.ok()?; + if first.trim() != "---" { + return None; + } + + // Scan remaining lines for description key, stop at closing "---" + for line in lines { + let line = line.ok()?; + let trimmed = line.trim(); + + if trimmed == "---" { + // End of frontmatter — description not found + break; + } + + if let Some(rest) = trimmed.strip_prefix("description:") { + let value = rest.trim(); + // Strip surrounding quotes if present + let value = if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + &value[1..value.len() - 1] + } else { + value + }; + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None } /// List all markdown files and directories in the vault, sorted alphabetically. @@ -231,6 +287,7 @@ pub fn list_vault_files(vault_path: &Path) -> Vec { name, is_dir: true, vault_path: None, + description: None, }); } else if name.ends_with(".md") { let display_name = name.strip_suffix(".md").unwrap_or(&name).to_string(); @@ -240,11 +297,13 @@ pub fn list_vault_files(vault_path: &Path) -> Vec { .unwrap_or(entry.path()) .to_string_lossy() .to_string(); + let description = extract_frontmatter_description(entry.path()); entries.push(DirEntry { depth, name: display_name, is_dir: false, vault_path: Some(rel), + description, }); } // Skip non-.md files silently