Compare commits
11 Commits
f0ec2edc53
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
065365662c
|
|||
|
8ef587c163
|
|||
|
f7870179ee
|
|||
|
37b8352919
|
|||
|
c8d4754340
|
|||
|
5759ec83e6
|
|||
|
eb1c7866ce
|
|||
| 1752554cf6 | |||
| 4464774c7a | |||
| f3d787a2b7 | |||
| 2d1e01821c |
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
+20
-4
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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 ``.
|
||||||
|
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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user