diff --git a/src/app.rs b/src/app.rs index a81917b..3ff2052 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,11 +20,11 @@ //! - `Err(BrokenPipe)` — SSH connection closed mid-write (silent exit) //! - `Err(_)` — unexpected I/O error (logged to stderr after terminal restore) -use std::io; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::time::{Duration, Instant, UNIX_EPOCH}; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; @@ -218,9 +218,17 @@ pub struct App { link_records: Vec, /// Index of the currently selected link (None = no link selected). selected_link: Option, + /// True when link navigation mode is active (Tab to enter, Esc to exit). + /// In link mode, Up/Down cycle through links instead of scrolling. + link_mode: bool, /// Current document's vault-relative path (e.g. "index.md", "guides/page.md"). current_path: String, + // ── Quick-3 additions ───────────────────────────────────────────────────── + /// When viewing a remote document, stores the source URL for display in breadcrumb. + /// None when viewing a local vault document. + current_url: Option, + // ── Phase 4 additions ───────────────────────────────────────────────────── /// File metadata (mtime, size) for the currently displayed document. /// None for error screens or when metadata cannot be read. @@ -230,6 +238,18 @@ pub struct App { /// Debounce timer: set to Some(Instant) when a relevant file event arrives. /// Reload fires 300ms after the last event. pending_reload_at: Option, + /// Cached content area rect from the last draw, used for mouse hit-testing. + last_inner_content: Rect, + /// When true, follow the selected link after the next draw (deferred for visual feedback). + pending_click_follow: bool, + /// Copyable block records from the current rendered document. + copyable_blocks: Vec, + /// When set, show "Copied!" in the status bar until this instant. + copy_feedback_until: Option, + /// True when copy mode is active (press c to enter, Esc to exit). + copy_mode: bool, + /// Index of the currently selected copyable block in copy mode (None = none selected). + selected_block: Option, } impl App { @@ -250,6 +270,7 @@ impl App { document: DocumentState, raw_content: Option, link_records: Vec, + copyable_blocks: Vec, current_path: String, file_watcher: Option, ) -> Self { @@ -286,10 +307,18 @@ impl App { history_index: 0, link_records, selected_link: None, + link_mode: false, current_path, + current_url: None, page_meta, file_watcher, pending_reload_at: None, + last_inner_content: Rect::default(), + pending_click_follow: false, + copyable_blocks, + copy_feedback_until: None, + copy_mode: false, + selected_block: None, } } @@ -324,12 +353,21 @@ impl App { // main.rs catches BrokenPipe and exits cleanly (LIFE-04). terminal.draw(|frame| self.draw(frame))?; + // 2a. Follow a deferred click — the link was highlighted in the draw above + if self.pending_click_follow { + self.pending_click_follow = false; + self.follow_selected_link(); + } + // 3. Poll for events with a 250ms timeout if event::poll(Duration::from_millis(250))? { match event::read()? { Event::Key(key) => { self.handle_key(key); } + Event::Mouse(mouse) => { + self.handle_mouse(mouse); + } Event::Resize(w, _h) => { self.handle_resize(w); } @@ -385,6 +423,13 @@ impl App { self.show_quit_prompt = false; } } + + // 5a. Clear copy feedback when expired + if let Some(until) = self.copy_feedback_until { + if Instant::now() >= until { + self.copy_feedback_until = None; + } + } } } @@ -401,10 +446,13 @@ impl App { if let Some(ref content) = self.raw_content.clone() { let vault_path = self.config.vault_path.clone(); - let (mut lines, mut link_records) = crate::renderer::render_markdown( + let width = new_width.saturating_sub(self.config.margin * 2); + // Remote pages have no vault context — wiki-links cannot resolve + let vault_ref = if self.current_url.is_some() { None } else { Some(&*vault_path) }; + let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown( content, - new_width, - Some(&vault_path), + width, + vault_ref, ); // Prepend splash lines for index.md (same logic as navigate_to) @@ -418,16 +466,27 @@ impl App { for record in &mut link_records { record.line_index += splash_count; } + for record in &mut copyable_blocks { + record.start_line += splash_count; + record.end_line += splash_count; + } } } self.link_records = link_records; + self.copyable_blocks = copyable_blocks; // Preserve selected_link if still valid after re-render if let Some(i) = self.selected_link { if i >= self.link_records.len() { self.selected_link = None; } } + // Preserve selected_block if still valid after re-render + if let Some(i) = self.selected_block { + if i >= self.copyable_blocks.len() { + self.selected_block = None; + } + } let filename = self.filename.clone(); self.document = DocumentState::Loaded { filename, lines }; // Clamp scroll to new max after re-render @@ -440,79 +499,149 @@ impl App { /// Handle a single key event and update app state accordingly. /// - /// # Key bindings (in match order) + /// # Key bindings /// + /// ## Always active /// - `Ctrl+C` — first press opens double-press window; second press (within 2s) quits /// - `q` — quits immediately (suppressed in login shell mode) - /// - `Tab` — select next link (wrap-around) - /// - `Shift+Tab` — select previous link (wrap-around) - /// - `Enter` — follow the selected link + /// - `Enter` — follow the selected link (if any) /// - `Backspace` — navigate back in history /// - `Alt+Left` — navigate back in history /// - `Alt+Right` — navigate forward in history - /// - `Left` — navigate back in history (same as Backspace) - /// - `Right` — navigate forward in history (same as Alt+Right) + /// + /// ## Normal mode (link_mode = false) + /// - `Tab` — enter link mode and select first link /// - `j` / `Down` — scroll down one line /// - `k` / `Up` — scroll up one line + /// - `Left` — navigate back in history + /// - `Right` — navigate forward in history /// - `PgDn` — scroll down one page /// - `PgUp` — scroll up one page - /// - Any other key — if the quit prompt is showing, dismisses it + /// + /// ## Link mode (link_mode = true) + /// - `Esc` / `Tab` — exit link mode, deselect link + /// - `Down` / `j` — select next link + /// - `Up` / `k` — select previous link + /// - `Left` — navigate back (exits link mode) + /// - `Right` — navigate forward (exits link mode) fn handle_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Double Ctrl+C state machine if let Some(pressed_at) = self.ctrl_c_pressed_at { if pressed_at.elapsed() < DOUBLE_PRESS_WINDOW { - // Second press within the window — quit self.should_quit = true; } else { - // Window expired; treat as a first press self.ctrl_c_pressed_at = Some(Instant::now()); self.show_quit_prompt = true; } } else { - // First press — open the confirmation window self.ctrl_c_pressed_at = Some(Instant::now()); self.show_quit_prompt = true; } } KeyCode::Char('q') if !self.is_login_shell => { - // 'q' is suppressed in login shell mode — only double Ctrl+C exits self.should_quit = true; } - // ── Navigation keys ─────────────────────────────────────────────── + // ── Tab: toggle link mode (exit copy mode first) ───────────────── KeyCode::Tab => { - self.select_next_link(); + if self.copy_mode { + self.copy_mode = false; + self.selected_block = None; + // Enter link mode + if !self.link_records.is_empty() { + self.link_mode = true; + self.selected_link = Some(0); + self.scroll_to_selected_link(); + } + } else if self.link_mode && self.current_path != "__directory__" { + // Exit link mode (not on directory — always in link mode there) + self.link_mode = false; + self.selected_link = None; + } else if !self.link_mode && !self.link_records.is_empty() { + // Enter link mode, select first link + self.link_mode = true; + self.selected_link = Some(0); + self.scroll_to_selected_link(); + } } - KeyCode::BackTab => { - self.select_prev_link(); + // ── Esc: exit copy mode, exit link mode, or navigate back ───────── + KeyCode::Esc => { + if self.copy_mode { + self.copy_mode = false; + self.selected_block = None; + } else if self.current_path == "__directory__" { + // On directory page, Esc navigates back + self.link_mode = false; + self.selected_link = None; + self.navigate_back(); + } else if self.link_mode { + self.link_mode = false; + self.selected_link = None; + } } + // ── Enter: in copy mode → copy; otherwise follow link ───────────── KeyCode::Enter => { - self.follow_selected_link(); + if self.copy_mode { + self.copy_selected_block(); + self.copy_mode = false; + self.selected_block = None; + } else { + self.follow_selected_link(); + } } + // ── Backspace: exit copy mode + navigate back ───────────────────── KeyCode::Backspace => { + self.copy_mode = false; + self.selected_block = None; + self.link_mode = false; + self.selected_link = None; self.navigate_back(); } - // Alt+Left = back, Alt+Right = forward + // ── Alt+arrows: always navigate back/forward (exit copy mode) ───── KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => { + self.copy_mode = false; + self.selected_block = None; + self.link_mode = false; + self.selected_link = None; self.navigate_back(); } KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => { + self.copy_mode = false; + self.selected_block = None; + self.link_mode = false; + self.selected_link = None; self.navigate_forward(); } - // Bare Left/Right arrows also navigate back/forward - KeyCode::Left => { + // ── Left/Right: exit copy mode + navigate (only outside link mode) ─ + KeyCode::Left if !self.link_mode => { + self.copy_mode = false; + self.selected_block = None; self.navigate_back(); } - KeyCode::Right => { + KeyCode::Right if !self.link_mode => { + self.copy_mode = false; + self.selected_block = None; self.navigate_forward(); } - // ── Scrolling keys — do NOT dismiss the quit prompt ─────────────── + // ── Up/Down + j/k: copy mode → cycle blocks; link mode → cycle links; else scroll KeyCode::Char('j') | KeyCode::Down => { - self.scroll_down(1); + if self.copy_mode { + self.select_next_block(); + } else if self.link_mode { + self.select_next_link(); + } else { + self.scroll_down(1); + } } KeyCode::Char('k') | KeyCode::Up => { - self.scroll_up(1); + if self.copy_mode { + self.select_prev_block(); + } else if self.link_mode { + self.select_prev_link(); + } else { + self.scroll_up(1); + } } KeyCode::PageDown => { let h = self.page_height(); @@ -522,8 +651,25 @@ impl App { let h = self.page_height(); self.scroll_up(h); } + // ── c: enter/execute copy mode ──────────────────────────────────── + KeyCode::Char('c') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if self.copy_mode { + // In copy mode: copy selected block and exit + self.copy_selected_block(); + self.copy_mode = false; + self.selected_block = None; + } else if !self.link_mode { + // Enter copy mode (mutually exclusive with link mode) + if !self.copyable_blocks.is_empty() { + self.copy_mode = true; + self.link_mode = false; + self.selected_link = None; + self.selected_block = self.first_visible_block(); + self.scroll_to_selected_block(); + } + } + } _ => { - // Any other key dismisses the quit prompt if self.show_quit_prompt { self.ctrl_c_pressed_at = None; self.show_quit_prompt = false; @@ -532,6 +678,160 @@ impl App { } } + // ── Mouse handling ───────────────────────────────────────────────────────── + + /// Handle a mouse event: scroll wheel, mouse-down highlight, mouse-up follow. + fn handle_mouse(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollDown => self.scroll_down(3), + MouseEventKind::ScrollUp => self.scroll_up(3), + MouseEventKind::Down(MouseButton::Left) => { + self.handle_mouse_down(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) => { + self.handle_mouse_up(mouse.column, mouse.row); + } + MouseEventKind::Down(MouseButton::Right) => { + if let Some(doc_line) = self.screen_to_doc_line(mouse.column, mouse.row) { + if let Some(idx) = self.block_at_line(doc_line) { + self.copy_block(idx); + } + } + } + _ => {} + } + } + + /// Mouse down: highlight the link under the cursor (inverse feedback). + fn handle_mouse_down(&mut self, col: u16, row: u16) { + if let Some(i) = self.link_at(col, row) { + self.selected_link = Some(i); + self.link_mode = true; + } + } + + /// Mouse up: follow the link if released on the same link that was pressed. + fn handle_mouse_up(&mut self, col: u16, row: u16) { + let released_on = self.link_at(col, row); + if let (Some(selected), Some(released)) = (self.selected_link, released_on) { + if selected == released { + self.pending_click_follow = true; + } + } + } + + /// Convert screen coordinates to a document line index, or None if outside content area. + fn screen_to_doc_line(&self, col: u16, row: u16) -> Option { + let area = self.last_inner_content; + + if col < area.x || col >= area.x + area.width + || row < area.y || row >= area.y + area.height + { + return None; + } + + let content_row = row - area.y; + Some((self.scroll_offset + content_row) as usize) + } + + /// Find the link index at screen coordinates, or None. + fn link_at(&self, col: u16, row: u16) -> Option { + let doc_line = self.screen_to_doc_line(col, row)?; + let content_col = (col - self.last_inner_content.x) as usize; + + for (i, record) in self.link_records.iter().enumerate() { + if record.line_index == doc_line + && content_col >= record.col_offset + && content_col < record.col_offset + record.span_len + { + return Some(i); + } + } + None + } + + /// Find the copyable block index that contains the given document line, or None. + fn block_at_line(&self, doc_line: usize) -> Option { + self.copyable_blocks.iter().position(|r| { + doc_line >= r.start_line && doc_line <= r.end_line + }) + } + + /// Copy a copyable block's raw content to the system clipboard via OSC 52. + fn copy_block(&mut self, index: usize) { + if let Some(record) = self.copyable_blocks.get(index) { + let encoded = base64_encode(&record.raw_content); + let _ = write!(std::io::stdout(), "\x1b]52;c;{}\x07", encoded); + let _ = std::io::stdout().flush(); + self.copy_feedback_until = Some(Instant::now() + Duration::from_secs(2)); + } + } + + // ── Copy mode helpers ──────────────────────────────────────────────────── + + /// Select the next copyable block, wrapping from last to first. + fn select_next_block(&mut self) { + if self.copyable_blocks.is_empty() { + return; + } + let next = match self.selected_block { + Some(i) => (i + 1) % self.copyable_blocks.len(), + None => 0, + }; + self.selected_block = Some(next); + self.scroll_to_selected_block(); + } + + /// Select the previous copyable block, wrapping from first to last. + fn select_prev_block(&mut self) { + if self.copyable_blocks.is_empty() { + return; + } + let prev = match self.selected_block { + Some(0) => self.copyable_blocks.len() - 1, + Some(i) => i - 1, + None => self.copyable_blocks.len() - 1, + }; + self.selected_block = Some(prev); + self.scroll_to_selected_block(); + } + + /// Auto-scroll to center the selected block on screen if it's off-screen. + fn scroll_to_selected_block(&mut self) { + if let Some(i) = self.selected_block { + if let Some(record) = self.copyable_blocks.get(i) { + let block_line = record.start_line as u16; + let viewport_start = self.scroll_offset; + let viewport_end = viewport_start + self.last_content_height; + + if block_line < viewport_start || block_line >= viewport_end { + let half = self.last_content_height / 2; + self.scroll_offset = block_line.saturating_sub(half); + let max = self.max_scroll(); + if self.scroll_offset > max { + self.scroll_offset = max; + } + } + } + } + } + + /// Find the first copyable block that overlaps the current viewport. + fn first_visible_block(&self) -> Option { + let viewport_start = self.scroll_offset as usize; + let viewport_end = viewport_start + self.last_content_height as usize; + self.copyable_blocks.iter().position(|r| { + r.start_line < viewport_end && r.end_line >= viewport_start + }) + } + + /// Copy the currently selected block to clipboard via OSC 52. + fn copy_selected_block(&mut self) { + if let Some(idx) = self.selected_block { + self.copy_block(idx); + } + } + // ── Navigation methods ───────────────────────────────────────────────────── /// Navigate to a new document by vault-relative path. @@ -560,8 +860,9 @@ impl App { .unwrap_or_else(|| vault_relative.to_string()); let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) - .unwrap_or(80); - let (mut lines, mut link_records) = + .unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); // Read file metadata for status bar @@ -579,6 +880,10 @@ impl App { for record in &mut link_records { record.line_index += splash_count; } + for record in &mut copyable_blocks { + record.start_line += splash_count; + record.end_line += splash_count; + } } } @@ -588,9 +893,14 @@ impl App { }; self.raw_content = Some(content); self.link_records = link_records; + self.copyable_blocks = copyable_blocks; self.selected_link = None; + self.link_mode = false; + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = 0; self.current_path = vault_relative.to_string(); + self.current_url = None; // Clear remote state when navigating to a local page self.filename = filename; // 4. Push new history entry @@ -606,14 +916,20 @@ impl App { self.document = DocumentState::Missing { path }; self.raw_content = None; self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); self.selected_link = None; + self.copy_mode = false; + self.selected_block = None; self.page_meta = None; } crate::vault::VaultDocument::ReadError { path, reason } => { self.document = DocumentState::Error { path, reason }; self.raw_content = None; self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); self.selected_link = None; + self.copy_mode = false; + self.selected_block = None; self.page_meta = None; } } @@ -645,7 +961,11 @@ impl App { }; self.raw_content = None; // No raw markdown for virtual pages self.link_records = link_records; - self.selected_link = None; + self.copyable_blocks = Vec::new(); // No copyable blocks in directory listing + self.selected_link = if self.link_records.is_empty() { None } else { Some(0) }; + self.link_mode = true; // Directory is always in link mode + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = 0; self.current_path = "__directory__".to_string(); self.filename = "[Directory]".to_string(); @@ -665,9 +985,16 @@ impl App { /// For regular documents: re-read and re-render the markdown. /// For directory listing: regenerate from walkdir. /// For error/missing pages: do nothing. + /// For remote pages: skip reload (remote content does not live-reload from filesystem). fn reload_current_document(&mut self) { + // Remote pages do not live-reload + if self.current_url.is_some() { + return; + } + let scroll = self.scroll_offset; let selected = self.selected_link; + let selected_blk = self.selected_block; if self.current_path == "__directory__" { // Regenerate directory listing @@ -693,8 +1020,9 @@ impl App { .unwrap_or_else(|| current.clone()); let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) - .unwrap_or(80); - let (mut lines, mut link_records) = + .unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); // Splash prepend for index.md (same logic as navigate_to) @@ -709,6 +1037,10 @@ impl App { for record in link_records.iter_mut() { record.line_index += splash_count; } + for record in copyable_blocks.iter_mut() { + record.start_line += splash_count; + record.end_line += splash_count; + } } } @@ -718,6 +1050,7 @@ impl App { }; self.raw_content = Some(content); self.link_records = link_records; + self.copyable_blocks = copyable_blocks; self.filename = filename; // Update metadata @@ -738,6 +1071,15 @@ impl App { self.selected_link = None; } } + + // Preserve block selection if still valid + if let Some(i) = selected_blk { + if i < self.copyable_blocks.len() { + self.selected_block = Some(i); + } else { + self.selected_block = None; + } + } } /// Re-point the filesystem watcher to the appropriate directory for the current page. @@ -790,11 +1132,55 @@ impl App { }; self.raw_content = None; self.link_records = link_records; + self.copyable_blocks = Vec::new(); self.selected_link = target_link; + self.link_mode = true; // Directory is always in link mode + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = target_scroll; self.current_path = "__directory__".to_string(); + self.current_url = None; self.filename = "[Directory]".to_string(); self.page_meta = None; + } else if target_path.starts_with("http://") || target_path.starts_with("https://") { + // Remote URL — re-fetch (consistent with "re-load from disk" pattern) + // Temporarily move history_index back so navigate_to_remote doesn't double-push + // We call navigate_to_remote which will push a new entry, so we correct after. + // Simpler: inline the remote fetch logic here with scroll/link restoration. + match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains) { + crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => { + let filename = fetched_url + .split('?').next().unwrap_or(&fetched_url) + .trim_end_matches('/') + .rsplit('/').next() + .filter(|s| !s.is_empty()) + .unwrap_or("remote") + .to_string(); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w).unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (lines, link_records, copyable_blocks) = + crate::renderer::render_markdown(&content, width, None); + self.document = DocumentState::Loaded { filename: filename.clone(), lines }; + self.raw_content = Some(content); + self.link_records = link_records; + self.copyable_blocks = copyable_blocks; + self.selected_link = target_link; + self.link_mode = false; + self.copy_mode = false; + self.selected_block = None; + self.scroll_offset = target_scroll; + self.current_path = fetched_url.clone(); + self.current_url = Some(fetched_url); + self.filename = filename; + self.page_meta = None; + } + _ => { + // Fetch failed during back navigation — leave current doc unchanged + } + } + // Do not call rewatch_for_current_page — no file to watch + return; } else if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(&vault_path, &target_path) { @@ -804,8 +1190,9 @@ impl App { .unwrap_or_else(|| target_path.clone()); let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) - .unwrap_or(80); - let (mut lines, mut link_records) = + .unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); // Read file metadata for status bar @@ -822,6 +1209,10 @@ impl App { for record in &mut link_records { record.line_index += splash_count; } + for record in &mut copyable_blocks { + record.start_line += splash_count; + record.end_line += splash_count; + } } } @@ -831,9 +1222,13 @@ impl App { }; self.raw_content = Some(content); self.link_records = link_records; + self.copyable_blocks = copyable_blocks; self.selected_link = target_link; + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = target_scroll; self.current_path = target_path; + self.current_url = None; // Clear remote state for local navigation self.filename = filename; } // If file was deleted since last visit, leave current doc unchanged @@ -869,11 +1264,52 @@ impl App { }; self.raw_content = None; self.link_records = link_records; + self.copyable_blocks = Vec::new(); self.selected_link = target_link; + self.link_mode = true; // Directory is always in link mode + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = target_scroll; self.current_path = "__directory__".to_string(); + self.current_url = None; self.filename = "[Directory]".to_string(); self.page_meta = None; + } else if target_path.starts_with("http://") || target_path.starts_with("https://") { + // Remote URL — re-fetch (consistent with "re-load from disk" pattern) + match crate::vault::fetch_remote_markdown(&target_path, &self.config.allowed_remote_domains) { + crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => { + let filename = fetched_url + .split('?').next().unwrap_or(&fetched_url) + .trim_end_matches('/') + .rsplit('/').next() + .filter(|s| !s.is_empty()) + .unwrap_or("remote") + .to_string(); + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w).unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (lines, link_records, copyable_blocks) = + crate::renderer::render_markdown(&content, width, None); + self.document = DocumentState::Loaded { filename: filename.clone(), lines }; + self.raw_content = Some(content); + self.link_records = link_records; + self.copyable_blocks = copyable_blocks; + self.selected_link = target_link; + self.link_mode = false; + self.copy_mode = false; + self.selected_block = None; + self.scroll_offset = target_scroll; + self.current_path = fetched_url.clone(); + self.current_url = Some(fetched_url); + self.filename = filename; + self.page_meta = None; + } + _ => { + // Fetch failed during forward navigation — leave current doc unchanged + } + } + // Do not call rewatch_for_current_page — no file to watch + return; } else if let crate::vault::VaultDocument::Loaded { path, content } = crate::vault::load_document(&vault_path, &target_path) { @@ -883,8 +1319,9 @@ impl App { .unwrap_or_else(|| target_path.clone()); let width = ratatui::crossterm::terminal::size() .map(|(w, _)| w) - .unwrap_or(80); - let (mut lines, mut link_records) = + .unwrap_or(80) + .saturating_sub(self.config.margin * 2); + let (mut lines, mut link_records, mut copyable_blocks) = crate::renderer::render_markdown(&content, width, Some(&vault_path)); // Read file metadata for status bar @@ -901,6 +1338,10 @@ impl App { for record in &mut link_records { record.line_index += splash_count; } + for record in &mut copyable_blocks { + record.start_line += splash_count; + record.end_line += splash_count; + } } } @@ -910,9 +1351,13 @@ impl App { }; self.raw_content = Some(content); self.link_records = link_records; + self.copyable_blocks = copyable_blocks; self.selected_link = target_link; + self.copy_mode = false; + self.selected_block = None; self.scroll_offset = target_scroll; self.current_path = target_path; + self.current_url = None; // Clear remote state for local navigation self.filename = filename; } self.rewatch_for_current_page(); @@ -950,6 +1395,9 @@ impl App { // Standard markdown link — resolve relative to current document's directory if dest == "__directory__" { self.navigate_to_directory(); + } else if dest.starts_with("http://") || dest.starts_with("https://") { + // Remote HTTP/HTTPS link — fetch and render + self.navigate_to_remote(&dest); } else { match crate::vault::resolve_standard_link(&vault_path, &self.current_path, &dest) { Some(resolved) => { @@ -961,6 +1409,7 @@ impl App { let full_path = vault_path.join(&dest); self.document = DocumentState::Missing { path: full_path }; self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); self.selected_link = None; } } @@ -968,6 +1417,118 @@ impl App { } } + /// Navigate to a remote URL: fetch markdown, validate, render, push history. + /// + /// On success: renders the fetched markdown and pushes a history entry. + /// On any error: shows the BBS error screen without pushing history. + fn navigate_to_remote(&mut self, url: &str) { + // Save current state to history at current position + if let Some(entry) = self.history.get_mut(self.history_index) { + entry.scroll_offset = self.scroll_offset; + entry.selected_link = self.selected_link; + } + // Truncate forward history + self.history.truncate(self.history_index + 1); + + match crate::vault::fetch_remote_markdown(url, &self.config.allowed_remote_domains) { + crate::vault::RemoteDocument::Loaded { url: fetched_url, content } => { + // Derive a display filename from the URL path + let filename = fetched_url + .split('?') + .next() + .unwrap_or(&fetched_url) + .trim_end_matches('/') + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + // Fall back to the domain + fetched_url + .split_once("://") + .and_then(|(_, rest)| rest.split('/').next()) + .unwrap_or("remote") + }) + .to_string(); + + let width = ratatui::crossterm::terminal::size() + .map(|(w, _)| w) + .unwrap_or(80) + .saturating_sub(self.config.margin * 2); + + // Pass None for vault_path — wiki-links in remote content cannot resolve + let (lines, link_records, copyable_blocks) = + crate::renderer::render_markdown(&content, width, None); + + self.document = DocumentState::Loaded { + filename: filename.clone(), + lines, + }; + self.raw_content = Some(content); + self.link_records = link_records; + self.copyable_blocks = copyable_blocks; + self.selected_link = None; + self.link_mode = false; + self.copy_mode = false; + self.selected_block = None; + self.scroll_offset = 0; + self.current_path = fetched_url.clone(); + self.current_url = Some(fetched_url.clone()); + self.filename = filename; + self.page_meta = None; // No file metadata for remote pages + + // Push history entry (URL stored as the path) + self.history.push(HistoryEntry { + path: fetched_url, + scroll_offset: 0, + selected_link: None, + }); + self.history_index = self.history.len() - 1; + // Do NOT call rewatch_for_current_page — no file to watch + } + crate::vault::RemoteDocument::DomainNotAllowed { domain } => { + self.document = DocumentState::Error { + path: PathBuf::from(url), + reason: format!( + "Domain '{}' is not in the allowed remote domains list. \ + Add it to allowed_remote_domains in bbs.toml.", + domain + ), + }; + self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); + self.raw_content = None; + self.current_url = None; + self.page_meta = None; + // Do NOT push to history — same pattern as Missing/ReadError + } + crate::vault::RemoteDocument::FetchError { url: err_url, reason } => { + self.document = DocumentState::Error { + path: PathBuf::from(&err_url), + reason, + }; + self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); + self.raw_content = None; + self.current_url = None; + self.page_meta = None; + } + crate::vault::RemoteDocument::NotMarkdown { url: err_url, content_type } => { + self.document = DocumentState::Error { + path: PathBuf::from(&err_url), + reason: format!( + "Remote content is not markdown (Content-Type: {})", + content_type + ), + }; + self.link_records = Vec::new(); + self.copyable_blocks = Vec::new(); + self.raw_content = None; + self.current_url = None; + self.page_meta = None; + } + } + } + // ── Link cycling helpers ─────────────────────────────────────────────────── /// Select the next link (Tab key), wrapping from last to first. @@ -1071,14 +1632,38 @@ impl App { let content_area = chunks[0]; let status_area = chunks[1]; - // Update content height for page scrolling - self.last_content_height = content_area.height; + // Apply horizontal margin to content area (status bar stays full-width) + let margin = ratatui::layout::Margin { horizontal: self.config.margin, vertical: 0 }; + let inner_content = content_area.inner(margin); + + // Update content height for page scrolling and content rect for mouse hit-testing + self.last_content_height = inner_content.height; + self.last_inner_content = inner_content; // ── Content area ───────────────────────────────────────────────────── match &self.document { DocumentState::Loaded { lines, .. } => { - // Apply REVERSED modifier to the selected link at draw time - let display_lines = if let Some(selected_idx) = self.selected_link { + // Apply REVERSED modifier to the selected block (copy mode) + // or the selected link (link mode). They are mutually exclusive. + let display_lines = if self.copy_mode { + if let Some(block_idx) = self.selected_block { + if let Some(block) = self.copyable_blocks.get(block_idx) { + let mut cloned = lines.clone(); + for line_idx in block.start_line..=block.end_line { + if let Some(line) = cloned.get_mut(line_idx) { + for span in line.spans.iter_mut() { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + } + } + cloned + } else { + lines.clone() + } + } else { + lines.clone() + } + } else if let Some(selected_idx) = self.selected_link { if let Some(record) = self.link_records.get(selected_idx) { let mut cloned = lines.clone(); if let Some(line) = cloned.get_mut(record.line_index) { @@ -1109,16 +1694,16 @@ impl App { }; let para = Paragraph::new(display_lines).scroll((self.scroll_offset, 0)); - frame.render_widget(para, content_area); + frame.render_widget(para, inner_content); } DocumentState::Missing { path } => { let path = path.clone(); - self.draw_error_screen(frame, content_area, &path, None); + self.draw_error_screen(frame, inner_content, &path, None); } DocumentState::Error { path, reason } => { let path = path.clone(); let reason = reason.clone(); - self.draw_error_screen(frame, content_area, &path, Some(&reason)); + self.draw_error_screen(frame, inner_content, &path, Some(&reason)); } } @@ -1136,6 +1721,23 @@ impl App { let breadcrumb = build_breadcrumb(&self.current_path); let left = format!(" {} ", breadcrumb); + // Show "Copied!" feedback when active + if let Some(until) = self.copy_feedback_until { + if Instant::now() < until { + let right = " Copied! ".to_string(); + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + let bar = Paragraph::new(Line::from(vec![Span::styled( + format!("{}{}{}", left, padding, right), + Style::default() + .fg(Color::LightGreen) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + )])); + frame.render_widget(bar, area); + return; + } + } + if self.show_quit_prompt { let right = " Press Ctrl+C again to disconnect... ".to_string(); let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); @@ -1150,6 +1752,26 @@ impl App { return; } + // Copy mode status bar + if self.copy_mode { + let block_info = if let Some(i) = self.selected_block { + format!("Block {}/{}", i + 1, self.copyable_blocks.len()) + } else { + format!("0/{}", self.copyable_blocks.len()) + }; + let right = format!(" [Copy Mode] {} \u{2191}\u{2193}:Select c/Enter:Copy Esc:Cancel ", block_info); + let pad_len = width.saturating_sub(left.len()).saturating_sub(right.len()); + let padding = " ".repeat(pad_len); + let bar = Paragraph::new(Line::from(vec![Span::styled( + format!("{}{}{}", left, padding, right), + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + )])); + frame.render_widget(bar, area); + return; + } + // Normal status bar: build right side from nav indicators + link counter + hints let mut right_parts: Vec = Vec::new(); @@ -1173,11 +1795,17 @@ impl App { right_parts.push(format!("Last modified: {} | {}", meta.modified, meta.size)); } - // Keyboard hints - let hints = if self.is_login_shell { - "Tab:Links Enter:Go Bksp:Back Ctrl+C\u{00D7}2:Quit" + // Keyboard hints — contextual based on link mode + let hints = if self.link_mode { + if self.is_login_shell { + "\u{2191}\u{2193}:Links Enter:Go Tab/Esc:Exit Ctrl+C\u{00D7}2:Quit" + } else { + "\u{2191}\u{2193}:Links Enter:Go Tab/Esc:Exit q:Quit" + } + } else if self.is_login_shell { + "Tab:Links \u{2190}\u{2192}:Nav Ctrl+C\u{00D7}2:Quit" } else { - "Tab:Links Enter:Go Bksp:Back q:Quit" + "Tab:Links \u{2190}\u{2192}:Nav q:Quit" }; right_parts.push(hints.to_string()); @@ -1444,6 +2072,35 @@ fn build_breadcrumb(vault_relative: &str) -> String { .join(" > ") } +// ── Base64 encoder ──────────────────────────────────────────────────────────── + +/// Encode a string as base64 (standard alphabet, with padding). +/// Minimal implementation — no external crate needed. +fn base64_encode(input: &str) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = input.as_bytes(); + let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); + out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHABET[(triple & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + out +} + // ── show_goodbye ───────────────────────────────────────────────────────────── /// Display a BBS-style goodbye message after terminal has been restored. diff --git a/src/renderer.rs b/src/renderer.rs index 1015c18..abc73f7 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -6,7 +6,7 @@ //! //! # Public API //! -//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec)` +//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec, Vec)` 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` and `Vec`. +/// Used to support OSC 52 copy-to-clipboard in copy mode or on right-click. +pub struct CopyableBlock { + /// Index into `Vec` where this block begins. + pub start_line: usize, + /// Index into `Vec` 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, /// All fully resolved link records (line_index filled in at flush time). link_records: Vec, + /// All copyable block records discovered during rendering. + copyable_blocks: Vec, + /// Line index where the current text section began (None = not in a text section). + text_section_start: Option, + /// 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, + /// 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::>() + .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>, Vec) { + fn finish(mut self) -> (Vec>, Vec, Vec) { // 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::()) + .map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::()) .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>, Vec)`: +/// Returns a 3-tuple `(Vec>, Vec, Vec)`: /// - 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>, Vec) { +pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec, Vec) { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_STRIKETHROUGH); diff --git a/src/vault.rs b/src/vault.rs index 362efe4..2ad0c2d 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -211,7 +211,7 @@ pub enum RemoteDocument { /// - `https://sub.example.com:8080/bar` → `sub.example.com` fn extract_domain(url: &str) -> Option { // 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) => {