feat(04-02): wire virtual directory listing page with history and Tab-cycling

- navigate_to_directory() builds tree view via list_vault_files() + build_directory_lines()
- follow_selected_link() routes __directory__ sentinel to navigate_to_directory()
- navigate_back/forward regenerate directory listing fresh when target_path == __directory__
- handle_resize() skips re-render for directory pages (fixed-width text, no markdown)
- build_directory_lines() emits yellow-bold dirs and cyan bracketed file links with LinkRecord
This commit is contained in:
2026-03-01 10:58:14 +01:00
parent fe69cf5fab
commit 600b46a83b
+161 -15
View File
@@ -313,6 +313,11 @@ 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.
/// On index.md, splash lines are re-prepended after the re-render. /// On index.md, splash lines are re-prepended after the re-render.
fn handle_resize(&mut self, new_width: u16) { fn handle_resize(&mut self, new_width: u16) {
// Directory listing is fixed-width text — no width-based re-render needed
if self.current_path == "__directory__" {
return;
}
if let Some(ref content) = self.raw_content.clone() { if let Some(ref content) = self.raw_content.clone() {
let vault_path = self.config.vault_path.clone(); let vault_path = self.config.vault_path.clone();
let (mut lines, mut link_records) = crate::renderer::render_markdown( let (mut lines, mut link_records) = crate::renderer::render_markdown(
@@ -524,6 +529,46 @@ impl App {
} }
} }
/// Navigate to the virtual directory listing page.
///
/// Generates a tree view of all vault files as styled lines with link records.
/// Saves current scroll/link state to history and pushes a new history entry.
fn navigate_to_directory(&mut self) {
let vault_path = self.config.vault_path.clone();
// Save current state to history
if let Some(entry) = self.history.get_mut(self.history_index) {
entry.scroll_offset = self.scroll_offset;
entry.selected_link = self.selected_link;
}
// Truncate forward history
self.history.truncate(self.history_index + 1);
// Build directory listing
let dir_entries = crate::vault::list_vault_files(&vault_path);
let (lines, link_records) = build_directory_lines(&dir_entries);
self.document = DocumentState::Loaded {
filename: "[Directory]".to_string(),
lines,
};
self.raw_content = None; // No raw markdown for virtual pages
self.link_records = link_records;
self.selected_link = None;
self.scroll_offset = 0;
self.current_path = "__directory__".to_string();
self.filename = "[Directory]".to_string();
self.page_meta = None; // No file metadata for virtual pages
// Push history entry
self.history.push(HistoryEntry {
path: "__directory__".to_string(),
scroll_offset: 0,
selected_link: None,
});
self.history_index = self.history.len() - 1;
}
/// Navigate back one step in the history stack, restoring scroll and link selection. /// Navigate back one step in the history stack, restoring scroll and link selection.
fn navigate_back(&mut self) { fn navigate_back(&mut self) {
if self.history_index == 0 { if self.history_index == 0 {
@@ -543,7 +588,23 @@ impl App {
// Re-load and re-render the document (per research: don't cache rendered output) // Re-load and re-render the document (per research: don't cache rendered output)
let vault_path = self.config.vault_path.clone(); let vault_path = self.config.vault_path.clone();
if let crate::vault::VaultDocument::Loaded { path, content } =
if target_path == "__directory__" {
// Regenerate directory listing (always fresh, never cached)
let dir_entries = crate::vault::list_vault_files(&vault_path);
let (lines, link_records) = build_directory_lines(&dir_entries);
self.document = DocumentState::Loaded {
filename: "[Directory]".to_string(),
lines,
};
self.raw_content = None;
self.link_records = link_records;
self.selected_link = target_link;
self.scroll_offset = target_scroll;
self.current_path = "__directory__".to_string();
self.filename = "[Directory]".to_string();
self.page_meta = None;
} else if let crate::vault::VaultDocument::Loaded { path, content } =
crate::vault::load_document(&vault_path, &target_path) crate::vault::load_document(&vault_path, &target_path)
{ {
let filename = path let filename = path
@@ -605,7 +666,23 @@ impl App {
let target_link = self.history[self.history_index].selected_link; let target_link = self.history[self.history_index].selected_link;
let vault_path = self.config.vault_path.clone(); let vault_path = self.config.vault_path.clone();
if let crate::vault::VaultDocument::Loaded { path, content } =
if target_path == "__directory__" {
// Regenerate directory listing (always fresh, never cached)
let dir_entries = crate::vault::list_vault_files(&vault_path);
let (lines, link_records) = build_directory_lines(&dir_entries);
self.document = DocumentState::Loaded {
filename: "[Directory]".to_string(),
lines,
};
self.raw_content = None;
self.link_records = link_records;
self.selected_link = target_link;
self.scroll_offset = target_scroll;
self.current_path = "__directory__".to_string();
self.filename = "[Directory]".to_string();
self.page_meta = None;
} else if let crate::vault::VaultDocument::Loaded { path, content } =
crate::vault::load_document(&vault_path, &target_path) crate::vault::load_document(&vault_path, &target_path)
{ {
let filename = path let filename = path
@@ -664,8 +741,12 @@ impl App {
// Resolve wiki-link to vault-relative path // Resolve wiki-link to vault-relative path
match crate::vault::resolve_wiki_link(&vault_path, &dest) { match crate::vault::resolve_wiki_link(&vault_path, &dest) {
Some(resolved) => { Some(resolved) => {
let rel = resolved.to_string_lossy().to_string(); if resolved == Path::new("__directory__") {
self.navigate_to(&rel); self.navigate_to_directory();
} else {
let rel = resolved.to_string_lossy().to_string();
self.navigate_to(&rel);
}
} }
None => { None => {
// Broken wiki-link — already shown as red/strikethrough in render. // Broken wiki-link — already shown as red/strikethrough in render.
@@ -674,17 +755,21 @@ impl App {
} }
} else { } else {
// Standard markdown link — resolve relative to current document's directory // Standard markdown link — resolve relative to current document's directory
match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) { if dest == "__directory__" {
Some(resolved) => { self.navigate_to_directory();
let rel = resolved.to_string_lossy().to_string(); } else {
self.navigate_to(&rel); match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) {
} Some(resolved) => {
None => { let rel = resolved.to_string_lossy().to_string();
// Broken link — show error page self.navigate_to(&rel);
let full_path = vault_path.join(&dest); }
self.document = DocumentState::Missing { path: full_path }; None => {
self.link_records = Vec::new(); // Broken link — show error page
self.selected_link = None; let full_path = vault_path.join(&dest);
self.document = DocumentState::Missing { path: full_path };
self.link_records = Vec::new();
self.selected_link = None;
}
} }
} }
} }
@@ -1053,6 +1138,67 @@ impl App {
} }
} }
// ── Directory listing builder ─────────────────────────────────────────────────
/// Build styled lines and link records from vault directory entries.
fn build_directory_lines(
entries: &[crate::vault::DirEntry],
) -> (Vec<Line<'static>>, Vec<crate::renderer::LinkRecord>) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut link_records: Vec<crate::renderer::LinkRecord> = Vec::new();
// Title header
lines.push(Line::from(Span::styled(
" VAULT DIRECTORY".to_string(),
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
"=".repeat(40),
Style::default().fg(Color::LightCyan),
)));
lines.push(Line::default()); // blank separator
for entry in entries {
let indent = " ".repeat(entry.depth.saturating_sub(1));
let indent_chars = indent.chars().count();
if entry.is_dir {
// Directory: yellow bold, not a link
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(
format!("{}/", entry.name),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
]));
} else if let Some(ref vault_rel) = entry.vault_path {
// File: cyan link — navigable via Tab-cycling
let display = format!("[{}]", entry.name);
let col_offset = indent_chars;
let span_len = display.chars().count();
let line_index = lines.len(); // This line's index in the lines vec
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(
display,
Style::default().fg(Color::LightCyan),
),
]));
link_records.push(crate::renderer::LinkRecord {
line_index,
col_offset,
span_len,
dest: vault_rel.clone(),
is_wiki: false, // Direct vault-relative path, not a wiki-link
});
}
}
(lines, link_records)
}
// ── Breadcrumb helper ───────────────────────────────────────────────────────── // ── Breadcrumb helper ─────────────────────────────────────────────────────────
/// Build a breadcrumb trail from a vault-relative path. /// Build a breadcrumb trail from a vault-relative path.