feat(03-02): add navigation history, link cycling, and navigate_to to App

- Add HistoryEntry struct with path, scroll_offset, selected_link fields
- Add Phase 3 navigation fields to App: history, history_index, link_records, selected_link, current_path
- Update App::new() to accept link_records and current_path parameters
- Implement navigate_to() with browser-style forward history truncation
- Implement navigate_back() and navigate_forward() with state restoration
- Implement follow_selected_link() for wiki-link and standard link resolution
- Implement select_next_link() and select_prev_link() with wrap-around
- Implement scroll_to_selected_link() for auto-scroll centering
- Add Tab, Shift-Tab, Enter, Backspace, Alt+Left, Alt+Right key bindings
- Update handle_resize() to capture and validate link_records after re-render
- Update main.rs to destructure link_records and pass to App::new()
- Remove #[allow(dead_code)] on config field (now actively used)
This commit is contained in:
2026-02-28 23:10:23 +01:00
parent 8ea4545c9b
commit d705313aae
2 changed files with 481 additions and 63 deletions
+445 -44
View File
@@ -3,10 +3,12 @@
//! //!
//! # Design //! # Design
//! //!
//! The event loop is the convergence point for all Phase 1 and Phase 2 behavior: //! The event loop is the convergence point for all Phase 1, 2, and 3 behavior:
//! - Signal polling (SIGHUP/SIGTERM) checked first each iteration //! - Signal polling (SIGHUP/SIGTERM) checked first each iteration
//! - Double-press Ctrl+C state machine with a 2-second window //! - Double-press Ctrl+C state machine with a 2-second window
//! - Login shell mode suppresses the 'q' key quit shortcut //! - Login shell mode suppresses the 'q' key quit shortcut
//! - Tab/Shift-Tab cycle through links; Enter follows the selected link
//! - Backspace and Alt+Left/Right navigate the browser-style history stack
//! - j/k and arrow keys scroll content one line; PgUp/PgDn scroll one page //! - j/k and arrow keys scroll content one line; PgUp/PgDn scroll one page
//! - Terminal resize triggers re-render of raw markdown at new width //! - Terminal resize triggers re-render of raw markdown at new width
//! - Clean shutdown path restores terminal before displaying the goodbye message //! - Clean shutdown path restores terminal before displaying the goodbye message
@@ -65,9 +67,21 @@ pub enum ShutdownReason {
Signal, Signal,
} }
// ── HistoryEntry ──────────────────────────────────────────────────────────────
/// 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>,
}
// ── App ─────────────────────────────────────────────────────────────────────── // ── App ───────────────────────────────────────────────────────────────────────
/// Application state for the Phase 2 event loop. /// Application state for the bbs-md event loop.
pub struct App { pub struct App {
// ── Phase 1 fields (preserved exactly) ─────────────────────────────────── // ── Phase 1 fields (preserved exactly) ───────────────────────────────────
/// Whether the process was launched as a login shell (argv[0] starts with '-'). /// Whether the process was launched as a login shell (argv[0] starts with '-').
@@ -85,7 +99,6 @@ pub struct App {
should_quit: bool, should_quit: bool,
/// Loaded application configuration (vault_path, theme). /// Loaded application configuration (vault_path, theme).
#[allow(dead_code)]
config: Config, config: Config,
// ── Phase 2 additions ───────────────────────────────────────────────────── // ── Phase 2 additions ─────────────────────────────────────────────────────
@@ -103,25 +116,47 @@ pub struct App {
/// Height of the content area from the last draw, used for page scrolling. /// Height of the content area from the last draw, used for page scrolling.
last_content_height: u16, last_content_height: u16,
// ── 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,
} }
impl App { impl App {
/// Create a new `App` with the given document state. /// Create a new `App` with the given document state.
/// ///
/// `is_login_shell` controls whether the 'q' key is active. /// `is_login_shell` controls whether the 'q' key is active.
/// `config` is stored for future use (vault_path for navigation in Phase 3+). /// `config` is stored for vault_path access during navigation.
/// `document` is the initial document to display. /// `document` is the initial document to display.
/// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render. /// `raw_content` is the raw markdown string (Some if Loaded) for resize re-render.
/// `link_records` are the link metadata records from the initial render.
/// `current_path` is the vault-relative path of the initial document.
pub fn new( pub fn new(
is_login_shell: bool, is_login_shell: bool,
config: Config, config: Config,
document: DocumentState, document: DocumentState,
raw_content: Option<String>, raw_content: Option<String>,
link_records: Vec<crate::renderer::LinkRecord>,
current_path: String,
) -> Self { ) -> Self {
let filename = match &document { let filename = match &document {
DocumentState::Loaded { filename, .. } => filename.clone(), DocumentState::Loaded { filename, .. } => filename.clone(),
DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(), DocumentState::Missing { .. } | DocumentState::Error { .. } => "ERROR".to_string(),
}; };
// Initialize history with one entry for the initial page
let initial_history = vec![HistoryEntry {
path: current_path.clone(),
scroll_offset: 0,
selected_link: None,
}];
App { App {
is_login_shell, is_login_shell,
ctrl_c_pressed_at: None, ctrl_c_pressed_at: None,
@@ -133,6 +168,11 @@ impl App {
raw_content, raw_content,
filename, filename,
last_content_height: 24, last_content_height: 24,
history: initial_history,
history_index: 0,
link_records,
selected_link: None,
current_path,
} }
} }
@@ -201,8 +241,18 @@ impl App {
/// We re-render so horizontal rules, code block borders, and table widths adapt. /// We re-render so horizontal rules, code block borders, and table widths adapt.
fn handle_resize(&mut self, new_width: u16) { fn handle_resize(&mut self, new_width: u16) {
if let Some(ref content) = self.raw_content.clone() { if let Some(ref content) = self.raw_content.clone() {
// TODO(03-02): use link_records from render_markdown for Tab-cycling navigation let (lines, link_records) = crate::renderer::render_markdown(
let (lines, _link_records) = crate::renderer::render_markdown(content, new_width, None); content,
new_width,
Some(&self.config.vault_path),
);
self.link_records = link_records;
// Preserve selected_link if still valid after re-render
if let Some(i) = self.selected_link {
if i >= self.link_records.len() {
self.selected_link = None;
}
}
let filename = self.filename.clone(); let filename = self.filename.clone();
self.document = DocumentState::Loaded { filename, lines }; self.document = DocumentState::Loaded { filename, lines };
// Clamp scroll to new max after re-render // Clamp scroll to new max after re-render
@@ -215,14 +265,20 @@ impl App {
/// Handle a single key event and update app state accordingly. /// Handle a single key event and update app state accordingly.
/// ///
/// # Key bindings /// # Key bindings (in match order)
/// ///
/// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits /// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits
/// - `q` — quits immediately (suppressed in login shell mode) /// - `q` — quits immediately (suppressed in login shell mode)
/// - `Tab` — select next link (wrap-around)
/// - `Shift+Tab` — select previous link (wrap-around)
/// - `Enter` — follow the selected link
/// - `Backspace` — navigate back in history
/// - `Alt+Left` — navigate back in history
/// - `Alt+Right` — navigate forward in history
/// - `j` / `Down` — scroll down one line /// - `j` / `Down` — scroll down one line
/// - `k` / `Up` — scroll up one line /// - `k` / `Up` — scroll up one line
/// - `PgDn` — scroll down one page /// - `PgDn` — scroll down one page
/// - `PgUp` — scroll up one page /// - `PgUp` — scroll up one page
/// - Any other key — if the quit prompt is showing, dismisses it /// - Any other key — if the quit prompt is showing, dismisses it
fn handle_key(&mut self, key: KeyEvent) { fn handle_key(&mut self, key: KeyEvent) {
match key.code { match key.code {
@@ -247,6 +303,26 @@ impl App {
// 'q' is suppressed in login shell mode — only double Ctrl+C exits // 'q' is suppressed in login shell mode — only double Ctrl+C exits
self.should_quit = true; self.should_quit = true;
} }
// ── Navigation 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();
}
// ── Scrolling keys — do NOT dismiss the quit prompt ─────────────── // ── Scrolling keys — do NOT dismiss the quit prompt ───────────────
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
self.scroll_down(1); self.scroll_down(1);
@@ -272,6 +348,256 @@ impl App {
} }
} }
// ── Navigation methods ─────────────────────────────────────────────────────
/// 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.clone();
// 1. Save current state to history at current position
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 = 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: filename.clone(),
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 = filename;
// 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
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;
}
}
}
/// Navigate back one step in the history stack, restoring scroll and link selection.
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 target_path = self.history[self.history_index].path.clone();
let target_scroll = self.history[self.history_index].scroll_offset;
let target_link = self.history[self.history_index].selected_link;
// Re-load and re-render the document (per research: don't cache rendered output)
let vault_path = self.config.vault_path.clone();
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: filename.clone(),
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 = filename;
}
// If file was deleted since last visit, leave current doc unchanged
}
/// Navigate forward one step in the history stack, restoring scroll and link selection.
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;
let target_path = self.history[self.history_index].path.clone();
let target_scroll = self.history[self.history_index].scroll_offset;
let target_link = self.history[self.history_index].selected_link;
let vault_path = self.config.vault_path.clone();
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: filename.clone(),
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 = filename;
}
}
/// Follow the currently selected link, resolving wiki-links or standard links.
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 dest = self.link_records[link_index].dest.clone();
let is_wiki = self.link_records[link_index].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.
// Do nothing on Enter for broken links.
}
}
} 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;
}
}
}
}
// ── Link cycling helpers ───────────────────────────────────────────────────
/// Select the next link (Tab key), wrapping from last to first.
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
None => 0, // First Tab press selects the first link
};
self.selected_link = Some(next);
self.scroll_to_selected_link();
}
/// Select the previous link (Shift+Tab key), wrapping from first to last.
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;
}
}
}
}
}
// ── Scroll helpers ───────────────────────────────────────────────────────── // ── Scroll helpers ─────────────────────────────────────────────────────────
fn scroll_down(&mut self, n: u16) { fn scroll_down(&mut self, n: u16) {
@@ -300,7 +626,7 @@ impl App {
// ── Draw ────────────────────────────────────────────────────────────────── // ── Draw ──────────────────────────────────────────────────────────────────
/// Draw the Phase 2 TUI: content area + status bar. /// Draw the TUI: content area + status bar.
/// ///
/// Layout: /// Layout:
/// ```text /// ```text
@@ -309,7 +635,7 @@ impl App {
/// │ │ /// │ │
/// │ ... │ /// │ ... │
/// ├─────────────────────────────────────────┤ /// ├─────────────────────────────────────────┤
/// │ index.md q:Quit j/k:Scroll ... │ ← Length(1) status bar /// │ guides > page Tab:Links q:Quit │ ← Length(1) status bar
/// └─────────────────────────────────────────┘ /// └─────────────────────────────────────────┘
/// ``` /// ```
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
@@ -330,8 +656,38 @@ impl App {
// ── Content area ───────────────────────────────────────────────────── // ── Content area ─────────────────────────────────────────────────────
match &self.document { match &self.document {
DocumentState::Loaded { lines, .. } => { DocumentState::Loaded { lines, .. } => {
let para = Paragraph::new(lines.clone()) // Apply REVERSED modifier to the selected link at draw time
.scroll((self.scroll_offset, 0)); 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();
if let Some(line) = cloned.get_mut(record.line_index) {
// Walk spans, summing character widths until we reach the link range
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
{
span.style = span.style.add_modifier(Modifier::REVERSED);
}
// Also catch spans that overlap the link range
if col < record.col_offset + record.span_len
&& col + span_chars > record.col_offset
{
span.style = span.style.add_modifier(Modifier::REVERSED);
}
col += span_chars;
}
}
cloned
} else {
lines.clone()
}
} else {
lines.clone()
};
let para = Paragraph::new(display_lines).scroll((self.scroll_offset, 0));
frame.render_widget(para, content_area); frame.render_widget(para, content_area);
} }
DocumentState::Missing { path } => { DocumentState::Missing { path } => {
@@ -349,50 +705,70 @@ impl App {
self.draw_status_bar(frame, status_area); self.draw_status_bar(frame, status_area);
} }
/// Render the one-line status bar with filename on the left and hints on the right. /// Render the one-line status bar with breadcrumb on the left and navigation hints on the right.
/// ///
/// Uses `Modifier::REVERSED` for retro reverse-video BBS styling. /// Uses `Modifier::REVERSED` for retro reverse-video BBS styling.
/// When the quit prompt is active, replaces the hints with the warning text. /// When the quit prompt is active, replaces the hints with the warning text.
fn draw_status_bar(&self, frame: &mut Frame, area: Rect) { fn draw_status_bar(&self, frame: &mut Frame, area: Rect) {
let width = area.width as usize; let width = area.width as usize;
let left = format!(" {} ", self.filename); let breadcrumb = build_breadcrumb(&self.current_path);
let left = format!(" {} ", breadcrumb);
let right = if self.show_quit_prompt { if self.show_quit_prompt {
" Press Ctrl+C again to disconnect... ".to_string() let right = " Press Ctrl+C again to disconnect... ".to_string();
} else if self.is_login_shell { let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len());
" Ctrl+C\u{00D7}2:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() 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: build right side from nav indicators + link counter + hints
let mut right_parts: Vec<String> = Vec::new();
// Back indicator (shown only when history exists in that direction)
if self.history_index > 0 {
right_parts.push("< Back".to_string());
}
// Link counter (shown when a link is 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 { } else {
" q:Quit j/k:Scroll PgUp/PgDn:Page ".to_string() "Tab:Links Enter:Go Bksp:Back q:Quit"
}; };
right_parts.push(hints.to_string());
// Calculate padding between left and right so the bar fills the full width let right = format!(" {} ", right_parts.join(" "));
let pad_len = width
.saturating_sub(left.len()) let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len());
.saturating_sub(right.len());
let padding = " ".repeat(pad_len); let padding = " ".repeat(pad_len);
let status_text = if self.show_quit_prompt { let bar = Paragraph::new(Line::from(vec![Span::raw(format!(
// Quit prompt: yellow bold reverse video "{}{}{}",
Line::from(vec![ left, padding, right
Span::styled( ))]))
format!("{}{}{}", left, padding, right), .style(Style::default().add_modifier(Modifier::REVERSED));
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::REVERSED),
),
])
} else {
// Normal status bar: reverse video
Line::from(vec![Span::raw(format!("{}{}{}", left, padding, right))])
};
let bar = Paragraph::new(status_text)
.style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_widget(bar, area); frame.render_widget(bar, area);
} }
/// Render the BBS-style error screen when index.md is missing or unreadable. /// Render the BBS-style error screen when a document is missing or unreadable.
/// ///
/// Layout (centered in `area`): /// Layout (centered in `area`):
/// ```text /// ```text
@@ -512,6 +888,31 @@ impl App {
} }
} }
// ── Breadcrumb helper ─────────────────────────────────────────────────────────
/// Build a breadcrumb trail from a vault-relative path.
///
/// Converts path components to a human-readable trail, stripping `.md` extensions.
///
/// # Examples
///
/// ```text
/// build_breadcrumb("index.md") → "index"
/// build_breadcrumb("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.as_ref())
.to_string()
})
.collect::<Vec<_>>()
.join(" > ")
}
// ── show_goodbye ───────────────────────────────────────────────────────────── // ── show_goodbye ─────────────────────────────────────────────────────────────
/// Display a BBS-style goodbye message after terminal has been restored. /// Display a BBS-style goodbye message after terminal has been restored.
+36 -19
View File
@@ -38,24 +38,32 @@ fn main() {
.map(|(w, _)| w) .map(|(w, _)| w)
.unwrap_or(80); .unwrap_or(80);
let (initial_doc, raw_content) = match vault::load_document(&app_config.vault_path, "index.md") { let (initial_doc, raw_content, initial_link_records) =
vault::VaultDocument::Loaded { path, content } => { match vault::load_document(&app_config.vault_path, "index.md") {
let filename = path vault::VaultDocument::Loaded { path, content } => {
.file_name() let filename = path
.map(|n| n.to_string_lossy().to_string()) .file_name()
.unwrap_or_else(|| "index.md".to_string()); .map(|n| n.to_string_lossy().to_string())
// TODO(03-02): use link_records for Tab-cycling navigation .unwrap_or_else(|| "index.md".to_string());
let (lines, _link_records) = renderer::render_markdown(&content, initial_width, None); let (lines, link_records) = renderer::render_markdown(
let doc = app::DocumentState::Loaded { filename, lines }; &content,
(doc, Some(content)) initial_width,
} Some(&app_config.vault_path),
vault::VaultDocument::Missing { path } => { );
(app::DocumentState::Missing { path }, None) let doc = app::DocumentState::Loaded { filename, lines };
} (doc, Some(content), link_records)
vault::VaultDocument::ReadError { path, reason } => { }
(app::DocumentState::Error { path, reason }, None) vault::VaultDocument::Missing { path } => {
} (app::DocumentState::Missing { path }, None, Vec::new())
}; }
vault::VaultDocument::ReadError { path, reason } => {
(
app::DocumentState::Error { path, reason },
None,
Vec::new(),
)
}
};
// ── TERMINAL PHASE ──────────────────────────────────────────────────────── // ── TERMINAL PHASE ────────────────────────────────────────────────────────
// Install safety envelope BEFORE terminal init so panics during init are caught. // Install safety envelope BEFORE terminal init so panics during init are caught.
@@ -86,7 +94,16 @@ fn main() {
// 7. Create app state and run the event loop. // 7. Create app state and run the event loop.
// raw_content is passed so the event loop can re-render on terminal resize. // raw_content is passed so the event loop can re-render on terminal resize.
let mut app_state = app::App::new(is_login_shell, app_config, initial_doc, raw_content); // link_records enables Tab-cycling navigation.
// "index.md" is the initial vault-relative path for history and breadcrumb.
let mut app_state = app::App::new(
is_login_shell,
app_config,
initial_doc,
raw_content,
initial_link_records,
"index.md".to_string(),
);
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags); let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
// ── SHUTDOWN PHASE ──────────────────────────────────────────────────────── // ── SHUTDOWN PHASE ────────────────────────────────────────────────────────