diff --git a/src/app.rs b/src/app.rs index a65c5f5..0cae66f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -313,6 +313,11 @@ impl App { /// 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. 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() { let vault_path = self.config.vault_path.clone(); 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. fn navigate_back(&mut self) { 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) 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) { let filename = path @@ -605,7 +666,23 @@ impl App { 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 } = + + 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) { let filename = path @@ -664,8 +741,12 @@ impl App { // 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); + if resolved == Path::new("__directory__") { + self.navigate_to_directory(); + } else { + let rel = resolved.to_string_lossy().to_string(); + self.navigate_to(&rel); + } } None => { // Broken wiki-link — already shown as red/strikethrough in render. @@ -674,17 +755,21 @@ impl App { } } 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; + if dest == "__directory__" { + self.navigate_to_directory(); + } else { + 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; + } } } } @@ -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>, Vec) { + let mut lines: Vec> = Vec::new(); + let mut link_records: Vec = 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 ───────────────────────────────────────────────────────── /// Build a breadcrumb trail from a vault-relative path.