docs(03): create phase plan
This commit is contained in:
@@ -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<LinkRecord> 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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/ruohki/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add navigation state, history, link cycling, and navigate_to to App</name>
|
||||
<files>src/app.rs, src/main.rs</files>
|
||||
<action>
|
||||
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<usize>,
|
||||
}
|
||||
```
|
||||
|
||||
**2. Add navigation fields to App struct**:
|
||||
```rust
|
||||
// ── 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`:
|
||||
```rust
|
||||
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**:
|
||||
```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
|
||||
</action>
|
||||
<verify>
|
||||
`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).
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Draw-time link selection and breadcrumb status bar</name>
|
||||
<files>src/app.rs</files>
|
||||
<action>
|
||||
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::<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)
|
||||
|
||||
```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<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.
|
||||
</action>
|
||||
<verify>
|
||||
`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.
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-navigation-and-links/03-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user