Files
bbs-md/.planning/phases/02-vault-core-and-rendering/02-03-PLAN.md
T

324 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 02-vault-core-and-rendering
plan: 03
type: execute
wave: 3
depends_on:
- "02-01"
- "02-02"
files_modified:
- src/app.rs
- src/main.rs
autonomous: true
requirements:
- NAV-05
- NAV-06
- NAV-07
- NAV-08
- NAV-09
must_haves:
truths:
- "App starts and displays index.md content from the configured vault_path"
- "When index.md is missing, user sees a BBS-style error screen with box-drawing border and helpful message"
- "User can scroll content with j/k (one line), arrow keys (one line), PgUp/PgDn (full page)"
- "Status bar at the bottom shows filename on the left and keyboard hints on the right in reverse video"
- "Terminal resize re-renders content at new width and reflows layout without crashing"
- "Quit behavior from Phase 1 (q key, double Ctrl+C) is preserved"
artifacts:
- path: "src/app.rs"
provides: "Document display, scrolling, status bar, error screen"
contains: "DocumentState"
- path: "src/main.rs"
provides: "Wiring: mod declarations, highlighter init, vault loading"
contains: "mod renderer"
key_links:
- from: "src/main.rs"
to: "src/highlighter.rs"
via: "init_highlighter() called before App::new()"
pattern: "init_highlighter"
- from: "src/app.rs"
to: "src/vault.rs"
via: "load_document() called to get markdown content"
pattern: "load_document"
- from: "src/app.rs"
to: "src/renderer.rs"
via: "render_markdown() converts content to styled lines for display"
pattern: "render_markdown"
- from: "src/app.rs"
to: "ratatui::widgets::Paragraph"
via: "Paragraph::new(lines).scroll((offset, 0)) for scrollable content"
pattern: "scroll"
---
<objective>
Wire everything together: rework app.rs to display rendered markdown content with scrolling, a status bar, and error screens; update main.rs to register all new modules, initialize the highlighter, and load index.md on startup.
Purpose: This is the integration plan that turns the separate modules (vault, renderer, highlighter) into a working content viewer.
Output: Running `cargo run` displays index.md from the vault with full markdown styling, scrolling, and a status bar.
</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/02-vault-core-and-rendering/02-RESEARCH.md
@.planning/phases/02-vault-core-and-rendering/02-CONTEXT.md
@.planning/phases/02-vault-core-and-rendering/02-01-SUMMARY.md
@.planning/phases/02-vault-core-and-rendering/02-02-SUMMARY.md
@src/app.rs
@src/main.rs
</context>
<tasks>
<task type="auto">
<name>Task 1: Rework app.rs with document state, scrolling, status bar, and error screen</name>
<files>src/app.rs</files>
<action>
**Extend the App struct** — add new fields while preserving ALL Phase 1 fields and behavior:
```rust
pub struct App {
// Phase 1 fields (keep exactly as-is)
is_login_shell: bool,
ctrl_c_pressed_at: Option<Instant>,
show_quit_prompt: bool,
should_quit: bool,
config: Config,
// Phase 2 additions
document: DocumentState,
scroll_offset: u16,
}
```
**Add `DocumentState` enum:**
```rust
pub enum DocumentState {
Loaded {
filename: String,
lines: Vec<Line<'static>>,
},
Missing {
path: PathBuf,
},
Error {
path: PathBuf,
reason: String,
},
}
```
**Update `App::new()`** to accept the initial document state:
Change signature to `pub fn new(is_login_shell: bool, config: Config, document: DocumentState) -> Self` with `scroll_offset: 0`.
**Rework `draw()`** — replace the Phase 1 placeholder UI entirely:
1. Split frame into two areas using Layout:
```rust
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let content_area = chunks[0];
let status_area = chunks[1];
```
2. **Content area rendering** — match on `self.document`:
- `DocumentState::Loaded { filename, lines }`:
- Create `Paragraph::new(lines.clone()).scroll((self.scroll_offset, 0))`
- Do NOT enable Wrap — lines are pre-rendered
- Render into `content_area`
- `DocumentState::Missing { path }`:
- Call `draw_error_screen(frame, content_area, path)` (see below)
- `DocumentState::Error { path, reason }`:
- Call `draw_error_screen_with_reason(frame, content_area, path, reason)`
3. **Status bar rendering (NAV-08):**
- Determine filename text: from DocumentState::Loaded filename, or "ERROR" for missing/error states
- Determine hints: `"q:Quit j/k:Scroll PgUp/PgDn:Page"` (if not login shell), or `"Ctrl+C×2:Quit j/k:Scroll PgUp/PgDn:Page"` (if login shell)
- Build the status bar as a single Line with the filename left-aligned and hints right-aligned:
- Calculate padding to fill the full status_area.width between filename and hints
- Style: `Style::default().add_modifier(Modifier::REVERSED)` on the entire Paragraph
- Render `Paragraph::new(status_line).style(Style::default().add_modifier(Modifier::REVERSED))` into `status_area`
4. **Quit prompt overlay** — if `self.show_quit_prompt` is true, render the "Press Ctrl+C again..." message. Options:
- Render it as part of the status bar (replace hints with the warning), OR
- Overlay it on the bottom of content_area
- Recommended: replace the status bar content with the quit prompt in yellow bold reverse video
**Error screen widget (NAV-07):**
Create a private method `draw_error_screen(frame: &mut Frame, area: Rect, path: &Path)`:
Use ratatui's `Block` widget with `BorderType::Plain` (┌─┐│└─┘) borders:
```
┌─────────────────────────────────────────┐
│ *** SYSTEM ERROR *** │
│ │
│ No index.md found in vault: │
│ /path/to/vault │
│ │
│ Create index.md to begin. │
└─────────────────────────────────────────┘
```
- Block border: `Color::Red`
- "SYSTEM ERROR" text: `Color::LightRed + Modifier::BOLD`
- Path: `Color::Yellow`
- Hint: `Color::DarkGray`
- Center the block in the content area (calculate a centered Rect)
Also create `draw_error_screen_with_reason()` for ReadError states — same layout but shows the error reason instead of "No index.md found".
**Extend `handle_key()` for scrolling (NAV-05):**
Add new key bindings BEFORE the existing `_` catch-all:
```rust
KeyCode::Char('j') | KeyCode::Down => {
self.scroll_down(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll_up(1);
}
KeyCode::PageDown => {
self.scroll_down(self.page_height());
}
KeyCode::PageUp => {
self.scroll_up(self.page_height());
}
```
Helper methods:
- `scroll_down(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_add(n).min(self.max_scroll())`
- `scroll_up(n: u16)`: `self.scroll_offset = self.scroll_offset.saturating_sub(n)`
- `max_scroll() -> u16`: if Loaded, `lines.len() as u16 - page_height`, else 0. Use `saturating_sub`.
- `page_height() -> u16`: store the content area height from the last draw, or default to 24. Add a `last_content_height: u16` field to App, update it in draw().
**IMPORTANT:** The scroll keys (j/k/arrows/PgUp/PgDn) must NOT trigger the quit prompt dismissal. Currently the `_ =>` branch dismisses the prompt. The scroll keys should be handled before the `_` catch-all, and only dismiss the prompt for truly unrelated keys.
**Resize handling (NAV-09):**
- App needs to store the raw markdown content so it can re-render on resize. Add a `raw_content: Option<String>` field to App (populated when document is loaded).
- In `run_event_loop()`, handle `Event::Resize(w, _h)`:
- If `raw_content` is Some, re-render: `let lines = renderer::render_markdown(&content, w); self.document = DocumentState::Loaded { filename, lines };`
- Clamp `scroll_offset` to new `max_scroll()` after re-render
- ratatui handles buffer resize automatically for `Viewport::Fullscreen`
- This ensures horizontal rules, code block borders, and table widths adapt to the new terminal width
- `max_scroll()` recomputes on every draw based on `last_content_height`, so vertical scroll is handled naturally
**Preserve ALL Phase 1 behavior:**
- Double Ctrl+C quit mechanism
- Login shell mode suppressing 'q' key
- `show_goodbye()` function unchanged
- `ShutdownReason` enum unchanged
- `DOUBLE_PRESS_WINDOW` unchanged
</action>
<verify>`cargo check` passes. Mentally trace: App starts with DocumentState::Loaded, draw() shows content with status bar, j/k adjust scroll_offset, q still quits, Ctrl+C double-press still works.</verify>
<done>app.rs has DocumentState enum, scroll_offset field, status bar with filename+hints in reverse video, BBS error screen for missing files, j/k/arrow/PgUp/PgDn scroll keys, resize handling. All Phase 1 quit behavior preserved.</done>
</task>
<task type="auto">
<name>Task 2: Wire main.rs with module declarations, highlighter init, and index.md loading</name>
<files>src/main.rs</files>
<action>
**Add module declarations** at the top of main.rs (add to the existing mod block):
```rust
mod app;
mod config;
mod highlighter;
mod renderer;
mod signals;
mod terminal;
mod vault;
```
**Update the startup sequence** in `fn main()`:
After step 3 (config loading) and before step 4 (panic hook), add:
```rust
// 3a. Initialize syntax highlighting (one-time, ~23ms)
highlighter::init_highlighter();
// 3b. Load initial document (index.md from vault)
let initial_doc = match vault::load_document(&app_config.vault_path, "index.md") {
vault::VaultDocument::Loaded { path, content } => {
// Get terminal width for rendering — use a reasonable default before terminal init
// We'll re-render if needed, but 80 is safe for initial parse
let width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w)
.unwrap_or(80);
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "index.md".to_string());
let lines = renderer::render_markdown(&content, width);
app::DocumentState::Loaded { filename, lines }
}
vault::VaultDocument::Missing { path } => {
app::DocumentState::Missing { path }
}
vault::VaultDocument::ReadError { path, reason } => {
app::DocumentState::Error { path, reason }
}
};
```
**Update App::new() call** in step 7:
```rust
let mut app_state = app::App::new(is_login_shell, app_config, initial_doc);
```
**Pass raw content to App for resize re-rendering:**
When loading a Loaded document, also pass the raw markdown string to App so it can re-render on resize. Update `App::new()` signature to accept an optional raw content string, or store it alongside the DocumentState.
**Remove the `#[allow(dead_code)]` on config field** in app.rs if it's now used (vault_path is accessed). Actually, config is passed at construction but vault loading happens in main.rs, so config may still appear unused inside App. Keep the allow if needed, or add a method to access vault_path for future use.
</action>
<verify>
1. `cargo build` succeeds
2. Create a test vault: `mkdir -p /tmp/bbs-test-vault && echo '# Welcome\n\nHello **world**!\n\n- Item one\n- Item two\n\n> A blockquote\n\n---\n\n```rust\nlet x = 42;\n```' > /tmp/bbs-test-vault/index.md`
3. Run: `cargo run -- --config /dev/null` with a bbs.toml pointing to the test vault (or modify the default path for testing)
4. Verify: content displays with colored headings, styled text, and a status bar at the bottom
5. Verify: j/k scroll content, q exits cleanly
6. Verify: without index.md, the BBS error screen appears
</verify>
<done>main.rs declares all 7 modules (app, config, highlighter, renderer, signals, terminal, vault). Startup sequence initializes highlighter, loads index.md via vault, renders via renderer, passes DocumentState to App. Running `cargo run` displays styled markdown content with scrolling and status bar. Missing index.md shows error screen.</done>
</task>
</tasks>
<verification>
1. `cargo build` succeeds with zero warnings on new code
2. With a vault containing index.md: app displays rendered markdown content
3. Without index.md in vault: app displays BBS error screen with box-drawing border
4. j/k keys scroll one line, PgUp/PgDn scroll one page
5. Status bar shows filename on left, keyboard hints on right, in reverse video
6. q key exits (non-login-shell), double Ctrl+C exits (always)
7. Terminal resize does not crash — content area adjusts
8. All Phase 1 safety features (panic hook, signal handling, terminal restore) still work
</verification>
<success_criteria>
- Running `cargo run` displays index.md from the configured vault with full markdown styling
- Missing index.md shows BBS error screen instead of crashing
- Content scrolls smoothly with j/k/arrows/PgUp/PgDn
- Status bar visible at bottom with filename and keyboard hints
- All Phase 1 quit/safety behavior preserved
- Terminal resize handled gracefully
</success_criteria>
<output>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-03-SUMMARY.md`
</output>