332 lines
13 KiB
Markdown
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>
|