Compare commits

..

11 Commits

Author SHA1 Message Date
ruohki 065365662c feat: add idle timeout and parent process death detection
Prevents orphaned bbs-md processes when SSH sessions disconnect
uncleanly. Adds two mechanisms:

- Parent death check: polls getppid() each loop iteration and exits
  when the parent (sshd) dies, detecting session orphaning immediately.
- Idle timeout: configurable idle_timeout_minutes in bbs.toml (default
  0 = disabled) exits after no keyboard/mouse input for the duration.
2026-03-01 18:50:29 +01:00
ruohki 8ef587c163 feat: add inline image rendering and remove bundled vault
- Add ratatui-image and image crates for terminal image support
- Auto-detect terminal protocol (Kitty/iTerm2/Sixel) with halfblocks fallback
- Render inline images with Protocol API, cache decoded images
- Handle partial scroll visibility via temp-buffer rendering
- Support alpha compositing (RGBA→RGB) and dynamic height adjustment
- Add force_halfblocks config option to bypass protocol detection
- Suppress link brackets around image-wrapped links
- Remove bundled vault files (demo content moved to test vault)
- Add .gitignore for target dir and .DS_Store
2026-03-01 15:17:47 +01:00
ruohki f7870179ee docs(quick-3): remote markdown page linking with domain whitelist 2026-03-01 13:17:16 +01:00
ruohki 37b8352919 docs(quick-3): complete remote markdown page linking plan 2026-03-01 13:16:32 +01:00
ruohki c8d4754340 feat(quick-3): wire remote link navigation with history, error screens, and LightMagenta styling
- Detect HTTP/HTTPS links in follow_selected_link and dispatch to navigate_to_remote()
- Add navigate_to_remote() method with whitelist check, fetch, render, history push, and error screens
- Handle DomainNotAllowed/FetchError/NotMarkdown with BBS-themed error screens (no history push)
- Update navigate_back/forward to re-fetch remote URLs from history (consistent with disk-reload pattern)
- Update handle_resize() to pass None vault_path for remote pages
- Skip live-reload for remote pages in reload_current_document()
- Clear current_url when navigating to local pages in navigate_to()
- Style HTTP/HTTPS links in LightMagenta to visually distinguish from local LightCyan links
2026-03-01 13:15:23 +01:00
ruohki 5759ec83e6 feat(quick-3): add ureq dep, allowed_remote_domains config, and fetch_remote_markdown
- Add ureq = "2.12" to Cargo.toml for synchronous HTTP fetching
- Add allowed_remote_domains: Vec<String> field to Config struct with serde default
- Add RemoteDocument enum with Loaded/DomainNotAllowed/FetchError/NotMarkdown variants
- Add fetch_remote_markdown() with domain whitelist, 10s timeout, content-type validation, 5MB body limit
2026-03-01 13:12:27 +01:00
ruohki eb1c7866ce docs(quick-3): plan remote markdown page linking with domain whitelist 2026-03-01 13:10:39 +01:00
ruohki 1752554cf6 docs(quick-2): demo vault content with ASCII art splash and feature pages 2026-03-01 11:58:14 +01:00
ruohki 4464774c7a content: create demo vault with splash art and feature showcase pages
8 files: index.md with wiki-links, features overview, navigation guide,
markdown showcase (all constructs), about page, changelog, and two
guides (sysop handbook, writing content) in a subdirectory. Includes
ANSI art splash.txt for the index page header.
2026-03-01 11:58:01 +01:00
ruohki f3d787a2b7 docs(quick-1): arrow key navigation and directory description column 2026-03-01 11:40:12 +01:00
ruohki 2d1e01821c docs(quick-1): complete arrow key navigation and directory description plan 2026-03-01 11:39:38 +01:00
14 changed files with 3512 additions and 110 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
.DS_Store
+20 -4
View File
@@ -12,7 +12,7 @@ See: .planning/PROJECT.md (updated 2026-02-28)
Phase: 4 of 4 (BBS Polish and Live Content) — COMPLETE Phase: 4 of 4 (BBS Polish and Live Content) — COMPLETE
Plan: 3 of 3 in current phase (04-01, 04-02, 04-03 all done) Plan: 3 of 3 in current phase (04-01, 04-02, 04-03 all done)
Status: PROJECT COMPLETE — live reload with 300ms debounce, scroll preservation, and non-fatal watcher failure handling Status: PROJECT COMPLETE — live reload with 300ms debounce, scroll preservation, and non-fatal watcher failure handling
Last activity: 2026-03-01 — Plan 03 complete (FileWatcher, reload_current_document, rewatch_for_current_page, try_recv drain loop) Last activity: 2026-03-01 - Completed quick task 3: Remote markdown page linking with domain whitelist
Progress: [██████████] 100% Progress: [██████████] 100%
@@ -80,10 +80,18 @@ Recent decisions affecting current work:
- [Phase 04-03]: try_recv drain loop per poll iteration — drains all queued events, debounce collapses burst into single reload - [Phase 04-03]: try_recv drain loop per poll iteration — drains all queued events, debounce collapses burst into single reload
- [Phase 04-03]: FileWatcher initialized in main.rs before App::new() — avoids self-referential ownership, keeps constructor pure - [Phase 04-03]: FileWatcher initialized in main.rs before App::new() — avoids self-referential ownership, keeps constructor pure
- [Phase 04-03]: Non-fatal watcher failure — warning printed, None passed to App, app runs without live reload - [Phase 04-03]: Non-fatal watcher failure — warning printed, None passed to App, app runs without live reload
- [quick-1]: Bare Left/Right match arms placed after Alt+Left/Alt+Right guards — match order ensures Alt combos consumed first
- [quick-1]: BufReader + take(20) for frontmatter parsing — avoids loading entire large files
- [quick-1]: span_len covers only [name] bracket not description — keeps Tab-cycling highlight correct
- [quick-3]: ureq chosen for synchronous HTTP (no async runtime, matches "no tokio" decision)
- [quick-3]: Subdomain matching: sub.example.com matches whitelist entry example.com via suffix check
- [quick-3]: Error cases (domain-not-allowed, fetch-error, not-markdown) do NOT push history entry
- [quick-3]: current_url field distinguishes remote vs local page context for resize/reload handling
- [quick-3]: Back/forward re-fetch remote pages from network (consistent with re-load-from-disk pattern)
### Pending Todos ### Pending Todos
None. Project complete. None. Project complete. Quick tasks 1, 2, and 3 executed.
### Blockers/Concerns ### Blockers/Concerns
@@ -92,8 +100,16 @@ All blockers resolved:
- **NAV path traversal resolved**: is_within_vault() with canonicalize + starts_with guards all link resolution in vault.rs - **NAV path traversal resolved**: is_within_vault() with canonicalize + starts_with guards all link resolution in vault.rs
- **LIVE resolved**: notify 6.1 API verified; parent-directory watch pattern avoids inotify exhaustion and survives atomic saves - **LIVE resolved**: notify 6.1 API verified; parent-directory watch pattern avoids inotify exhaustion and survives atomic saves
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|-----------|
| 1 | Arrow key navigation and directory description column | 2026-03-01 | 2d1e018 | [1-arrow-key-navigation-and-directory-descr](./quick/1-arrow-key-navigation-and-directory-descr/) |
| 2 | Demo vault content with ASCII art splash and feature pages | 2026-03-01 | 4464774 | [2-create-demo-vault-content-with-ascii-art](./quick/2-create-demo-vault-content-with-ascii-art/) |
| 3 | Remote markdown page linking with domain whitelist | 2026-03-01 | 37b8352 | [3-implement-remote-markdown-page-linking-w](./quick/3-implement-remote-markdown-page-linking-w/) |
## Session Continuity ## Session Continuity
Last session: 2026-03-01 Last session: 2026-03-01
Stopped at: PROJECT COMPLETE — Completed 04-03-PLAN.md (final plan) Stopped at: Completed quick-3 (remote markdown page linking with domain whitelist)
Resume file: N/A — project complete Resume file: N/A — project complete, quick tasks 1-3 done
@@ -0,0 +1,89 @@
---
phase: quick-1
plan: "01"
subsystem: navigation, directory-listing
tags: [arrow-keys, frontmatter, directory, vault]
dependency_graph:
requires: []
provides: [ARROW-NAV, DIR-DESC]
affects: [src/app.rs, src/vault.rs]
tech_stack:
added: []
patterns: [yaml-frontmatter-parsing, bufreader-take]
key_files:
created: []
modified:
- src/app.rs
- src/vault.rs
decisions:
- "Bare Left/Right go after Alt+Left/Right guards in match — Rust match guards ensure Alt+Left is consumed first"
- "Read only first 20 lines via BufReader + take(20) — avoids loading large files for metadata"
- "strip_span covers only [name] bracket, not description — keeps Tab-cycling highlight correct"
- "78-char truncation budget applied after accounting for indent and separator widths"
metrics:
duration: "5 min"
completed: "2026-03-01T10:38:54Z"
tasks_completed: 1
files_modified: 2
---
# Quick Task 1: Arrow Key Navigation and Directory Descriptions Summary
**One-liner:** Bare Left/Right arrow keys navigate history and directory listing shows YAML frontmatter descriptions in DarkGray beside each file entry.
## What Was Built
### Part A — Bare arrow key navigation (src/app.rs)
Added two new match arms to `handle_key()` immediately after the `Alt+Left`/`Alt+Right` guarded arms:
```rust
KeyCode::Left => {
self.navigate_back();
}
KeyCode::Right => {
self.navigate_forward();
}
```
The match arm order is critical: the guarded `KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT)` arms are checked first. Bare `Left`/`Right` fall through to the new unguarded arms only when `ALT` is not held. Updated the doc comment on `handle_key()` to document the new bindings.
### Part B — Frontmatter description parsing (src/vault.rs)
Added a `description: Option<String>` field to `DirEntry`.
Added `extract_frontmatter_description(path: &Path) -> Option<String>`:
- Opens the file with `BufReader` and reads at most 20 lines (`lines().take(20)`)
- Checks first line is exactly `---`
- Scans for `description:` key, stops at closing `---`
- Strips surrounding single or double quotes if present
- Returns `Some(value)` or `None`
Updated `list_vault_files()` to call `extract_frontmatter_description()` for each `.md` file and populate the field. Directory entries get `description: None`.
### Part C — Description display in directory listing (src/app.rs)
Updated `build_directory_lines()` to append description spans for file entries that have one:
```
[filename] description text here, truncated...
```
- Two-space separator (`" "`) between the bracketed name and description
- Description rendered as `Span::styled(text, Style::default().fg(Color::DarkGray))`
- Truncation budget: `78 - indent_chars - span_len - 2`, appending `"..."` if clipped
- `link_records` entry's `span_len` covers only the `[name]` bracket — description is a separate span and not part of the link highlight range
## Deviations from Plan
None - plan executed exactly as written.
## Self-Check
- [x] `src/app.rs` modified — bare Left/Right arms present, description display in `build_directory_lines()`
- [x] `src/vault.rs` modified — `description` field on `DirEntry`, `extract_frontmatter_description()` helper, `list_vault_files()` populates field
- [x] `cargo build` — clean (0 new warnings)
- [x] `cargo clippy` — clean (all warnings pre-existing in unrelated files)
- [x] Commit `f0ec2ed` exists
## Self-Check: PASSED
@@ -0,0 +1,278 @@
---
phase: quick-3
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- Cargo.toml
- src/config.rs
- src/vault.rs
- src/app.rs
- src/renderer.rs
autonomous: true
requirements: [REMOTE-01, REMOTE-02, REMOTE-03, REMOTE-04]
must_haves:
truths:
- "User can follow an HTTP/HTTPS link to a whitelisted domain and see the remote markdown rendered in the TUI"
- "User sees a BBS-themed error screen when following an HTTP link to a non-whitelisted domain"
- "User sees a BBS-themed error screen when the remote URL fails to fetch or returns non-markdown content"
- "Config file accepts an allowed_remote_domains list in bbs.toml"
- "Remote pages participate in browser-style history (back/forward navigation works)"
artifacts:
- path: "Cargo.toml"
provides: "ureq HTTP client dependency"
contains: "ureq"
- path: "src/config.rs"
provides: "allowed_remote_domains config field"
contains: "allowed_remote_domains"
- path: "src/vault.rs"
provides: "fetch_remote_markdown function"
contains: "fn fetch_remote_markdown"
- path: "src/app.rs"
provides: "HTTP link detection and remote page navigation in follow_selected_link"
contains: "fetch_remote_markdown"
key_links:
- from: "src/app.rs"
to: "src/vault.rs"
via: "fetch_remote_markdown call in follow_selected_link"
pattern: "vault::fetch_remote_markdown"
- from: "src/app.rs"
to: "src/config.rs"
via: "allowed_remote_domains whitelist check"
pattern: "allowed_remote_domains"
- from: "src/renderer.rs"
to: "src/app.rs"
via: "HTTP links styled differently (LightMagenta) to indicate remote"
pattern: "starts_with.*http"
---
<objective>
Add remote markdown page linking: when a user follows an HTTP/HTTPS link in a markdown document,
the app checks if the domain is whitelisted in bbs.toml, fetches the URL, validates it returns
markdown content, and renders it in the TUI like a local page.
Purpose: Enable the BBS to link to external markdown content (documentation sites, wikis, READMEs)
while maintaining security through domain whitelisting.
Output: Working remote link following with config, fetching, validation, rendering, error states,
and history integration.
</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/STATE.md
@Cargo.toml
@src/config.rs
@src/vault.rs
@src/app.rs
@src/renderer.rs
@src/main.rs
</context>
<tasks>
<task type="auto">
<name>Task 1: Add config field, ureq dependency, and remote fetch function</name>
<files>
Cargo.toml
src/config.rs
src/vault.rs
</files>
<action>
1. **Cargo.toml**: Add `ureq = "2.12"` to `[dependencies]`. ureq is a synchronous HTTP client
with no async runtime requirement -- fits the project's "no tokio/async runtime" decision.
2. **src/config.rs**: Add `allowed_remote_domains` field to the `Config` struct:
```rust
#[serde(default)]
pub allowed_remote_domains: Vec<String>,
```
Also add the field to the `Default` impl with an empty Vec default.
IMPORTANT: The struct uses `#[serde(deny_unknown_fields)]` -- this field MUST be added to
the struct, not just documented. The `#[serde(default)]` attribute ensures existing configs
without this field still parse correctly.
3. **src/vault.rs**: Add a new public enum and function:
```rust
/// Result of attempting to fetch a remote markdown document.
pub enum RemoteDocument {
/// Successfully fetched and content appears to be markdown.
Loaded { url: String, content: String },
/// Domain is not in the whitelist.
DomainNotAllowed { domain: String },
/// HTTP request failed (network error, timeout, non-2xx status).
FetchError { url: String, reason: String },
/// Response content does not appear to be markdown (e.g. HTML page, binary).
NotMarkdown { url: String, content_type: String },
}
```
Add function `pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String]) -> RemoteDocument`:
- Extract the domain from the URL using simple string parsing (split on `://`, then take
everything before the first `/` or end of string, then strip any port number with rfind(':'))
- Check domain against `allowed_domains` (case-insensitive comparison). Also check if the
URL domain ENDS WITH a whitelisted domain prefixed by `.` -- this allows `sub.example.com`
to match whitelist entry `example.com`.
- If not whitelisted, return `RemoteDocument::DomainNotAllowed`.
- Use `ureq::get(url).call()` with a 10-second timeout via `.timeout(Duration::from_secs(10))`.
- On success, check the Content-Type header: accept `text/markdown`, `text/plain`, `text/x-markdown`,
or any response where the URL path ends in `.md`. If Content-Type indicates HTML (`text/html`)
or binary, return `RemoteDocument::NotMarkdown`.
- Read the response body as a string (with a 5MB size limit to prevent memory exhaustion:
use `.into_reader().take(5_000_000)` and read to string).
- Return `RemoteDocument::Loaded` with the URL and content.
- On any ureq error, return `RemoteDocument::FetchError` with the error message.
</action>
<verify>
`cargo check` passes with no errors. The new `allowed_remote_domains` field exists in Config.
The `fetch_remote_markdown` function compiles and is public.
</verify>
<done>
Config struct has `allowed_remote_domains: Vec<String>` with serde default.
`vault::fetch_remote_markdown` function exists, handles domain whitelisting, HTTP fetching,
content-type validation, and returns appropriate RemoteDocument variants.
ureq dependency is in Cargo.toml.
</done>
</task>
<task type="auto">
<name>Task 2: Wire remote link navigation into app event loop with history and error screens</name>
<files>
src/app.rs
src/renderer.rs
</files>
<action>
1. **src/renderer.rs**: In the `Event::Start(Tag::Link { .. })` handler, add HTTP link detection.
Currently all non-wiki links get `Style::default().fg(Color::LightCyan)`. Add a check:
if `dest_url.starts_with("http://") || dest_url.starts_with("https://")`, use
`Style::default().fg(Color::LightMagenta)` instead of `LightCyan` to visually distinguish
remote links from local links. This goes in the `else` branch (non-wiki links) around line 603.
2. **src/app.rs** -- Add a `current_url` field to `App` struct:
```rust
/// When viewing a remote document, stores the source URL for display in breadcrumb.
/// None when viewing a local vault document.
current_url: Option<String>,
```
Initialize to `None` in `App::new()`. Set to `Some(url)` when navigating to a remote page,
set back to `None` when navigating to a local page.
3. **src/app.rs** -- Modify `follow_selected_link()`: In the `else` (non-wiki) branch, BEFORE
the existing `resolve_standard_link` logic, add a check:
```rust
if dest.starts_with("http://") || dest.starts_with("https://") {
self.navigate_to_remote(&dest);
return;
}
```
4. **src/app.rs** -- Add new method `navigate_to_remote(&mut self, url: &str)`:
- Save current state to history (same pattern as `navigate_to`).
- Truncate forward history.
- Call `crate::vault::fetch_remote_markdown(url, &self.config.allowed_remote_domains)`.
- Match on the result:
- `RemoteDocument::Loaded { url, content }`:
- Derive filename from the URL path (last segment, or the domain if path is `/`).
- Render with `crate::renderer::render_markdown(&content, width, None)` -- pass `None`
for vault_path since wiki-links in remote content cannot resolve to the local vault.
- Set `self.document = DocumentState::Loaded { filename, lines }`.
- Set `self.raw_content = Some(content)`.
- Set `self.current_url = Some(url)`.
- Set `self.current_path` to the URL string (for breadcrumb display).
- Update link_records, copyable_blocks, reset scroll, push history entry.
- Do NOT call `self.rewatch_for_current_page()` (no file to watch).
- But DO stop the watcher from watching irrelevant dirs: unwatch is optional,
just skip the rewatch call.
- `RemoteDocument::DomainNotAllowed { domain }`:
- Show an error screen: set `self.document = DocumentState::Error` with
`path: PathBuf::from(url)` and
`reason: format!("Domain '{}' is not in the allowed remote domains list. Add it to allowed_remote_domains in bbs.toml.", domain)`.
- Clear link_records, copyable_blocks, raw_content. Set current_url to None.
- Do NOT push to history (same pattern as Missing/ReadError in navigate_to).
- `RemoteDocument::FetchError { url, reason }`:
- Show error: `DocumentState::Error { path: PathBuf::from(&url), reason }`.
- Same cleanup as DomainNotAllowed. Do NOT push to history.
- `RemoteDocument::NotMarkdown { url, content_type }`:
- Show error: `DocumentState::Error { path: PathBuf::from(&url), reason: format!("Remote content is not markdown (Content-Type: {})", content_type) }`.
- Same cleanup. Do NOT push to history.
5. **src/app.rs** -- Update `navigate_back()` and `navigate_forward()`: When restoring a history
entry whose path starts with "http://" or "https://", call `navigate_to_remote` logic instead
of `load_document`. The simplest approach: check `if target_path.starts_with("http://") || target_path.starts_with("https://")`,
then re-fetch the remote content (same as initial navigation -- no caching, consistent with
the existing "re-load from disk" pattern for local docs).
6. **src/app.rs** -- Update `handle_resize()`: When `self.current_url.is_some()`, re-render
using the stored `raw_content` (same as local docs) but pass `None` for vault_path.
Currently handle_resize always passes `Some(&vault_path)` -- add a conditional:
```rust
let vault_ref = if self.current_url.is_some() { None } else { Some(&*vault_path) };
```
7. **src/app.rs** -- Update `reload_current_document()`: When `self.current_url.is_some()`,
skip reload entirely (remote content does not live-reload from filesystem).
Add early return: `if self.current_url.is_some() { return; }`.
8. **src/app.rs** -- Update `build_breadcrumb()` or the status bar: When `current_path` starts
with `http`, show a truncated URL in the breadcrumb instead of path components. The existing
`build_breadcrumb` function will naturally split on `/` which works acceptably for URLs
(e.g. "https: > example.com > docs > page.md"). This is acceptable BBS behavior. No change
needed unless it looks terrible -- use your judgment.
9. **src/app.rs** -- In `navigate_to()` (the local navigation method), set
`self.current_url = None` to clear remote state when navigating back to a local page.
</action>
<verify>
`cargo build` succeeds. Test manually:
1. Create a `bbs.toml` with `allowed_remote_domains = ["raw.githubusercontent.com"]`
2. Create a vault markdown file with a link like `[Test](https://raw.githubusercontent.com/some/repo/main/README.md)`
3. Run `cargo run -- --config bbs.toml`, navigate to the link, press Enter
4. Verify the remote markdown renders in the TUI
5. Verify pressing Backspace returns to the previous local page
6. Test a non-whitelisted domain link shows the domain-not-allowed error
7. Verify `cargo clippy` has no errors (warnings acceptable)
</verify>
<done>
HTTP/HTTPS links in markdown are detected and visually distinguished (LightMagenta color).
Following a whitelisted remote link fetches, validates, and renders the markdown content.
Non-whitelisted domains show a clear error with instructions to add to config.
Fetch failures and non-markdown content show appropriate error screens.
Remote pages participate in back/forward history navigation.
Resize re-renders remote content correctly. Live reload is skipped for remote pages.
</done>
</task>
</tasks>
<verification>
1. `cargo build` compiles without errors
2. `cargo clippy` passes (warnings acceptable)
3. Remote link to whitelisted domain renders markdown in TUI
4. Remote link to non-whitelisted domain shows error screen with domain name
5. Remote link to non-existent URL shows fetch error screen
6. Remote link to HTML content shows "not markdown" error screen
7. Back/forward navigation works through remote pages
8. Terminal resize re-renders remote content correctly
9. Existing local vault navigation is completely unaffected
</verification>
<success_criteria>
- bbs.toml `allowed_remote_domains = ["example.com"]` config field works
- HTTP/HTTPS links render in LightMagenta (visually distinct from local LightCyan links)
- Following whitelisted HTTP links fetches and displays remote markdown
- Non-whitelisted domains show BBS-themed error with domain name and config hint
- Fetch errors and non-markdown responses show appropriate error screens
- Browser history (back/forward) works through local and remote pages
- No regressions in local vault navigation, wiki-links, or directory listing
</success_criteria>
<output>
After completion, create `.planning/quick/3-implement-remote-markdown-page-linking-w/3-SUMMARY.md`
</output>
@@ -0,0 +1,128 @@
---
phase: quick-3
plan: 01
subsystem: navigation
tags: [remote-links, http, security, whitelist, history]
dependency_graph:
requires: [quick-1, quick-2]
provides: [remote-markdown-fetching, domain-whitelist]
affects: [app-navigation, renderer-styling, vault-fetching, config]
tech_stack:
added: [ureq 2.12 (synchronous HTTP client)]
patterns: [domain whitelist check, content-type validation, 5MB body cap, no-history-on-error]
key_files:
created: []
modified:
- Cargo.toml
- src/config.rs
- src/vault.rs
- src/app.rs
- src/renderer.rs
decisions:
- ureq chosen for synchronous HTTP (no async runtime, consistent with existing architecture)
- Subdomain matching: sub.example.com matches whitelist entry example.com via suffix check
- Content-type validation accepts text/markdown, text/plain, text/x-markdown, and any URL ending in .md
- Error cases (domain-not-allowed, fetch-error, not-markdown) do NOT push history entry
- Back/forward re-fetch remote pages from network (consistent with re-load-from-disk pattern)
- current_url: Option<String> field distinguishes remote vs local page context across resize/reload
metrics:
duration: "4 min"
completed: "2026-03-01T12:15:30Z"
tasks_completed: 2
files_modified: 5
---
# Quick Task 3: Remote Markdown Page Linking Summary
**One-liner:** Domain-whitelisted HTTP/HTTPS link following with ureq, content-type validation, BBS error screens, and browser history integration.
## What Was Built
Users can now follow HTTP/HTTPS links in markdown documents. When a link is activated:
1. The domain is extracted from the URL and checked against `allowed_remote_domains` in `bbs.toml`.
2. If allowed, ureq fetches the URL with a 10-second timeout.
3. The Content-Type header is validated (accepts `text/markdown`, `text/plain`, `text/x-markdown`, or any `.md` URL).
4. The response body is read with a 5 MB cap.
5. The fetched markdown is rendered in the TUI exactly like a local page.
6. Remote pages participate in browser history (Backspace/Alt+Left goes back; re-fetches on return).
Error cases show BBS-themed error screens (no history entry pushed):
- **Domain not whitelisted**: shows domain name and config hint
- **Fetch error** (network, timeout, non-2xx): shows HTTP status or error message
- **Not markdown** (HTML, binary): shows Content-Type received
HTTP/HTTPS links are styled in `LightMagenta` to visually distinguish them from local `LightCyan` links.
## Tasks Completed
| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 | Add config field, ureq dependency, and remote fetch function | 5759ec8 | Cargo.toml, src/config.rs, src/vault.rs |
| 2 | Wire remote link navigation into app event loop with history and error screens | c8d4754 | src/app.rs, src/renderer.rs |
## Key Changes
### Cargo.toml
- Added `ureq = "2.12"` dependency
### src/config.rs
- Added `allowed_remote_domains: Vec<String>` field with `#[serde(default)]`
- Updated `Default` impl to include empty Vec
### src/vault.rs
- Added `RemoteDocument` enum (`Loaded`, `DomainNotAllowed`, `FetchError`, `NotMarkdown`)
- Added `extract_domain()` — strips scheme, path, port from URL
- Added `domain_is_allowed()` — exact + subdomain matching (case-insensitive)
- Added `fetch_remote_markdown()` — whitelist check, 10s timeout, content-type validation, 5MB cap
### src/renderer.rs
- HTTP/HTTPS links styled `LightMagenta` in `Event::Start(Tag::Link)` handler (non-wiki, non-local branch)
### src/app.rs
- Added `current_url: Option<String>` field (Some = remote page, None = local page)
- Added `navigate_to_remote()` method — saves history, fetches, renders, pushes entry on success
- Updated `follow_selected_link()` to detect HTTP/HTTPS and dispatch to `navigate_to_remote()`
- Updated `navigate_back()` and `navigate_forward()` to re-fetch remote URLs from history
- Updated `handle_resize()` to pass `None` vault_path for remote pages
- Updated `reload_current_document()` to skip live-reload for remote pages
- Updated `navigate_to()` to clear `current_url` when going to a local page
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed clippy lint: manual `split_once` pattern**
- **Found during:** Task 2 verification (cargo clippy)
- **Issue:** `url.splitn(2, "://").nth(1)` and `resp.status_text().to_string()` triggered clippy warnings
- **Fix:** Replaced with `url.split_once("://")?.1` and removed redundant `.to_string()` in format!
- **Files modified:** src/vault.rs, src/app.rs
- **Commit:** c8d4754
### Out-of-scope items (deferred)
None discovered.
## Verification Results
- `cargo build` passes with no errors (3 pre-existing warnings, unrelated to this task)
- `cargo clippy` passes with warnings only (no new errors introduced)
- HTTP links styled LightMagenta (verified in renderer.rs code path)
- Domain whitelist check with exact and subdomain matching implemented
- All 4 RemoteDocument variants handled with appropriate error screens
- History integration tested via code review (back/forward re-fetch remote URLs)
- handle_resize passes None vault_path for remote pages
- reload_current_document returns early for remote pages
## Self-Check: PASSED
Files exist:
- Cargo.toml: FOUND (ureq = "2.12")
- src/config.rs: FOUND (allowed_remote_domains field)
- src/vault.rs: FOUND (fetch_remote_markdown function)
- src/app.rs: FOUND (navigate_to_remote method, current_url field)
- src/renderer.rs: FOUND (LightMagenta branch)
Commits exist:
- 5759ec8: Task 1 commit
- c8d4754: Task 2 commit
Generated
+1382 -10
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -15,3 +15,7 @@ syntect-tui = "3.0"
notify = "6.1" notify = "6.1"
ansi-to-tui = "8.0" ansi-to-tui = "8.0"
walkdir = "2.5" walkdir = "2.5"
ureq = "2.12"
ratatui-image = { version = "10.0", default-features = false }
image = "0.25"
libc = "0.2"
+1113 -46
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -12,6 +12,30 @@ pub struct Config {
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
#[allow(dead_code)] #[allow(dead_code)]
pub theme: String, pub theme: String,
#[serde(default)]
pub margin: u16,
#[serde(default)]
pub allowed_remote_domains: Vec<String>,
#[serde(default = "default_max_remote_document_size")]
pub max_remote_document_size: u64,
#[serde(default = "default_max_remote_image_size")]
pub max_remote_image_size: u64,
/// When true, forces the halfblocks (Unicode block character) image renderer
/// instead of auto-detecting Kitty/iTerm2/Sixel protocols. Useful for
/// terminals where protocol detection fails or produces garbled output.
#[serde(default)]
pub force_halfblocks: bool,
/// Idle session timeout in minutes. When no keyboard or mouse input is
/// received for this duration, the session exits automatically.
/// Set to 0 to disable (default).
#[serde(default)]
pub idle_timeout_minutes: u64,
} }
fn default_vault_path() -> PathBuf { fn default_vault_path() -> PathBuf {
@@ -22,11 +46,25 @@ fn default_theme() -> String {
"default".to_string() "default".to_string()
} }
fn default_max_remote_document_size() -> u64 {
5_000_000
}
fn default_max_remote_image_size() -> u64 {
10_485_760
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Config { Config {
vault_path: default_vault_path(), vault_path: default_vault_path(),
theme: default_theme(), theme: default_theme(),
margin: 0,
allowed_remote_domains: Vec::new(),
max_remote_document_size: default_max_remote_document_size(),
max_remote_image_size: default_max_remote_image_size(),
force_halfblocks: false,
idle_timeout_minutes: 0,
} }
} }
} }
+11 -1
View File
@@ -117,7 +117,7 @@ pub fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
.highlight_line(line, ss) .highlight_line(line, ss)
.unwrap_or_default(); .unwrap_or_default();
let spans: Vec<Span<'static>> = ranges let mut spans: Vec<Span<'static>> = ranges
.into_iter() .into_iter()
.map(|(style, text)| { .map(|(style, text)| {
let fg = syntect_color_to_cga(style.foreground); let fg = syntect_color_to_cga(style.foreground);
@@ -125,6 +125,16 @@ pub fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
}) })
.collect(); .collect();
// Strip trailing newline from the last span — LinesWithNewlines preserves
// \n for syntect grammar correctness, but it must not reach the display layer
// or it inflates content_len in emit_code_block by 1, misaligning the right border.
if let Some(last) = spans.last_mut() {
let trimmed = last.content.trim_end_matches('\n').trim_end_matches('\r');
if trimmed.len() != last.content.len() {
*last = Span::styled(trimmed.to_string(), last.style);
}
}
result.push(Line::from(spans)); result.push(Line::from(spans));
} }
+34 -5
View File
@@ -37,16 +37,17 @@ fn main() {
// On resize, the event loop re-renders with the updated width. // On resize, the event loop re-renders with the updated width.
let initial_width = ratatui::crossterm::terminal::size() let initial_width = ratatui::crossterm::terminal::size()
.map(|(w, _)| w) .map(|(w, _)| w)
.unwrap_or(80); .unwrap_or(80)
.saturating_sub(app_config.margin * 2);
let (initial_doc, raw_content, initial_link_records) = let (initial_doc, raw_content, initial_link_records, initial_copyable_blocks, initial_image_records) =
match vault::load_document(&app_config.vault_path, "index.md") { match vault::load_document(&app_config.vault_path, "index.md") {
vault::VaultDocument::Loaded { path, content } => { vault::VaultDocument::Loaded { path, content } => {
let filename = path let filename = path
.file_name() .file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "index.md".to_string()); .unwrap_or_else(|| "index.md".to_string());
let (mut lines, mut link_records) = renderer::render_markdown( let (mut lines, mut link_records, mut copyable_blocks, mut image_records) = renderer::render_markdown(
&content, &content,
initial_width, initial_width,
Some(&app_config.vault_path), Some(&app_config.vault_path),
@@ -61,18 +62,27 @@ fn main() {
for record in &mut link_records { for record in &mut link_records {
record.line_index += splash_count; record.line_index += splash_count;
} }
for record in &mut copyable_blocks {
record.start_line += splash_count;
record.end_line += splash_count;
}
for record in &mut image_records {
record.line_index += splash_count;
}
} }
let doc = app::DocumentState::Loaded { filename, lines }; let doc = app::DocumentState::Loaded { filename, lines };
(doc, Some(content), link_records) (doc, Some(content), link_records, copyable_blocks, image_records)
} }
vault::VaultDocument::Missing { path } => { vault::VaultDocument::Missing { path } => {
(app::DocumentState::Missing { path }, None, Vec::new()) (app::DocumentState::Missing { path }, None, Vec::new(), Vec::new(), Vec::new())
} }
vault::VaultDocument::ReadError { path, reason } => { vault::VaultDocument::ReadError { path, reason } => {
( (
app::DocumentState::Error { path, reason }, app::DocumentState::Error { path, reason },
None, None,
Vec::new(), Vec::new(),
Vec::new(),
Vec::new(),
) )
} }
}; };
@@ -120,6 +130,14 @@ fn main() {
} }
}; };
// 6a. Initialize image protocol picker (auto-detect Kitty/iTerm2/Sixel, fallback to halfblocks)
let picker = if app_config.force_halfblocks {
ratatui_image::picker::Picker::halfblocks()
} else {
ratatui_image::picker::Picker::from_query_stdio()
.unwrap_or_else(|_| ratatui_image::picker::Picker::halfblocks())
};
// ── EVENT LOOP PHASE ────────────────────────────────────────────────────── // ── EVENT LOOP PHASE ──────────────────────────────────────────────────────
// 7. Create app state and run the event loop. // 7. Create app state and run the event loop.
@@ -132,8 +150,11 @@ fn main() {
initial_doc, initial_doc,
raw_content, raw_content,
initial_link_records, initial_link_records,
initial_copyable_blocks,
initial_image_records,
"index.md".to_string(), "index.md".to_string(),
file_watcher, file_watcher,
picker,
); );
let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags); let shutdown_reason = app_state.run_event_loop(&mut term, &signal_flags);
@@ -154,6 +175,14 @@ fn main() {
// SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown. // SIGHUP or SIGTERM — SSH disconnect or graceful OS shutdown.
// Exit silently: there may be nobody on the other end to see a message. // Exit silently: there may be nobody on the other end to see a message.
} }
Ok(app::ShutdownReason::IdleTimeout) => {
// Idle timeout — user may still be watching, show a brief message.
println!("\r\nSession timed out due to inactivity.\r");
}
Ok(app::ShutdownReason::ParentDied) => {
// Parent process (sshd) died — SSH session is gone.
// Exit silently: there is nobody on the other end.
}
Err(e) => { Err(e) => {
// I/O error from the event loop // I/O error from the event loop
if e.kind() == std::io::ErrorKind::BrokenPipe { if e.kind() == std::io::ErrorKind::BrokenPipe {
+245 -27
View File
@@ -6,7 +6,7 @@
//! //!
//! # Public API //! # Public API
//! //!
//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>)` //! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>)`
use std::path::Path; use std::path::Path;
use pulldown_cmark::{ use pulldown_cmark::{
@@ -37,6 +37,50 @@ pub struct LinkRecord {
pub is_wiki: bool, pub is_wiki: bool,
} }
// ── CopyableBlock ────────────────────────────────────────────────────────────
/// The kind of copyable block discovered during rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Code,
Table,
Blockquote,
Text,
}
/// Metadata for a single copyable block discovered during rendering.
///
/// Produced by `render_markdown` alongside `Vec<Line>` and `Vec<LinkRecord>`.
/// Used to support OSC 52 copy-to-clipboard in copy mode or on right-click.
pub struct CopyableBlock {
/// Index into `Vec<Line>` where this block begins.
pub start_line: usize,
/// Index into `Vec<Line>` where this block ends (inclusive).
pub end_line: usize,
/// Raw content suitable for clipboard (plain text).
pub raw_content: String,
/// What kind of block this is.
pub kind: BlockKind,
}
// ── ImageRecord ──────────────────────────────────────────────────────────────
/// Metadata for a single image discovered during rendering.
///
/// Produced by `render_markdown` alongside lines, link records, and copyable blocks.
/// The renderer reserves `height` blank placeholder lines starting at `line_index`,
/// and the app layer overlays actual image rendering on top of those lines.
pub struct ImageRecord {
/// Index into `Vec<Line>` where this image's reserved space begins.
pub line_index: usize,
/// Number of lines reserved for this image (default 15).
pub height: u16,
/// Image source: local path or URL from markdown `![](source)`.
pub source: String,
/// Alt text from markdown `![alt]()`.
pub alt: String,
}
// ── Internal pending link helpers ───────────────────────────────────────────── // ── Internal pending link helpers ─────────────────────────────────────────────
/// Captured at `Tag::Link` Start — records link metadata before the text spans are pushed. /// Captured at `Tag::Link` Start — records link metadata before the text spans are pushed.
@@ -107,6 +151,23 @@ struct RenderState {
pending_link_records: Vec<PendingLinkRecord>, pending_link_records: Vec<PendingLinkRecord>,
/// All fully resolved link records (line_index filled in at flush time). /// All fully resolved link records (line_index filled in at flush time).
link_records: Vec<LinkRecord>, link_records: Vec<LinkRecord>,
/// All copyable block records discovered during rendering.
copyable_blocks: Vec<CopyableBlock>,
/// Line index where the current text section began (None = not in a text section).
text_section_start: Option<usize>,
/// Accumulated raw text for the current text section.
text_section_raw: String,
/// Line index where the outermost blockquote began (None = not in a blockquote block).
blockquote_start_line: Option<usize>,
/// Accumulated raw text for the current blockquote.
blockquote_raw: String,
// ── Image state ──────────────────────────────────────────────────────────
/// All image records discovered during rendering.
image_records: Vec<ImageRecord>,
/// Captured destination URL from the current `Tag::Image` (set at Start, consumed at End).
image_dest: Option<String>,
/// True when the current link contains an image (suppresses `[]` brackets).
link_has_image: bool,
} }
impl RenderState { impl RenderState {
@@ -133,6 +194,14 @@ impl RenderState {
link_span_start_count: 0, link_span_start_count: 0,
pending_link_records: Vec::new(), pending_link_records: Vec::new(),
link_records: Vec::new(), link_records: Vec::new(),
copyable_blocks: Vec::new(),
text_section_start: None,
text_section_raw: String::new(),
blockquote_start_line: None,
blockquote_raw: String::new(),
image_records: Vec::new(),
image_dest: None,
link_has_image: false,
} }
} }
@@ -162,6 +231,22 @@ impl RenderState {
fn flush_line(&mut self) { fn flush_line(&mut self) {
let mut spans = std::mem::take(&mut self.current_spans); let mut spans = std::mem::take(&mut self.current_spans);
// Capture raw text from current spans for copyable block tracking
let raw_text: String = spans.iter().map(|s| s.content.as_ref()).collect();
if self.in_blockquote {
if !raw_text.is_empty() {
if !self.blockquote_raw.is_empty() {
self.blockquote_raw.push('\n');
}
self.blockquote_raw.push_str(&raw_text);
}
} else if self.text_section_start.is_some() && !raw_text.is_empty() {
if !self.text_section_raw.is_empty() {
self.text_section_raw.push('\n');
}
self.text_section_raw.push_str(&raw_text);
}
if self.in_blockquote && self.blockquote_depth > 0 { if self.in_blockquote && self.blockquote_depth > 0 {
// Re-color content spans to Gray // Re-color content spans to Gray
for span in spans.iter_mut() { for span in spans.iter_mut() {
@@ -197,6 +282,32 @@ impl RenderState {
self.lines.push(Line::default()); self.lines.push(Line::default());
} }
// ── Text section helpers ───────────────────────────────────────────────────
/// Finalize the current text section, pushing a CopyableBlock if it has content.
fn finalize_text_section(&mut self) {
if let Some(start) = self.text_section_start.take() {
let raw = std::mem::take(&mut self.text_section_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Text,
});
}
}
}
/// Start a text section if none is active and we are not inside a blockquote.
fn ensure_text_section(&mut self) {
if self.text_section_start.is_none() && !self.in_blockquote {
self.text_section_start = Some(self.lines.len());
}
}
// ── Heading handling ────────────────────────────────────────────────────── // ── Heading handling ──────────────────────────────────────────────────────
fn start_heading(&mut self, level: HeadingLevel) { fn start_heading(&mut self, level: HeadingLevel) {
@@ -249,32 +360,68 @@ impl RenderState {
// ── Code block emitter ──────────────────────────────────────────────────── // ── Code block emitter ────────────────────────────────────────────────────
/// Flush the accumulated code buffer and emit a bordered, syntax-highlighted /// Flush the accumulated code buffer and emit a bordered, syntax-highlighted
/// code block into `self.lines`. /// code block into `self.lines`, recording a `CopyableBlock`.
fn emit_code_block_now(&mut self) { fn emit_code_block_now(&mut self) {
let code = std::mem::take(&mut self.code_buf); let code = std::mem::take(&mut self.code_buf);
let lang = std::mem::take(&mut self.code_lang); let lang = std::mem::take(&mut self.code_lang);
let width = self.width; let width = self.width;
let start_line = self.lines.len(); // index of blank line before top border
emit_code_block(&code, &lang, width, &mut self.lines); emit_code_block(&code, &lang, width, &mut self.lines);
let end_line = self.lines.len().saturating_sub(1); // index of blank line after bottom border
self.copyable_blocks.push(CopyableBlock {
start_line,
end_line,
raw_content: code,
kind: BlockKind::Code,
});
} }
// ── Table emitter ───────────────────────────────────────────────────────── // ── Table emitter ─────────────────────────────────────────────────────────
/// Flush the accumulated table rows and emit a full box-drawing grid table /// Flush the accumulated table rows and emit a full box-drawing grid table
/// into `self.lines`. /// into `self.lines`, recording a `CopyableBlock`.
fn emit_table_now(&mut self) { fn emit_table_now(&mut self) {
let alignments = std::mem::take(&mut self.table_alignments); let alignments = std::mem::take(&mut self.table_alignments);
let rows = std::mem::take(&mut self.table_rows); let rows = std::mem::take(&mut self.table_rows);
let start_line = self.lines.len();
// Build TSV raw content for clipboard
let raw_content: String = rows.iter()
.map(|r| r.join("\t"))
.collect::<Vec<_>>()
.join("\n");
emit_table(&alignments, &rows, &mut self.lines); emit_table(&alignments, &rows, &mut self.lines);
let end_line = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line,
end_line,
raw_content,
kind: BlockKind::Table,
});
} }
// ── Finish ──────────────────────────────────────────────────────────────── // ── Finish ────────────────────────────────────────────────────────────────
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>) { fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>) {
// Flush any trailing spans that were not terminated with a paragraph end // Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() { if !self.current_spans.is_empty() {
self.flush_line(); self.flush_line();
} }
(self.lines, self.link_records) // Finalize any pending text section or blockquote
self.finalize_text_section();
if let Some(start) = self.blockquote_start_line.take() {
let raw = std::mem::take(&mut self.blockquote_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Blockquote,
});
}
}
(self.lines, self.link_records, self.copyable_blocks, self.image_records)
} }
} }
@@ -284,6 +431,8 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
match event { match event {
// ── Headings ────────────────────────────────────────────────────────── // ── Headings ──────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
state.finalize_text_section();
state.text_section_start = Some(state.lines.len());
state.start_heading(level); state.start_heading(level);
} }
Event::End(TagEnd::Heading(level)) => { Event::End(TagEnd::Heading(level)) => {
@@ -292,7 +441,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Paragraphs ──────────────────────────────────────────────────────── // ── Paragraphs ────────────────────────────────────────────────────────
Event::Start(Tag::Paragraph) => { Event::Start(Tag::Paragraph) => {
// Nothing — spans accumulate until End state.ensure_text_section();
} }
Event::End(TagEnd::Paragraph) => { Event::End(TagEnd::Paragraph) => {
state.flush_line(); state.flush_line();
@@ -343,6 +492,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Code blocks ─────────────────────────────────────────────────────── // ── Code blocks ───────────────────────────────────────────────────────
Event::Start(Tag::CodeBlock(kind)) => { Event::Start(Tag::CodeBlock(kind)) => {
state.finalize_text_section();
state.code_lang = match kind { state.code_lang = match kind {
CodeBlockKind::Fenced(lang) => lang.to_string(), CodeBlockKind::Fenced(lang) => lang.to_string(),
CodeBlockKind::Indented => String::new(), CodeBlockKind::Indented => String::new(),
@@ -357,6 +507,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Lists ───────────────────────────────────────────────────────────── // ── Lists ─────────────────────────────────────────────────────────────
Event::Start(Tag::List(start)) => { Event::Start(Tag::List(start)) => {
state.ensure_text_section();
state.list_counters.push(start); state.list_counters.push(start);
} }
Event::End(TagEnd::List(_)) => { Event::End(TagEnd::List(_)) => {
@@ -399,7 +550,9 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Blockquotes ─────────────────────────────────────────────────────── // ── Blockquotes ───────────────────────────────────────────────────────
Event::Start(Tag::BlockQuote(_)) => { Event::Start(Tag::BlockQuote(_)) => {
if state.blockquote_depth == 0 { if state.blockquote_depth == 0 {
state.finalize_text_section();
state.push_blank(); state.push_blank();
state.blockquote_start_line = Some(state.lines.len());
} }
state.blockquote_depth += 1; state.blockquote_depth += 1;
state.in_blockquote = true; state.in_blockquote = true;
@@ -410,12 +563,27 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
} }
if state.blockquote_depth == 0 { if state.blockquote_depth == 0 {
state.in_blockquote = false; state.in_blockquote = false;
// Finalize blockquote as a copyable block
if let Some(start) = state.blockquote_start_line.take() {
let raw = std::mem::take(&mut state.blockquote_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = state.lines.len().saturating_sub(1);
state.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Blockquote,
});
}
}
state.push_blank(); state.push_blank();
} }
} }
// ── Horizontal rules ────────────────────────────────────────────────── // ── Horizontal rules ──────────────────────────────────────────────────
Event::Rule => { Event::Rule => {
state.finalize_text_section();
let w = state.width as usize; let w = state.width as usize;
state.lines.push(Line::from(Span::styled( state.lines.push(Line::from(Span::styled(
"".repeat(w), "".repeat(w),
@@ -425,19 +593,52 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
} }
// ── Images ──────────────────────────────────────────────────────────── // ── Images ────────────────────────────────────────────────────────────
Event::Start(Tag::Image { .. }) => { Event::Start(Tag::Image { dest_url, .. }) => {
state.in_image = true; state.in_image = true;
state.image_alt.clear(); state.image_alt.clear();
state.image_dest = Some(dest_url.to_string());
// If inside a link, remove the "[" bracket that was pushed at Link Start
// so we don't get visible [] around the image placeholder.
if state.pending_link.is_some() {
if let Some(last) = state.current_spans.last() {
if last.content.as_ref() == "[" {
state.current_spans.pop();
}
}
state.link_has_image = true;
}
} }
Event::End(TagEnd::Image) => { Event::End(TagEnd::Image) => {
let alt = std::mem::take(&mut state.image_alt); let alt = std::mem::take(&mut state.image_alt);
let placeholder = format!("[IMAGE: {}]", alt); let source = state.image_dest.take().unwrap_or_default();
state.current_spans.push(Span::styled(
placeholder, // Flush any pending spans before the image placeholder
Style::default() if !state.current_spans.is_empty() {
.fg(Color::DarkGray) state.flush_line();
.add_modifier(Modifier::DIM), }
));
// Record the line_index (first blank line of the reserved space).
// Fixed height — images are rendered at 2x original pixels, so we
// reserve a modest fixed space. The image overlay fills what it needs
// and blank lines remain for anything taller.
let line_index = state.lines.len();
let height: u16 = 15;
// Reserve blank lines — the image overlay renders on top of these.
// If the image fails to load, the alt text is stored in ImageRecord
// for the app layer to display as a fallback.
for _ in 0..height {
state.push_blank();
}
state.image_records.push(ImageRecord {
line_index,
height,
source,
alt,
});
state.in_image = false; state.in_image = false;
} }
@@ -460,6 +661,9 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
} else { } else {
Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT) Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT)
} }
} else if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
// Remote HTTP/HTTPS links are visually distinct from local links
Style::default().fg(Color::LightMagenta)
} else { } else {
Style::default().fg(Color::LightCyan) Style::default().fg(Color::LightCyan)
}; };
@@ -486,12 +690,19 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
Event::End(TagEnd::Link) => { Event::End(TagEnd::Link) => {
if let Some(pending) = state.pending_link.take() { if let Some(pending) = state.pending_link.take() {
if state.link_has_image {
// Link wrapped an image — brackets were already suppressed,
// skip the closing "]" and don't create a link record.
state.link_has_image = false;
} else {
// Push closing bracket "]" with the same link style // Push closing bracket "]" with the same link style
state.current_spans.push(Span::styled("]".to_string(), pending.link_style)); state.current_spans.push(Span::styled("]".to_string(), pending.link_style));
// Compute span_len: total chars in all spans pushed for this link // Compute span_len: total chars in all spans pushed for this link.
// (from link_span_start_count to current end, inclusive of brackets) // Clamp start index — a flush_line mid-link drains current_spans,
let span_len: usize = state.current_spans[state.link_span_start_count..] // making link_span_start_count stale.
let start = state.link_span_start_count.min(state.current_spans.len());
let span_len: usize = state.current_spans[start..]
.iter() .iter()
.map(|s| s.content.chars().count()) .map(|s| s.content.chars().count())
.sum(); .sum();
@@ -505,9 +716,11 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
}); });
} }
} }
}
// ── Tables ──────────────────────────────────────────────────────────── // ── Tables ────────────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => { Event::Start(Tag::Table(alignments)) => {
state.finalize_text_section();
state.table_alignments = alignments; state.table_alignments = alignments;
state.table_rows.clear(); state.table_rows.clear();
state.in_table = true; state.in_table = true;
@@ -582,10 +795,10 @@ fn emit_code_block(
// Compute box width: at least lang.len() + 6, capped at terminal width // Compute box width: at least lang.len() + 6, capped at terminal width
let max_content_width = highlighted.iter() let max_content_width = highlighted.iter()
.map(|l| l.spans.iter().map(|s| s.content.len()).sum::<usize>()) .map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::<usize>())
.max() .max()
.unwrap_or(0); .unwrap_or(0);
let min_width_for_lang = if lang.is_empty() { 4 } else { lang.len() + 6 }; let min_width_for_lang = if lang.is_empty() { 4 } else { lang.chars().count() + 6 };
let box_width = (max_content_width + 4) let box_width = (max_content_width + 4)
.max(min_width_for_lang) .max(min_width_for_lang)
.min(width as usize); .min(width as usize);
@@ -603,7 +816,7 @@ fn emit_code_block(
} else { } else {
// With language label: ╭─ {lang} ─...─╮ // With language label: ╭─ {lang} ─...─╮
let label = format!("{} ", lang); let label = format!("{} ", lang);
let used = label.len() + 2; // ╭ + label + ╮ let used = label.chars().count() + 2; // ╭ + label + ╮
let fill_len = box_width.saturating_sub(used); let fill_len = box_width.saturating_sub(used);
let fill = "".repeat(fill_len); let fill = "".repeat(fill_len);
Line::from(vec![ Line::from(vec![
@@ -630,7 +843,7 @@ fn emit_code_block(
])); ]));
} else { } else {
for hl_line in highlighted { for hl_line in highlighted {
let content_len: usize = hl_line.spans.iter().map(|s| s.content.len()).sum(); let content_len: usize = hl_line.spans.iter().map(|s| s.content.chars().count()).sum();
let inner_width = box_width.saturating_sub(4); let inner_width = box_width.saturating_sub(4);
let pad_len = inner_width.saturating_sub(content_len); let pad_len = inner_width.saturating_sub(content_len);
let padding = " ".repeat(pad_len); let padding = " ".repeat(pad_len);
@@ -691,7 +904,7 @@ fn emit_table(
for row in rows { for row in rows {
for (i, cell) in row.iter().enumerate() { for (i, cell) in row.iter().enumerate() {
if i < n_cols { if i < n_cols {
col_widths[i] = col_widths[i].max(cell.len() + 2); col_widths[i] = col_widths[i].max(cell.chars().count() + 2);
} }
} }
} }
@@ -779,7 +992,7 @@ fn emit_table_row(
fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String { fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// width includes the surrounding single-space padding on each side // width includes the surrounding single-space padding on each side
let content_width = width.saturating_sub(2); let content_width = width.saturating_sub(2);
let text_len = text.len(); let text_len = text.chars().count();
match alignment { match alignment {
Alignment::Right => { Alignment::Right => {
@@ -808,12 +1021,17 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// ── Public API ──────────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────────
/// Convert a markdown string into styled ratatui lines plus link metadata. /// Convert a markdown string into styled ratatui lines plus link, copyable block,
/// and image metadata.
/// ///
/// Returns a pair `(Vec<Line<'static>>, Vec<LinkRecord>)`: /// Returns a 4-tuple `(Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>)`:
/// - Lines: styled display content for ratatui `Paragraph` /// - Lines: styled display content for ratatui `Paragraph`
/// - LinkRecords: parallel metadata for every link found (line_index, col_offset, /// - LinkRecords: parallel metadata for every link found (line_index, col_offset,
/// span_len, dest, is_wiki) — consumed by Plan 02 for Tab-cycling navigation /// span_len, dest, is_wiki) — consumed for Tab-cycling navigation
/// - CopyableBlocks: metadata for every copyable block (start_line, end_line,
/// raw_content, kind) — consumed for copy mode and OSC 52 clipboard
/// - ImageRecords: metadata for every image found (line_index, height, source,
/// alt) — consumed for terminal image overlay rendering
/// ///
/// The `vault_path` parameter is used to resolve wiki-links at render time, /// The `vault_path` parameter is used to resolve wiki-links at render time,
/// enabling broken wiki-links to be shown in red/strikethrough inline. Pass /// enabling broken wiki-links to be shown in red/strikethrough inline. Pass
@@ -831,7 +1049,7 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
/// ///
/// Panics if `crate::highlighter::init_highlighter()` has not been called before /// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// the first code block is encountered. /// the first code block is encountered.
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>) { pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>, Vec<ImageRecord>) {
let mut opts = Options::empty(); let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_STRIKETHROUGH);
+7 -1
View File
@@ -14,6 +14,7 @@ use std::io::stdout;
use ratatui::crossterm::{ use ratatui::crossterm::{
execute, execute,
terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
event::{EnableMouseCapture, DisableMouseCapture},
cursor::Show, cursor::Show,
}; };
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport}; use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
@@ -30,7 +31,7 @@ pub type Term = Terminal<CrosstermBackend<std::io::Stdout>>;
/// Returns an error if raw mode cannot be enabled or the alternate screen cannot be entered. /// Returns an error if raw mode cannot be enabled or the alternate screen cannot be entered.
pub fn init_terminal() -> std::io::Result<Term> { pub fn init_terminal() -> std::io::Result<Term> {
enable_raw_mode()?; enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?; execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout()); let backend = CrosstermBackend::new(stdout());
Terminal::with_options(backend, TerminalOptions { Terminal::with_options(backend, TerminalOptions {
viewport: Viewport::Fullscreen, viewport: Viewport::Fullscreen,
@@ -43,6 +44,7 @@ pub fn init_terminal() -> std::io::Result<Term> {
/// uses `let _ =` to suppress errors — this function is called from cleanup paths /// uses `let _ =` to suppress errors — this function is called from cleanup paths
/// including the panic hook, where panicking would hide the original error. /// including the panic hook, where panicking would hide the original error.
pub fn restore_terminal() { pub fn restore_terminal() {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
let _ = execute!(std::io::stdout(), LeaveAlternateScreen); let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), Show); let _ = execute!(std::io::stdout(), Show);
@@ -60,6 +62,10 @@ pub fn install_panic_hook() {
let original_hook = std::panic::take_hook(); let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| { std::panic::set_hook(Box::new(move |panic_info| {
// Restore terminal — use let _ to avoid double-panic if cleanup itself fails // Restore terminal — use let _ to avoid double-panic if cleanup itself fails
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::DisableMouseCapture
);
let _ = ratatui::crossterm::execute!( let _ = ratatui::crossterm::execute!(
std::io::stdout(), std::io::stdout(),
ratatui::crossterm::terminal::LeaveAlternateScreen ratatui::crossterm::terminal::LeaveAlternateScreen
+146 -1
View File
@@ -1,5 +1,6 @@
use std::io::{self, BufRead}; use std::io::{self, BufRead, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use walkdir::WalkDir; use walkdir::WalkDir;
// ── VaultDocument ───────────────────────────────────────────────────────────── // ── VaultDocument ─────────────────────────────────────────────────────────────
@@ -189,6 +190,150 @@ pub fn resolve_standard_link(vault_path: &Path, current_doc: &str, dest: &str) -
None None
} }
// ── Remote document fetching ──────────────────────────────────────────────────
/// Result of attempting to fetch a remote markdown document.
pub enum RemoteDocument {
/// Successfully fetched and content appears to be markdown.
Loaded { url: String, content: String },
/// Domain is not in the whitelist.
DomainNotAllowed { domain: String },
/// HTTP request failed (network error, timeout, non-2xx status).
FetchError { url: String, reason: String },
/// Response content does not appear to be markdown (e.g. HTML page, binary).
NotMarkdown { url: String, content_type: String },
}
/// Extract the domain from a URL string.
///
/// Examples:
/// - `https://example.com/foo` → `example.com`
/// - `https://sub.example.com:8080/bar` → `sub.example.com`
fn extract_domain(url: &str) -> Option<String> {
// Split off the scheme: "https://example.com/..." → "example.com/..."
let after_scheme = url.split_once("://")?.1;
// Take everything before the first '/'
let host_port = after_scheme.split('/').next()?;
// Strip port number if present (last ':' only if it looks like a port)
let domain = match host_port.rfind(':') {
Some(i) => {
let port_part = &host_port[i + 1..];
if port_part.chars().all(|c| c.is_ascii_digit()) {
&host_port[..i]
} else {
host_port
}
}
None => host_port,
};
Some(domain.to_lowercase())
}
/// Check whether `domain` is permitted by the `allowed_domains` list.
///
/// Matching rules (case-insensitive):
/// - Exact match: `example.com` matches `example.com`
/// - Subdomain match: `sub.example.com` matches whitelist entry `example.com`
pub fn domain_is_allowed(domain: &str, allowed_domains: &[String]) -> bool {
for allowed in allowed_domains {
let allowed_lower = allowed.to_lowercase();
if domain == allowed_lower {
return true;
}
// Allow subdomains: "sub.example.com" matches "example.com"
let suffix = format!(".{}", allowed_lower);
if domain.ends_with(&suffix) {
return true;
}
}
false
}
/// Fetch a remote markdown document from `url` and validate its content.
///
/// Steps:
/// 1. Extract domain from URL and check against `allowed_domains` whitelist.
/// 2. Issue a GET request with a 10-second timeout via ureq.
/// 3. Validate the Content-Type header (accept markdown/plain text; reject HTML/binary).
/// 4. Read the response body (capped at 5 MB to prevent memory exhaustion).
/// 5. Return the appropriate `RemoteDocument` variant.
pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String], max_size: u64) -> RemoteDocument {
// Step 1: Domain whitelist check
let domain = match extract_domain(url) {
Some(d) => d,
None => {
return RemoteDocument::FetchError {
url: url.to_string(),
reason: "Could not parse domain from URL".to_string(),
};
}
};
if !domain_is_allowed(&domain, allowed_domains) {
return RemoteDocument::DomainNotAllowed { domain };
}
// Step 2: HTTP GET with timeout
let response = match ureq::get(url)
.timeout(Duration::from_secs(10))
.call()
{
Ok(resp) => resp,
Err(ureq::Error::Status(code, resp)) => {
return RemoteDocument::FetchError {
url: url.to_string(),
reason: format!("HTTP {} {}", code, resp.status_text()),
};
}
Err(e) => {
return RemoteDocument::FetchError {
url: url.to_string(),
reason: e.to_string(),
};
}
};
// Step 3: Content-Type validation
let content_type = response
.header("Content-Type")
.unwrap_or("application/octet-stream")
.to_lowercase();
// Strip parameters like "; charset=utf-8"
let ct_base = content_type.split(';').next().unwrap_or("").trim().to_string();
let url_path_is_md = url.split('?').next().unwrap_or(url).ends_with(".md");
let is_acceptable = matches!(
ct_base.as_str(),
"text/markdown" | "text/plain" | "text/x-markdown"
) || url_path_is_md;
let is_html = ct_base == "text/html";
if is_html || (!is_acceptable && !url_path_is_md) {
return RemoteDocument::NotMarkdown {
url: url.to_string(),
content_type: ct_base,
};
}
// Step 4: Read body with 5 MB limit
let mut body = String::new();
let mut reader = response.into_reader().take(max_size);
if let Err(e) = reader.read_to_string(&mut body) {
return RemoteDocument::FetchError {
url: url.to_string(),
reason: format!("Failed to read response body: {}", e),
};
}
RemoteDocument::Loaded {
url: url.to_string(),
content: body,
}
}
// ── Directory listing ───────────────────────────────────────────────────────── // ── Directory listing ─────────────────────────────────────────────────────────
/// Entry in the vault directory listing. /// Entry in the vault directory listing.