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

13 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-vault-core-and-rendering 03 execute 3
02-01
02-02
src/app.rs
src/main.rs
true
NAV-05
NAV-06
NAV-07
NAV-08
NAV-09
truths artifacts key_links
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
path provides contains
src/app.rs Document display, scrolling, status bar, error screen DocumentState
path provides contains
src/main.rs Wiring: mod declarations, highlighter init, vault loading mod renderer
from to via pattern
src/main.rs src/highlighter.rs init_highlighter() called before App::new() init_highlighter
from to via pattern
src/app.rs src/vault.rs load_document() called to get markdown content load_document
from to via pattern
src/app.rs src/renderer.rs render_markdown() converts content to styled lines for display render_markdown
from to via pattern
src/app.rs ratatui::widgets::Paragraph Paragraph::new(lines).scroll((offset, 0)) for scrollable content scroll
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.

<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/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 Task 1: Rework app.rs with document state, scrolling, status bar, and error screen src/app.rs **Extend the App struct** — add new fields while preserving ALL Phase 1 fields and behavior:
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:

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:

    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:

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 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. 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.
Task 2: Wire main.rs with module declarations, highlighter init, and index.md loading src/main.rs **Add module declarations** at the top of main.rs (add to the existing mod block):
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:

// 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:

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.

  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 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.
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

<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>
After completion, create `.planning/phases/02-vault-core-and-rendering/02-03-SUMMARY.md`