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
|
//! # 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 std::path::Path;
|
||||||
use pulldown_cmark::{
|
use pulldown_cmark::{
|
||||||
@@ -37,6 +37,32 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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 +133,16 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderState {
|
impl RenderState {
|
||||||
@@ -133,6 +169,11 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +203,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 +254,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 +332,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>) {
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +403,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 +413,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 +464,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 +479,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 +522,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 +535,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),
|
||||||
@@ -460,6 +600,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)
|
||||||
};
|
};
|
||||||
@@ -508,6 +651,7 @@ 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 +726,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 +747,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 +774,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 +835,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 +923,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 +952,14 @@ 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 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`
|
/// - 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
|
||||||
///
|
///
|
||||||
/// 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 +977,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>) {
|
||||||
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);
|
||||||
|
|||||||
+2
-2
@@ -211,7 +211,7 @@ pub enum RemoteDocument {
|
|||||||
/// - `https://sub.example.com:8080/bar` → `sub.example.com`
|
/// - `https://sub.example.com:8080/bar` → `sub.example.com`
|
||||||
fn extract_domain(url: &str) -> Option<String> {
|
fn extract_domain(url: &str) -> Option<String> {
|
||||||
// Split off the scheme: "https://example.com/..." → "example.com/..."
|
// 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 '/'
|
// Take everything before the first '/'
|
||||||
let host_port = after_scheme.split('/').next()?;
|
let host_port = after_scheme.split('/').next()?;
|
||||||
// Strip port number if present (last ':' only if it looks like a port)
|
// 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)) => {
|
Err(ureq::Error::Status(code, resp)) => {
|
||||||
return RemoteDocument::FetchError {
|
return RemoteDocument::FetchError {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
reason: format!("HTTP {} {}", code, resp.status_text().to_string()),
|
reason: format!("HTTP {} {}", code, resp.status_text()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user