Files
bbs-md/.planning/phases/04-bbs-polish-and-live-content/04-02-PLAN.md
T
2026-02-28 23:41:11 +01:00

13 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
04-bbs-polish-and-live-content 02 execute 2
04-01
src/vault.rs
src/app.rs
src/main.rs
true
LIVE-02
truths artifacts key_links
User can follow a Directory wiki-link to see a tree view of all vault documents
Directory listing shows folder hierarchy with indentation, directories in yellow bold, files as cyan navigable links
User can Tab-cycle between directory entries and press Enter to open a document
Directory page participates in navigation history (back/forward works)
Navigating to Directory and back preserves scroll position and link selection
path provides contains
src/vault.rs Directory magic interception in resolve_wiki_link + list_vault_files() function __directory__
path provides contains
src/app.rs navigate_to_directory() method, DocumentState handling for virtual directory page navigate_to_directory
from to via pattern
src/vault.rs walkdir crate list_vault_files() using WalkDir recursive traversal WalkDir::new
from to via pattern
src/app.rs src/vault.rs follow_selected_link detects __directory__ sentinel and calls navigate_to_directory __directory__
from to via pattern
src/app.rs src/vault.rs navigate_to_directory calls list_vault_files to build the listing list_vault_files
Add a virtual directory listing page accessible via `Directory` wiki-link, displaying all vault documents in a navigable tree view.

Purpose: Users can discover all available documents in the vault without knowing their paths. The directory listing acts as a table of contents for the entire vault.

Output: Updated src/vault.rs with directory interception and file listing, updated src/app.rs with directory page navigation and rendering.

<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/04-bbs-polish-and-live-content/04-RESEARCH.md @.planning/phases/04-bbs-polish-and-live-content/04-01-SUMMARY.md @src/vault.rs @src/app.rs @src/renderer.rs Task 1: Add Directory interception and vault file listing src/vault.rs 1. In `resolve_wiki_link()`, add a magic sentinel check BEFORE the filesystem scan: ```rust // Magic sentinel — Directory navigates to the virtual directory page if raw_target.eq_ignore_ascii_case("directory") { return Some(PathBuf::from("__directory__")); } ``` Place this at the very top of `resolve_wiki_link()`, before the `rfind('/')` split.
  1. Add list_vault_files() function to vault.rs:

    use walkdir::WalkDir;
    
    /// Entry in the vault directory listing.
    pub struct DirEntry {
        /// Indentation depth (1-based, depth 1 = vault root children)
        pub depth: usize,
        /// Display name (without .md extension for files)
        pub name: String,
        /// True if this entry is a directory
        pub is_dir: bool,
        /// Vault-relative path (only for .md files; None for directories)
        pub vault_path: Option<String>,
    }
    
    /// List all markdown files and directories in the vault, sorted alphabetically.
    /// Skips hidden files/directories (starting with '.') and non-.md files.
    /// Returns entries with depth for tree indentation.
    pub fn list_vault_files(vault_path: &Path) -> Vec<DirEntry> {
        let mut entries = Vec::new();
        for entry in WalkDir::new(vault_path)
            .sort_by_file_name()
            .into_iter()
            .filter_map(|e| e.ok())
            .skip(1) // skip vault root itself
        {
            let depth = entry.depth();
            let name = entry.file_name().to_string_lossy().to_string();
    
            // Skip hidden files/dirs
            if name.starts_with('.') {
                continue;
            }
    
            let is_dir = entry.file_type().is_dir();
    
            if is_dir {
                entries.push(DirEntry {
                    depth,
                    name,
                    is_dir: true,
                    vault_path: None,
                });
            } else if name.ends_with(".md") {
                let display_name = name.strip_suffix(".md").unwrap_or(&name).to_string();
                let rel = entry.path()
                    .strip_prefix(vault_path)
                    .unwrap_or(entry.path())
                    .to_string_lossy()
                    .to_string();
                entries.push(DirEntry {
                    depth,
                    name: display_name,
                    is_dir: false,
                    vault_path: Some(rel),
                });
            }
            // Skip non-.md files silently
        }
        entries
    }
    
  2. Run cargo build to verify vault.rs compiles. cargo build succeeds. resolve_wiki_link() returns Some(PathBuf::from("__directory__")) when called with "Directory" (case-insensitive). list_vault_files() is defined and exported. Directory wiki-link resolves to the __directory__ sentinel. list_vault_files() returns structured directory entries with depth, name, is_dir, and vault_path.

Task 2: Wire directory as virtual page with Tab-cycling navigation src/app.rs, src/main.rs 1. Add a `navigate_to_directory()` method to `App`: ```rust /// Navigate to the virtual directory listing page. /// Generates a tree view of all vault files as styled lines with link records. 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;

}


2. Create the `build_directory_lines()` helper function (module-level in app.rs):
```rust
/// 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(); // Will be this line's index

            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)
}
  1. Update follow_selected_link() in app.rs:

    • After resolving a wiki-link with resolve_wiki_link(), check if the resolved path is the directory sentinel:
      // Inside the is_wiki branch, after resolve_wiki_link returns Some:
      if resolved == Path::new("__directory__") {
          self.navigate_to_directory();
      } else {
          let rel = resolved.to_string_lossy().to_string();
          self.navigate_to(&rel);
      }
      
    • For the non-wiki branch (standard links), if dest == "__directory__", call navigate_to_directory() instead.
  2. Update navigate_back() and navigate_forward():

    • When the target_path is "__directory__", call navigate_to_directory()-style logic instead of load_document(). Specifically:
      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 {
          // existing load_document() logic
      }
      
    • IMPORTANT: Do NOT save/push new history entries in navigate_back/forward — they restore existing history positions. Only update current state fields. The existing history entry management (saving scroll/link to current index, then moving index) remains unchanged.
  3. Update handle_resize():

    • If current_path == "__directory__", skip the render_markdown re-render. The directory listing is not markdown and does not need width-based re-render. The tree view spans are fixed-width text. Do nothing for directory pages on resize.
  4. Run cargo build to verify everything compiles. cargo build succeeds. The follow_selected_link() method correctly routes __directory__ to navigate_to_directory(). navigate_back/forward handle the __directory__ case. handle_resize skips re-render for directory pages. Directory wiki-link navigates to a tree view page. Tab-cycling selects directory entries. Enter opens the selected document. Back/Forward navigates through directory page in history. Directory listing is always regenerated fresh (never stale).

1. `cargo build` — compiles without errors 2. Add `Directory` link to index.md in a test vault — following it shows tree view 3. Tree view shows folders (yellow bold with `/`) and files (cyan `[name]` bracketed) 4. Tab-cycle between file entries — REVERSED highlight moves correctly 5. Press Enter on a file entry — navigates to that document 6. Press Backspace — returns to directory listing with scroll/link selection preserved 7. Navigate forward — returns to the document 8. Case-insensitive: `directory` and `DIRECTORY` both work

<success_criteria>

  • Directory wiki-link navigates to virtual tree view page
  • Tree shows hierarchy with indentation, dirs yellow bold, files cyan bracketed links
  • Tab-cycling works on directory entries using existing link model
  • Directory participates in back/forward history
  • Directory listing is regenerated each visit (not stale)
  • handle_resize does not crash on directory page </success_criteria>
After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-02-SUMMARY.md`