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
This commit is contained in:
2026-03-01 13:15:23 +01:00
parent 5759ec83e6
commit c8d4754340
3 changed files with 870 additions and 67 deletions
+706 -49
View File
File diff suppressed because it is too large Load Diff
+162 -16
View File
@@ -6,7 +6,7 @@
//!
//! # 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>)`
use std::path::Path;
use pulldown_cmark::{
@@ -37,6 +37,32 @@ pub struct LinkRecord {
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,
}
// ── Internal pending link helpers ─────────────────────────────────────────────
/// Captured at `Tag::Link` Start — records link metadata before the text spans are pushed.
@@ -107,6 +133,16 @@ struct RenderState {
pending_link_records: Vec<PendingLinkRecord>,
/// All fully resolved link records (line_index filled in at flush time).
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,
}
impl RenderState {
@@ -133,6 +169,11 @@ impl RenderState {
link_span_start_count: 0,
pending_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(),
}
}
@@ -162,6 +203,22 @@ impl RenderState {
fn flush_line(&mut self) {
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 {
// Re-color content spans to Gray
for span in spans.iter_mut() {
@@ -197,6 +254,32 @@ impl RenderState {
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 ──────────────────────────────────────────────────────
fn start_heading(&mut self, level: HeadingLevel) {
@@ -249,32 +332,68 @@ impl RenderState {
// ── Code block emitter ────────────────────────────────────────────────────
/// 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) {
let code = std::mem::take(&mut self.code_buf);
let lang = std::mem::take(&mut self.code_lang);
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);
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 ─────────────────────────────────────────────────────────
/// 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) {
let alignments = std::mem::take(&mut self.table_alignments);
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);
let end_line = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line,
end_line,
raw_content,
kind: BlockKind::Table,
});
}
// ── Finish ────────────────────────────────────────────────────────────────
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>) {
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>) {
// Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() {
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)
}
}
@@ -284,6 +403,8 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
match event {
// ── Headings ──────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => {
state.finalize_text_section();
state.text_section_start = Some(state.lines.len());
state.start_heading(level);
}
Event::End(TagEnd::Heading(level)) => {
@@ -292,7 +413,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Paragraphs ────────────────────────────────────────────────────────
Event::Start(Tag::Paragraph) => {
// Nothing — spans accumulate until End
state.ensure_text_section();
}
Event::End(TagEnd::Paragraph) => {
state.flush_line();
@@ -343,6 +464,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Code blocks ───────────────────────────────────────────────────────
Event::Start(Tag::CodeBlock(kind)) => {
state.finalize_text_section();
state.code_lang = match kind {
CodeBlockKind::Fenced(lang) => lang.to_string(),
CodeBlockKind::Indented => String::new(),
@@ -357,6 +479,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Lists ─────────────────────────────────────────────────────────────
Event::Start(Tag::List(start)) => {
state.ensure_text_section();
state.list_counters.push(start);
}
Event::End(TagEnd::List(_)) => {
@@ -399,7 +522,9 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Blockquotes ───────────────────────────────────────────────────────
Event::Start(Tag::BlockQuote(_)) => {
if state.blockquote_depth == 0 {
state.finalize_text_section();
state.push_blank();
state.blockquote_start_line = Some(state.lines.len());
}
state.blockquote_depth += 1;
state.in_blockquote = true;
@@ -410,12 +535,27 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
}
if state.blockquote_depth == 0 {
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();
}
}
// ── Horizontal rules ──────────────────────────────────────────────────
Event::Rule => {
state.finalize_text_section();
let w = state.width as usize;
state.lines.push(Line::from(Span::styled(
"".repeat(w),
@@ -460,6 +600,9 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
} else {
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 {
Style::default().fg(Color::LightCyan)
};
@@ -508,6 +651,7 @@ fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>
// ── Tables ────────────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => {
state.finalize_text_section();
state.table_alignments = alignments;
state.table_rows.clear();
state.in_table = true;
@@ -582,10 +726,10 @@ fn emit_code_block(
// Compute box width: at least lang.len() + 6, capped at terminal width
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()
.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)
.max(min_width_for_lang)
.min(width as usize);
@@ -603,7 +747,7 @@ fn emit_code_block(
} else {
// With language label: ╭─ {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 = "".repeat(fill_len);
Line::from(vec![
@@ -630,7 +774,7 @@ fn emit_code_block(
]));
} else {
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 pad_len = inner_width.saturating_sub(content_len);
let padding = " ".repeat(pad_len);
@@ -691,7 +835,7 @@ fn emit_table(
for row in rows {
for (i, cell) in row.iter().enumerate() {
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 +923,7 @@ fn emit_table_row(
fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// width includes the surrounding single-space padding on each side
let content_width = width.saturating_sub(2);
let text_len = text.len();
let text_len = text.chars().count();
match alignment {
Alignment::Right => {
@@ -808,12 +952,14 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// ── Public API ────────────────────────────────────────────────────────────────
/// Convert a markdown string into styled ratatui lines plus link metadata.
/// Convert a markdown string into styled ratatui lines plus link and copyable block metadata.
///
/// Returns a pair `(Vec<Line<'static>>, Vec<LinkRecord>)`:
/// Returns a 3-tuple `(Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>)`:
/// - Lines: styled display content for ratatui `Paragraph`
/// - 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
///
/// 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
@@ -831,7 +977,7 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
///
/// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// 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>) {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
+2 -2
View File
@@ -211,7 +211,7 @@ pub enum RemoteDocument {
/// - `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.splitn(2, "://").nth(1)?;
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)
@@ -282,7 +282,7 @@ pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String]) -> RemoteDoc
Err(ureq::Error::Status(code, resp)) => {
return RemoteDocument::FetchError {
url: url.to_string(),
reason: format!("HTTP {} {}", code, resp.status_text().to_string()),
reason: format!("HTTP {} {}", code, resp.status_text()),
};
}
Err(e) => {