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:
+706
-49
File diff suppressed because it is too large
Load Diff
+162
-16
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user