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

332 lines
13 KiB
Markdown

---
phase: 04-bbs-polish-and-live-content
plan: 02
type: execute
wave: 2
depends_on:
- 04-01
files_modified:
- src/vault.rs
- src/app.rs
- src/main.rs
autonomous: true
requirements:
- LIVE-02
must_haves:
truths:
- "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"
artifacts:
- path: "src/vault.rs"
provides: "[[Directory]] magic interception in resolve_wiki_link + list_vault_files() function"
contains: "__directory__"
- path: "src/app.rs"
provides: "navigate_to_directory() method, DocumentState handling for virtual directory page"
contains: "navigate_to_directory"
key_links:
- from: "src/vault.rs"
to: "walkdir crate"
via: "list_vault_files() using WalkDir recursive traversal"
pattern: "WalkDir::new"
- from: "src/app.rs"
to: "src/vault.rs"
via: "follow_selected_link detects __directory__ sentinel and calls navigate_to_directory"
pattern: "__directory__"
- from: "src/app.rs"
to: "src/vault.rs"
via: "navigate_to_directory calls list_vault_files to build the listing"
pattern: "list_vault_files"
---
<objective>
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.
</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/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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add [[Directory]] interception and vault file listing</name>
<files>src/vault.rs</files>
<action>
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.
2. Add `list_vault_files()` function to `vault.rs`:
```rust
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
}
```
3. Run `cargo build` to verify vault.rs compiles.
</action>
<verify>`cargo build` succeeds. `resolve_wiki_link()` returns `Some(PathBuf::from("__directory__"))` when called with "Directory" (case-insensitive). `list_vault_files()` is defined and exported.</verify>
<done>[[Directory]] wiki-link resolves to the `__directory__` sentinel. list_vault_files() returns structured directory entries with depth, name, is_dir, and vault_path.</done>
</task>
<task type="auto">
<name>Task 2: Wire directory as virtual page with Tab-cycling navigation</name>
<files>src/app.rs, src/main.rs</files>
<action>
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)
}
```
3. 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:
```rust
// 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.
4. Update `navigate_back()` and `navigate_forward()`:
- When the `target_path` is `"__directory__"`, call `navigate_to_directory()`-style logic instead of `load_document()`. Specifically:
```rust
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.
5. 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.
6. Run `cargo build` to verify everything compiles.
</action>
<verify>`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.</verify>
<done>[[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).</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/04-bbs-polish-and-live-content/04-02-SUMMARY.md`
</output>