diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 198f242..7eb2e0c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -61,7 +61,10 @@ Plans: 3. User can press Backspace or a back key to return to the previous document, and then press a forward key to return to where they were 4. User can Tab-cycle between links on a page to select and follow them without using a mouse 5. User sees their current location (breadcrumb) in the status bar -**Plans**: TBD +**Plans:** 2 plans +Plans: +- [ ] 03-01-PLAN.md — Renderer link extraction, wiki-link styling, and vault link resolution +- [ ] 03-02-PLAN.md — App navigation: history, link cycling, draw-time selection, breadcrumb status bar ### Phase 4: BBS Polish and Live Content **Goal**: The app feels like a real BBS — the index page has an ANSI art header, pages show last-updated timestamps, a vault directory is browsable, and content updates live without restarting @@ -83,5 +86,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 |-------|----------------|--------|-----------| | 1. Safety Foundation | 3/3 | Complete | 2026-02-28 | | 2. Vault Core and Rendering | 3/3 | Complete | 2026-02-28 | -| 3. Navigation and Links | 0/TBD | Not started | - | +| 3. Navigation and Links | 0/2 | Not started | - | | 4. BBS Polish and Live Content | 0/TBD | Not started | - | diff --git a/.planning/phases/03-navigation-and-links/03-01-PLAN.md b/.planning/phases/03-navigation-and-links/03-01-PLAN.md new file mode 100644 index 0000000..3e80b40 --- /dev/null +++ b/.planning/phases/03-navigation-and-links/03-01-PLAN.md @@ -0,0 +1,221 @@ +--- +phase: 03-navigation-and-links +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/renderer.rs + - src/vault.rs +autonomous: true +requirements: + - NAV-01 + - NAV-02 + - NAV-10 + +must_haves: + truths: + - "render_markdown returns both styled lines and a parallel Vec for every link in the document" + - "Wiki-links ([[Page Name]]) are parsed via pulldown-cmark ENABLE_WIKILINKS and rendered as bracket-wrapped LightCyan text" + - "Standard markdown links ([text](path.md)) are rendered as bracket-wrapped LightCyan text" + - "Broken wiki-links (dest does not resolve in vault) are rendered as red text with CROSSED_OUT modifier" + - "resolve_wiki_link() maps raw wiki targets to vault-relative file paths with case-insensitive matching" + - "Path traversal attacks via ../.. are blocked by canonicalize + starts_with guard" + artifacts: + - path: "src/renderer.rs" + provides: "LinkRecord struct, updated render_markdown returning (Vec, Vec)" + contains: "pub struct LinkRecord" + - path: "src/vault.rs" + provides: "Wiki-link resolution and path traversal guard" + contains: "pub fn resolve_wiki_link" + key_links: + - from: "src/renderer.rs" + to: "pulldown-cmark ENABLE_WIKILINKS" + via: "Options::ENABLE_WIKILINKS inserted in render_markdown" + pattern: "ENABLE_WIKILINKS" + - from: "src/renderer.rs" + to: "src/vault.rs" + via: "LinkRecord.is_wiki flag consumed by app.rs to decide whether to call resolve_wiki_link" + pattern: "is_wiki" + - from: "src/vault.rs" + to: "std::fs::read_dir" + via: "resolve_wiki_link scans vault directory for case-insensitive matches" + pattern: "read_dir" +--- + + +Extend the renderer to extract link metadata and render links with BBS-style bracket-wrapped styling, and add wiki-link resolution to the vault module. + +Purpose: The renderer currently ignores links (Phase 2 pass-through). This plan makes links visible and extractable so Plan 02 can wire navigation. Wiki-link resolution is the vault-side counterpart that maps raw targets to actual files. + +Output: +- `render_markdown()` returns `(Vec>, Vec)` instead of `Vec>` +- `LinkRecord` struct with line_index, col_offset, span_len, dest, is_wiki +- All links rendered as `[Link Text]` in LightCyan; broken wiki-links in Red+CROSSED_OUT +- `resolve_wiki_link()` in vault.rs with path traversal guard + + + +@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ruohki/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-navigation-and-links/03-RESEARCH.md +@src/renderer.rs +@src/vault.rs + + + + + + Task 1: Extend renderer with LinkRecord extraction and link styling + src/renderer.rs + +Modify `src/renderer.rs` to produce link metadata alongside styled lines: + +1. **Add LinkRecord struct** (public, at top of file): +```rust +pub struct LinkRecord { + pub line_index: usize, // Index into Vec where this link appears + pub col_offset: usize, // Character column offset within that line + pub span_len: usize, // Display length of the link text (including brackets) + pub dest: String, // Raw destination (path for inline, wiki target for wiki-links) + pub is_wiki: bool, // True if WikiLink type (needs resolution at nav time) +} +``` + +2. **Add PendingLink to RenderState**: +- Add fields: `pending_link: Option` and `pending_link_records: Vec` and `link_records: Vec` +- `PendingLink` holds `dest: String`, `is_wiki: bool`, `col_offset: usize` (computed from sum of current_spans character lengths at Tag::Link Start) +- `PendingLinkRecord` holds `dest`, `is_wiki`, `col_offset` (finalized at TagEnd::Link, awaiting line_index from flush) + +3. **Enable ENABLE_WIKILINKS** in `render_markdown()`: +- Add `opts.insert(Options::ENABLE_WIKILINKS);` to the options block +- **IMPORTANT**: Test TextMergeStream interaction — if wiki-link text is garbled, wrap only the parser without TextMergeStream for wiki-link detection and apply TextMergeStream separately for code blocks. Research indicates this is LOW risk but must be verified. + +4. **Handle Tag::Link Start event** (replace the current pass-through): +```rust +Event::Start(Tag::Link { link_type, dest_url, .. }) => { + let is_wiki = matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. }); + let col_offset: usize = state.current_spans.iter() + .map(|s| s.content.chars().count()) + .sum(); + state.pending_link = Some(PendingLink { + dest: dest_url.to_string(), + is_wiki, + col_offset, + }); + // Determine link color: broken wiki-links get Red+CROSSED_OUT + // For wiki-links, check at render time whether the link resolves. + // Pass vault_path into render_markdown for this check. + // Push opening bracket "[" in appropriate color +} +``` + +For broken wiki-link detection at render time: `render_markdown` needs access to `vault_path` to call `resolve_wiki_link` for wiki-links. Add `vault_path: Option<&Path>` parameter. When `is_wiki` and vault_path is Some, call `crate::vault::resolve_wiki_link(vault_path, &dest_url)` — if None, use red/strikethrough style; if Some, use LightCyan style. For non-wiki links, always use LightCyan (broken standard links are revealed only at navigation time per research recommendation). + +The link style determines the color of brackets and text: +- Normal link: `Style::default().fg(Color::LightCyan)` +- Broken wiki-link: `Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT)` + +Push opening bracket span: `state.current_spans.push(Span::styled("[", link_style))` +Set a flag so Text events inside the link use the link style instead of current_style(). + +5. **Handle TagEnd::Link**: +- Push closing bracket span `"]"` with same link style +- Compute `span_len` = chars count of all spans pushed between Start and End (including brackets) +- Create a `PendingLinkRecord { dest, is_wiki, col_offset, span_len }` and push to `state.pending_link_records` +- Clear `state.pending_link` + +6. **Update flush_line()** to finalize link records: +After pushing the line to `self.lines`, iterate `self.pending_link_records`, drain them, and for each create a `LinkRecord` with `line_index = self.lines.len() - 1` (the just-pushed line). Push to `self.link_records`. + +7. **Update finish()** to return the pair: +Change return type to `(Vec>, Vec)`. Return `(self.lines, self.link_records)`. + +8. **Update render_markdown() signature**: +```rust +pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec) +``` + +Note: Using `chars().count()` for col_offset instead of `len()` — handles multi-byte Unicode correctly per research Pitfall 4. + + +`cargo build` compiles with zero new errors. The function signature change will cause errors in app.rs and main.rs which are expected — Plan 02 will fix those callers. To verify in isolation, temporarily adjust the call sites in app.rs `handle_resize` and main.rs to destructure the tuple (e.g., `let (lines, _links) = renderer::render_markdown(...)`) so `cargo build` passes. If this is done, add a `// TODO(03-02): use link_records` comment. + + +render_markdown returns (Vec<Line>, Vec<LinkRecord>). Wiki-links parsed via ENABLE_WIKILINKS. Links display as bracket-wrapped colored text. Broken wiki-links show red/strikethrough. LinkRecord contains line_index, col_offset, span_len, dest, is_wiki for every link in the document. + + + + + Task 2: Add wiki-link resolution and path traversal guard to vault.rs + src/vault.rs + +Add two public functions to `src/vault.rs`: + +1. **`is_within_vault(vault_path: &Path, candidate: &Path) -> bool`**: +```rust +pub fn is_within_vault(vault_path: &Path, candidate: &Path) -> bool { + match (candidate.canonicalize(), vault_path.canonicalize()) { + (Ok(canon_candidate), Ok(canon_vault)) => canon_candidate.starts_with(&canon_vault), + _ => false, + } +} +``` + +2. **`resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option`**: + +Algorithm (per locked decisions — case-insensitive, hyphens first, then underscores, then literal): +- Split raw_target on last `/` to get (subdir, name). If no `/`, subdir is empty. +- Generate candidates: `name.replace(' ', "-")`, `name.replace(' ', "_")`, `name.to_string()` (literal spaces) +- Compute search_dir: `vault_path.join(subdir)` if subdir is non-empty, otherwise vault_path +- Call `std::fs::read_dir(&search_dir)` — return None on error +- For each entry, get filename, strip `.md` suffix, lowercase compare against each candidate (lowercased) +- On first match: construct full path, call `is_within_vault()` to guard against path traversal +- If within vault: return `Some(canonical.strip_prefix(vault_canonical).to_path_buf())` — vault-relative path +- If no match found: return None + +Per locked decision: first match wins (filesystem order). This is documented as intentional in the research. + +3. **`resolve_standard_link(vault_path: &Path, current_doc: &str, dest: &str) -> Option`**: + +For standard markdown links `[text](path.md)`: +- Compute base_dir from current_doc: `vault_path.join(current_doc).parent()` (directory of current document) +- Join: `base_dir.join(dest)` +- Apply is_within_vault() guard +- If file exists and within vault: return vault-relative path +- Otherwise: return None + +This function is needed for Plan 02 when following standard links. Adding it here keeps all vault path resolution in one place. + + +`cargo build` compiles. Add a small test at the bottom of vault.rs (or verify mentally): `resolve_wiki_link` with a known vault directory returns the expected path; `is_within_vault` rejects `../../etc/passwd`. + + +resolve_wiki_link() resolves raw wiki targets to vault-relative paths with case-insensitive multi-strategy matching. resolve_standard_link() resolves relative markdown link paths. Both are guarded by is_within_vault() which uses canonicalize + starts_with to prevent path traversal. + + + + + + +1. `cargo build` succeeds (caller sites temporarily updated to handle new return type) +2. render_markdown with a document containing `[[Test Link]]` and `[Click](page.md)` produces LinkRecords with correct is_wiki, dest, line_index, col_offset, span_len +3. Links render as bracket-wrapped colored text: `[Test Link]` in LightCyan, broken `[[Missing]]` in Red+CROSSED_OUT +4. resolve_wiki_link with "Getting Started" matches "getting-started.md" (case-insensitive, hyphen strategy) +5. resolve_wiki_link with "guides/Getting Started" resolves subpath correctly +6. is_within_vault rejects candidates outside the vault root + + + +The renderer produces a parallel link metadata structure alongside styled lines. Wiki-links and standard links are visually distinct from plain text. The vault module can resolve wiki-link targets to file paths safely. Plan 02 can consume these outputs to wire navigation. + + + +After completion, create `.planning/phases/03-navigation-and-links/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-navigation-and-links/03-02-PLAN.md b/.planning/phases/03-navigation-and-links/03-02-PLAN.md new file mode 100644 index 0000000..d38130d --- /dev/null +++ b/.planning/phases/03-navigation-and-links/03-02-PLAN.md @@ -0,0 +1,589 @@ +--- +phase: 03-navigation-and-links +plan: 02 +type: execute +wave: 2 +depends_on: + - "03-01" +files_modified: + - src/app.rs + - src/main.rs +autonomous: true +requirements: + - NAV-01 + - NAV-02 + - NAV-03 + - NAV-04 + - NAV-10 + - NAV-11 + +must_haves: + truths: + - "User can press Tab to cycle forward through links on a page, with wrap-around from last to first" + - "User can press Shift+Tab to cycle backward through links" + - "When Tab-cycling to an off-screen link, the view auto-scrolls to center the link" + - "User can press Enter on a selected link to navigate to that document" + - "User can press Backspace or Alt+Left to go back to the previous document with restored scroll and link selection" + - "User can press Alt+Right to go forward after going back" + - "Forward stack is cleared when following a new link after going back (browser-style fork)" + - "Status bar shows breadcrumb trail (e.g. docs > guides > getting-started) with .md stripped" + - "Status bar shows back/forward indicators only when history exists in that direction" + - "Status bar shows Link 3/7 counter when a link is selected" + - "Selected link is rendered with inverted colors (REVERSED modifier) at draw time" + artifacts: + - path: "src/app.rs" + provides: "Navigation history, link cycling, draw-time selection, breadcrumb status bar" + contains: "struct HistoryEntry" + - path: "src/main.rs" + provides: "Updated startup wiring for new render_markdown signature" + contains: "render_markdown" + key_links: + - from: "src/app.rs" + to: "src/renderer.rs" + via: "App stores Vec from render_markdown and uses it for Tab cycling and Enter follow" + pattern: "link_records" + - from: "src/app.rs" + to: "src/vault.rs" + via: "navigate_to calls vault::load_document and vault::resolve_wiki_link" + pattern: "resolve_wiki_link" + - from: "src/app.rs" + to: "src/renderer.rs" + via: "navigate_to re-renders loaded document via render_markdown" + pattern: "render_markdown" + - from: "src/main.rs" + to: "src/renderer.rs" + via: "Initial document load destructures (lines, link_records) tuple" + pattern: "render_markdown" +--- + + +Wire link navigation, history stack, Tab-cycling, and breadcrumb status bar into the app event loop. + +Purpose: This plan turns the static document viewer into an interactive vault browser. Users can follow links, navigate back/forward, Tab-cycle between links, and see where they are via breadcrumbs — the core browsing experience. + +Output: +- HistoryEntry struct with path, scroll_offset, selected_link for full state restoration +- Tab/Shift-Tab link cycling with wrap-around and auto-scroll +- Enter to follow selected link (wiki-link resolution + standard link resolution) +- Backspace and Alt+Left/Right for back/forward navigation +- Draw-time REVERSED modifier on selected link +- Breadcrumb trail, back/forward indicators, and link counter in status bar +- main.rs updated for new render_markdown return type + + + +@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md +@/Users/ruohki/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-navigation-and-links/03-RESEARCH.md +@.planning/phases/03-navigation-and-links/03-01-SUMMARY.md +@src/app.rs +@src/main.rs +@src/renderer.rs +@src/vault.rs + + + + + + Task 1: Add navigation state, history, link cycling, and navigate_to to App + src/app.rs, src/main.rs + +Modify `src/app.rs` and `src/main.rs` to support full link navigation: + +**1. Add HistoryEntry struct** (in app.rs, before App): +```rust +/// A snapshot of navigation state for back/forward history. +struct HistoryEntry { + /// Vault-relative path (e.g. "guides/getting-started.md") + path: String, + /// Scroll offset at time of navigation away from this page + scroll_offset: u16, + /// Selected link index at time of navigation (None if no link was selected) + selected_link: Option, +} +``` + +**2. Add navigation fields to App struct**: +```rust +// ── Phase 3 additions ───────────────────────────────────────────────────── +/// Browser-style navigation history. Vec of visited pages with state. +history: Vec, +/// Current position in history (index into history Vec). +history_index: usize, +/// Link records from the current rendered document. +link_records: Vec, +/// Index of the currently selected link (None = no link selected). +selected_link: Option, +/// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). +current_path: String, +``` + +**3. Update App::new()** to accept `link_records` and `current_path`: +```rust +pub fn new( + is_login_shell: bool, + config: Config, + document: DocumentState, + raw_content: Option, + link_records: Vec, + current_path: String, +) -> Self +``` +Initialize `history` with one entry for the initial page (scroll 0, no selected link), `history_index: 0`, `link_records`, `selected_link: None`, `current_path`. + +**4. Add navigate_to() method**: +```rust +/// Navigate to a new document by vault-relative path. +/// +/// Saves current state to history, loads the new document, renders it, +/// and updates all navigation state. If history_index is not at the end, +/// truncates forward history (browser-style fork). +fn navigate_to(&mut self, vault_relative: &str) { + let vault_path = &self.config.vault_path; + + // 1. Save current state to history at current position + // Update the entry at history_index with current scroll + selection + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + // 2. Truncate forward history if we navigated back then follow a new link + self.history.truncate(self.history_index + 1); + + // 3. Load new document + match crate::vault::load_document(vault_path, vault_relative) { + crate::vault::VaultDocument::Loaded { path, content } => { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| vault_relative.to_string()); + let width = // get current terminal width from last draw or crossterm::terminal::size() + ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); + let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); + + self.document = DocumentState::Loaded { filename, lines }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = None; + self.scroll_offset = 0; + self.current_path = vault_relative.to_string(); + self.filename = // extract from vault_relative + vault_relative.to_string(); + + // 4. Push new history entry + self.history.push(HistoryEntry { + path: vault_relative.to_string(), + scroll_offset: 0, + selected_link: None, + }); + self.history_index = self.history.len() - 1; + } + crate::vault::VaultDocument::Missing { path } => { + // Show error screen for missing link target — do NOT push to history + // (user stays on current page conceptually, just sees the error) + self.document = DocumentState::Missing { path }; + self.raw_content = None; + self.link_records = Vec::new(); + self.selected_link = None; + } + crate::vault::VaultDocument::ReadError { path, reason } => { + self.document = DocumentState::Error { path, reason }; + self.raw_content = None; + self.link_records = Vec::new(); + self.selected_link = None; + } + } +} +``` + +**5. Add navigate_back() and navigate_forward() methods**: +```rust +fn navigate_back(&mut self) { + if self.history_index == 0 { return; } + + // Save current state + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + self.history_index -= 1; + let entry = &self.history[self.history_index]; + let target_path = entry.path.clone(); + let target_scroll = entry.scroll_offset; + let target_link = entry.selected_link; + + // Re-load and re-render the document (per research: don't cache rendered output) + let vault_path = &self.config.vault_path; + if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(vault_path, &target_path) { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| target_path.clone()); + let width = ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); + let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); + + self.document = DocumentState::Loaded { filename, lines }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = target_link; + self.scroll_offset = target_scroll; + self.current_path = target_path; + self.filename = self.current_path.clone(); + } + // If file was deleted since last visit, leave current doc unchanged +} + +fn navigate_forward(&mut self) { + if self.history_index >= self.history.len().saturating_sub(1) { return; } + + // Save current state + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + + self.history_index += 1; + // Same re-load logic as navigate_back + let entry = &self.history[self.history_index]; + let target_path = entry.path.clone(); + let target_scroll = entry.scroll_offset; + let target_link = entry.selected_link; + + let vault_path = &self.config.vault_path; + if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(vault_path, &target_path) { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| target_path.clone()); + let width = ratatui::crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); + let (lines, link_records) = crate::renderer::render_markdown(&content, width, Some(vault_path)); + + self.document = DocumentState::Loaded { filename, lines }; + self.raw_content = Some(content); + self.link_records = link_records; + self.selected_link = target_link; + self.scroll_offset = target_scroll; + self.current_path = target_path; + self.filename = self.current_path.clone(); + } +} +``` + +**6. Add follow_selected_link() method**: +```rust +fn follow_selected_link(&mut self) { + let link_index = match self.selected_link { + Some(i) if i < self.link_records.len() => i, + _ => return, // No link selected or index out of bounds + }; + + let record = &self.link_records[link_index]; + let dest = record.dest.clone(); + let is_wiki = record.is_wiki; + + let vault_path = self.config.vault_path.clone(); + + if is_wiki { + // Resolve wiki-link to vault-relative path + match crate::vault::resolve_wiki_link(&vault_path, &dest) { + Some(resolved) => { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } + None => { + // Broken wiki-link — already shown as red/strikethrough in render. + // Optionally: could show an error flash. For now, do nothing. + } + } + } else { + // Standard markdown link — resolve relative to current document's directory + match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) { + Some(resolved) => { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } + None => { + // Broken link — show error page + let full_path = vault_path.join(&dest); + self.document = DocumentState::Missing { path: full_path }; + self.link_records = Vec::new(); + self.selected_link = None; + } + } + } +} +``` + +**7. Add link cycling helpers**: +```rust +fn select_next_link(&mut self) { + if self.link_records.is_empty() { return; } + let next = match self.selected_link { + Some(i) => (i + 1) % self.link_records.len(), // Wrap around per locked decision + None => 0, // First Tab press selects the first link + }; + self.selected_link = Some(next); + self.scroll_to_selected_link(); +} + +fn select_prev_link(&mut self) { + if self.link_records.is_empty() { return; } + let prev = match self.selected_link { + Some(0) => self.link_records.len() - 1, // Wrap to last + Some(i) => i - 1, + None => self.link_records.len() - 1, // First Shift+Tab selects last link + }; + self.selected_link = Some(prev); + self.scroll_to_selected_link(); +} + +/// Auto-scroll to center the selected link on screen if it's off-screen. +fn scroll_to_selected_link(&mut self) { + if let Some(i) = self.selected_link { + if let Some(record) = self.link_records.get(i) { + let link_line = record.line_index as u16; + let viewport_start = self.scroll_offset; + let viewport_end = viewport_start + self.last_content_height; + + if link_line < viewport_start || link_line >= viewport_end { + // Center the link on screen + let half = self.last_content_height / 2; + self.scroll_offset = link_line.saturating_sub(half); + // Clamp to max scroll + let max = self.max_scroll(); + if self.scroll_offset > max { + self.scroll_offset = max; + } + } + } + } +} +``` + +**8. Update handle_key()** — add new key bindings BEFORE the existing scroll keys (but after Ctrl+C and 'q'): + +```rust +// ── Navigation keys — add after 'q' handler, before scroll keys ────── +KeyCode::Tab => { + self.select_next_link(); +} +KeyCode::BackTab => { + self.select_prev_link(); +} +KeyCode::Enter => { + self.follow_selected_link(); +} +KeyCode::Backspace => { + self.navigate_back(); +} +// Alt+Left = back, Alt+Right = forward +KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => { + self.navigate_back(); +} +KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => { + self.navigate_forward(); +} +``` + +**IMPORTANT**: The Alt+Left/Right bindings must come BEFORE the existing Down/Up handlers. Since the existing handlers are `KeyCode::Down` and `KeyCode::Up`, there's no conflict. But ensure the pattern matching order is: Ctrl+C > q > Tab > BackTab > Enter > Backspace > Alt+Left > Alt+Right > j/k/Down/Up > PgDn/PgUp > _ catch-all. + +**9. Update handle_resize()** to use new render_markdown signature: +```rust +let (lines, link_records) = crate::renderer::render_markdown(content, new_width, Some(&self.config.vault_path)); +// ... update self.link_records = link_records; +// Preserve selected_link if still valid +if let Some(i) = self.selected_link { + if i >= self.link_records.len() { + self.selected_link = None; + } +} +``` + +**10. Update main.rs** for new render_markdown signature: +- Change `renderer::render_markdown(&content, initial_width)` to `renderer::render_markdown(&content, initial_width, Some(&app_config.vault_path))` +- Destructure: `let (lines, link_records) = renderer::render_markdown(...)` +- Pass `link_records` and `"index.md".to_string()` to `App::new()` +- Update `App::new()` call to include new parameters + + +`cargo build` compiles. Tab/Shift-Tab cycle through links. Enter follows a link. Backspace goes back. Alt+Right goes forward. History truncation works (navigate back, then follow new link = forward stack cleared). + + +App has full navigation: HistoryEntry with scroll+link restoration, navigate_to/back/forward, Tab/Shift-Tab cycling with wrap-around and auto-scroll, Enter to follow links (wiki via resolve_wiki_link, standard via resolve_standard_link), Backspace and Alt+Left/Right for back/forward. main.rs passes link_records and current_path to App::new. + + + + + Task 2: Draw-time link selection and breadcrumb status bar + src/app.rs + +Modify `src/app.rs` draw methods for link selection display and breadcrumb navigation status: + +**1. Update draw() content rendering** for selected link highlight: + +In the `DocumentState::Loaded` branch of `draw()`, instead of directly creating a Paragraph from `lines.clone()`, apply the REVERSED modifier to the selected link's spans at draw time: + +```rust +DocumentState::Loaded { lines, .. } => { + let display_lines = if let Some(selected_idx) = self.selected_link { + if let Some(record) = self.link_records.get(selected_idx) { + let mut cloned = lines.clone(); + // Find the line containing the selected link + if let Some(line) = cloned.get_mut(record.line_index) { + // Find and modify spans at the link's column offset + // Walk spans, summing character widths until we reach col_offset + let mut col = 0usize; + for span in line.spans.iter_mut() { + let span_chars = span.content.chars().count(); + if col >= record.col_offset && col < record.col_offset + record.span_len { + // This span is part of the selected link — add REVERSED + span.style = span.style.add_modifier(Modifier::REVERSED); + } + col += span_chars; + // Also catch spans that start within the link range + // (the link brackets + text are multiple spans) + } + } + cloned + } else { + lines.clone() + } + } else { + lines.clone() + }; + + let para = Paragraph::new(display_lines) + .scroll((self.scroll_offset, 0)); + frame.render_widget(para, content_area); +} +``` + +Note: The link's spans include `[`, the text content spans, and `]` — all consecutive in the line at the recorded col_offset. The REVERSED modifier inverts fg/bg per the locked decision for selected link appearance. + +**2. Add build_breadcrumb() function** (private helper in app.rs): +```rust +/// Build a breadcrumb trail from a vault-relative path. +/// "guides/getting-started.md" -> "guides > getting-started" +fn build_breadcrumb(vault_relative: &str) -> String { + std::path::Path::new(vault_relative) + .components() + .map(|c| { + let s = c.as_os_str().to_string_lossy(); + s.strip_suffix(".md").unwrap_or(&s).to_string() + }) + .collect::>() + .join(" > ") +} +``` + +**3. Rewrite draw_status_bar()** to include breadcrumb, back/forward indicators, and link counter: + +The status bar layout (left to right): +- Left: ` {breadcrumb} ` (e.g. ` guides > getting-started `) +- Center/Right: `< Back ` (if history_index > 0, else hidden) + `Link 3/7 ` (if link selected, else hidden) + `Forward >` (if history_index < history.len()-1, else hidden) +- Far right: keyboard hints (existing, but updated) + +```rust +fn draw_status_bar(&self, frame: &mut Frame, area: Rect) { + let width = area.width as usize; + + if self.show_quit_prompt { + // Quit prompt takes over entire bar (existing behavior) + let left = format!(" {} ", build_breadcrumb(&self.current_path)); + let right = " Press Ctrl+C again to disconnect... ".to_string(); + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + let bar = Paragraph::new(Line::from(vec![ + Span::styled( + format!("{}{}{}", left, padding, right), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + ), + ])); + frame.render_widget(bar, area); + return; + } + + // Normal status bar + let breadcrumb = build_breadcrumb(&self.current_path); + let left = format!(" {} ", breadcrumb); + + // Build right side: nav indicators + link counter + hints + let mut right_parts: Vec = Vec::new(); + + // Back indicator (per locked decision: shown only when history exists) + if self.history_index > 0 { + right_parts.push("< Back".to_string()); + } + + // Link counter (per locked decision: shown when link selected) + if let Some(i) = self.selected_link { + right_parts.push(format!("Link {}/{}", i + 1, self.link_records.len())); + } + + // Forward indicator + if self.history_index < self.history.len().saturating_sub(1) { + right_parts.push("Forward >".to_string()); + } + + // Keyboard hints + let hints = if self.is_login_shell { + "Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit" + } else { + "Tab:Links Enter:Go Bksp:Back q:Quit" + }; + right_parts.push(hints.to_string()); + + let right = format!(" {} ", right_parts.join(" ")); + + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + + let bar = Paragraph::new(Line::from(vec![ + Span::raw(format!("{}{}{}", left, padding, right)), + ])) + .style(Style::default().add_modifier(Modifier::REVERSED)); + frame.render_widget(bar, area); +} +``` + +**4. Remove the `#[allow(dead_code)]` on the config field** in App struct — it's now actively used by navigate_to/back/forward for vault_path access. + + +`cargo build` compiles with no new warnings. Status bar shows breadcrumb for current page. When Tab is pressed, selected link gets REVERSED modifier visually. Back/forward indicators appear/hide correctly based on history state. Link counter shows `Link N/M` when a link is selected. + + +Selected link displays with inverted colors (REVERSED) at draw time without mutating stored lines. Status bar shows breadcrumb trail with .md stripped and > separator. Back/forward indicators conditionally visible. Link counter shows position when a link is selected. Keyboard hints updated with navigation commands. + + + + + + +1. `cargo build` succeeds with zero errors +2. Launch with vault containing index.md with wiki-links and standard links: + - Links render as `[Link Text]` in cyan color + - Broken wiki-links render as red strikethrough +3. Press Tab — first link gets REVERSED (inverted) styling; press Tab again — next link selected +4. Press Shift+Tab — previous link selected; wrap-around works at both ends +5. Press Enter on a link — navigates to target document; status bar updates breadcrumb +6. Press Backspace — returns to previous document with scroll position and link selection restored +7. Press Alt+Right — navigates forward to where you were +8. Navigate back, then follow a new link — forward history is cleared (browser fork) +9. Status bar shows: breadcrumb left, `< Back` when history exists, `Link N/M` when selected, `Forward >` when forward exists, hints right +10. Off-screen link selected via Tab auto-scrolls to center it + + + +The app is a functional vault browser: users can Tab-cycle links, Enter to follow them, Backspace/Alt+arrows for history, and see their location via breadcrumbs. All locked decisions (bracket-wrapped links, inverted selection, breadcrumb format, back/forward indicators, link counter, wrap-around cycling, auto-scroll, browser-style fork, scroll+link restoration) are implemented. + + + +After completion, create `.planning/phases/03-navigation-and-links/03-02-SUMMARY.md` +