Files
2026-02-28 22:54:12 +01:00

24 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-navigation-and-links 02 execute 2
03-01
src/app.rs
src/main.rs
true
NAV-01
NAV-02
NAV-03
NAV-04
NAV-10
NAV-11
truths artifacts key_links
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
path provides contains
src/app.rs Navigation history, link cycling, draw-time selection, breadcrumb status bar struct HistoryEntry
path provides contains
src/main.rs Updated startup wiring for new render_markdown signature render_markdown
from to via pattern
src/app.rs src/renderer.rs App stores Vec<LinkRecord> from render_markdown and uses it for Tab cycling and Enter follow link_records
from to via pattern
src/app.rs src/vault.rs navigate_to calls vault::load_document and vault::resolve_wiki_link resolve_wiki_link
from to via pattern
src/app.rs src/renderer.rs navigate_to re-renders loaded document via render_markdown render_markdown
from to via pattern
src/main.rs src/renderer.rs Initial document load destructures (lines, link_records) tuple 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

<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

/// 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<usize>,
}

2. Add navigation fields to App struct:

// ── Phase 3 additions ─────────────────────────────────────────────────────
/// Browser-style navigation history. Vec of visited pages with state.
history: Vec<HistoryEntry>,
/// Current position in history (index into history Vec).
history_index: usize,
/// Link records from the current rendered document.
link_records: Vec<crate::renderer::LinkRecord>,
/// Index of the currently selected link (None = no link selected).
selected_link: Option<usize>,
/// 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:

pub fn new(
    is_login_shell: bool,
    config: Config,
    document: DocumentState,
    raw_content: Option<String>,
    link_records: Vec<crate::renderer::LinkRecord>,
    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:

/// 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:

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:

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:

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'):

// ── 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:

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:

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):

/// 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::<Vec<_>>()
        .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)
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<String> = 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

<success_criteria> 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. </success_criteria>

After completion, create `.planning/phases/03-navigation-and-links/03-02-SUMMARY.md`