Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 26 additions & 37 deletions src/core/engine/ext_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,12 +628,15 @@ impl Engine {
item_index: usize,
markdown: &str,
) {
let rendered = crate::core::markdown::render_markdown(markdown);
let links = Self::extract_hover_links(&rendered);
let markdown = crate::core::markdown::linkify_bare_urls(markdown);
let (line_text, links, code_highlights) =
crate::core::markdown::hover_markdown_structure(&markdown);
// Dismiss any active editor hover to avoid overlapping popups.
self.dismiss_editor_hover();
self.panel_hover = Some(PanelHoverPopup {
rendered,
markdown,
line_text,
code_highlights,
links,
panel_name: panel_name.to_string(),
item_id: item_id.to_string(),
Expand Down Expand Up @@ -1135,46 +1138,30 @@ impl Engine {
take_focus: bool,
add_goto_links: bool,
) {
let mut rendered = crate::core::markdown::render_markdown(markdown);
let mut links = Self::extract_hover_links(&rendered);
let mut full_markdown = markdown.to_string();

// Append "Go to" navigation links after actual LSP content (vim mode only).
// Emitted as real `[label](url)` markdown — quadraui's renderer
// (adopted below, #821) parses these into clickable links itself,
// so no manual span bookkeeping is needed here.
if add_goto_links && !self.is_vscode_mode() {
let goto = self.lsp_goto_links();
if !goto.is_empty() {
use crate::core::markdown::{MdSpan, MdStyle};
// Separator line.
rendered.lines.push(String::new());
rendered.spans.push(Vec::new());
rendered.code_highlights.push(Vec::new());
// Build: "Go to Definition (:gd) | Type Definition (:gy) | ..."
// "Go to" is default fg; labels are link-colored and clickable.
let nav_line_idx = rendered.lines.len();
let mut nav_text = String::from("Go to ");
let mut nav_spans = Vec::new();
full_markdown.push_str("\n\nGo to ");
for (i, (label, keybind, url)) in goto.iter().enumerate() {
if i > 0 {
nav_text.push_str(" | ");
full_markdown.push_str(" | ");
}
let start = nav_text.len();
nav_text.push_str(label);
let end = nav_text.len();
nav_spans.push(MdSpan {
start_byte: start,
end_byte: end,
style: MdStyle::Link,
});
links.push((nav_line_idx, start, end, url.to_string()));
nav_text.push_str(&format!(" (:{})", keybind));
full_markdown.push_str(&format!("[{label}]({url}) (:{keybind})"));
}
rendered.lines.push(nav_text);
rendered.spans.push(nav_spans);
rendered.code_highlights.push(Vec::new());
}
}

let popup_width = rendered
.lines
let full_markdown = crate::core::markdown::linkify_bare_urls(&full_markdown);
let (line_text, links, code_highlights) =
crate::core::markdown::hover_markdown_structure(&full_markdown);

let popup_width = line_text
.iter()
.map(|l| l.chars().count())
.max()
Expand All @@ -1187,7 +1174,9 @@ impl Engine {
// Dismiss any active panel hover to avoid overlapping popups.
self.dismiss_panel_hover_now();
self.editor_hover = Some(EditorHoverPopup {
rendered,
markdown: full_markdown,
line_text,
code_highlights,
links,
anchor_line,
anchor_col,
Expand Down Expand Up @@ -1283,7 +1272,7 @@ impl Engine {
"j" | "Down" => {
// Scroll down — stop when last line is visible
if let Some(hover) = &mut self.editor_hover {
let max_scroll = hover.rendered.lines.len().saturating_sub(20);
let max_scroll = hover.line_text.len().saturating_sub(20);
if hover.scroll_top < max_scroll {
hover.scroll_top += 1;
}
Expand Down Expand Up @@ -1413,7 +1402,7 @@ impl Engine {
/// Returns true if the popup was scrolled.
pub fn editor_hover_scroll(&mut self, delta: i32) -> bool {
if let Some(hover) = &mut self.editor_hover {
let max_scroll = hover.rendered.lines.len().saturating_sub(20);
let max_scroll = hover.line_text.len().saturating_sub(20);
if delta > 0 {
let new = (hover.scroll_top + delta as usize).min(max_scroll);
if new != hover.scroll_top {
Expand All @@ -1437,7 +1426,7 @@ impl Engine {
/// from `quadraui::dispatch_mouse_drag` into this call (#215).
pub fn editor_hover_set_scroll(&mut self, new_offset: usize) -> bool {
if let Some(hover) = &mut self.editor_hover {
let max_scroll = hover.rendered.lines.len().saturating_sub(20);
let max_scroll = hover.line_text.len().saturating_sub(20);
let clamped = new_offset.min(max_scroll);
if clamped != hover.scroll_top {
hover.scroll_top = clamped;
Expand All @@ -1459,9 +1448,9 @@ impl Engine {
pub fn hover_selection_text(&self) -> Option<String> {
let hover = self.editor_hover.as_ref()?;
let text = if let Some(ref sel) = hover.selection {
sel.extract_text(&hover.rendered.lines)
sel.extract_text(&hover.line_text)
} else {
hover.rendered.lines.join("\n")
hover.line_text.join("\n")
};
if text.is_empty() {
None
Expand Down
35 changes: 30 additions & 5 deletions src/core/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1452,9 +1452,19 @@ pub struct ContextMenuItem {
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PanelHoverPopup {
/// Rendered markdown lines + spans (reuses the existing markdown module).
pub rendered: crate::core::markdown::MdRendered,
/// Clickable link URLs extracted from LinkUrl spans: (line_idx, start_byte, end_byte, url).
/// Raw markdown source (already passed through
/// `core::markdown::linkify_bare_urls`). Styled at paint time via
/// `quadraui::compose::markdown::render_markdown_to_styled` with the
/// active theme (#821) — see `EditorHoverPopup::markdown`'s doc for the
/// full rationale.
pub markdown: String,
/// Plain per-line text (markdown syntax stripped) — used for the
/// popup's empty-content check and content-width sizing.
pub line_text: Vec<String>,
/// Per-line tree-sitter highlights for fenced code-block lines. See
/// `EditorHoverPopup::code_highlights`.
pub code_highlights: Vec<Vec<crate::core::markdown::MdCodeHighlight>>,
/// Clickable link URLs: (line_idx, start_byte, end_byte, url).
pub links: Vec<(usize, usize, usize, String)>,
/// Panel name this hover belongs to.
pub panel_name: String,
Expand Down Expand Up @@ -1492,8 +1502,23 @@ pub enum EditorHoverSource {
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditorHoverPopup {
/// Rendered markdown content.
pub rendered: crate::core::markdown::MdRendered,
/// Raw markdown source (already passed through
/// `core::markdown::linkify_bare_urls`, and with any "Go to" nav links
/// appended). Styled at paint time via
/// `quadraui::compose::markdown::render_markdown_to_styled` with the
/// active theme — see `render.rs`'s `markdown_hover_to_quadraui_lines`
/// (#821).
pub markdown: String,
/// Plain per-line text (markdown syntax stripped) — used for scroll
/// bounds, clipboard copy, and selection extraction. Computed once, at
/// show time, via `core::markdown::hover_markdown_structure` (theme-
/// independent, so it doesn't drift from what's painted regardless of
/// theme changes while the popup is open).
pub line_text: Vec<String>,
/// Per-line tree-sitter highlights for fenced code-block lines (empty
/// for non-code-block lines). Byte offsets are relative to the code
/// line's own raw text — see `hover_markdown_structure`'s doc.
pub code_highlights: Vec<Vec<crate::core::markdown::MdCodeHighlight>>,
/// Clickable link regions: (line_idx, start_byte, end_byte, url).
pub links: Vec<(usize, usize, usize, String)>,
/// Buffer line where the hover is anchored (0-indexed).
Expand Down
71 changes: 4 additions & 67 deletions src/core/engine/panels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1547,73 +1547,10 @@ impl Engine {
result
}

/// Extract clickable links from rendered markdown.
///
/// Two sources of click regions are handled:
///
/// 1. **Markdown links** — each `Link` span (the label text) is paired with
/// the following `LinkUrl` span on the same line. The click region covers
/// the label; the URL drives dispatch. Command URIs displayed as
/// `:Name?args` are restored to `command:Name?args`.
///
/// 2. **Bare URLs** — standalone `LinkUrl` spans (emitted by `render_markdown`
/// for plain `http://` / `https://` text) become their own click regions.
/// The span text is the URL itself, so no reconstruction is needed beyond
/// the same `:` → `command:` prefix check used for markdown link URLs.
pub(crate) fn extract_hover_links(
rendered: &crate::core::markdown::MdRendered,
) -> Vec<(usize, usize, usize, String)> {
use crate::core::markdown::MdStyle;
let mut links = Vec::new();
for (line_idx, line_spans) in rendered.spans.iter().enumerate() {
let Some(line) = rendered.lines.get(line_idx) else {
continue;
};
// Walk every span on this line.
let mut span_iter = line_spans.iter().peekable();
while let Some(span) = span_iter.next() {
if span.style == MdStyle::Link {
// Paired markdown link: look for the following LinkUrl span.
let url = span_iter
.peek()
.filter(|next| next.style == MdStyle::LinkUrl)
.and_then(|next| {
if next.end_byte <= line.len() {
Some(&line[next.start_byte..next.end_byte])
} else {
None
}
});
if let Some(url_text) = url {
// Command URIs display as ":Name?args" — restore prefix.
let url = if url_text.starts_with(':') {
format!("command{}", url_text)
} else {
url_text.to_string()
};
if is_safe_url(&url) {
// Click region = the Link label span.
links.push((line_idx, span.start_byte, span.end_byte, url));
}
}
} else if span.style == MdStyle::LinkUrl && span.end_byte <= line.len() {
// Standalone LinkUrl span (bare URL or the URL display of a
// markdown link). Make the span itself clickable.
let url_text = &line[span.start_byte..span.end_byte];
// Restore command: prefix if displayed as ":Name?args".
let url = if url_text.starts_with(':') {
format!("command{}", url_text)
} else {
url_text.to_string()
};
if is_safe_url(&url) {
links.push((line_idx, span.start_byte, span.end_byte, url));
}
}
}
}
links
}
// Link extraction from hover markdown moved to
// `core::markdown::hover_markdown_structure` (#821 — hover popups adopt
// quadraui's `render_markdown_to_styled`, which resolves link ranges
// itself instead of vimcode re-pairing `Link`/`LinkUrl` spans).

/// Execute an LSP navigation command from a hover popup link.
/// Moves the cursor to the given position before invoking the LSP request.
Expand Down
56 changes: 54 additions & 2 deletions src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14082,9 +14082,37 @@ fn test_plugin_disabled_not_registered() {

// ─── Source Control (Session 99) ─────────────────────────────────────────

/// An existing, empty, **non-git** directory for [`make_sc_engine_with_files`]
/// to use as `Engine::cwd` — see that fixture's doc comment for what running
/// the SC tests against a real checkout did instead.
///
/// Per-process (pid-suffixed) rather than a shared constant so two concurrent
/// `cargo test` runs (a second worktree, CI matrix job, …) cannot collide, and
/// deliberately *not* `git init`-ed: every `git -C` call made from it must
/// fail, which is exactly what the tests below assert.
fn sc_scratch_cwd() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vimcode-sc-tests-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
dir
}

/// Build an engine with synthetic SC file statuses for testing.
///
/// `cwd` is re-pointed at an empty scratch directory that is deliberately
/// **not** a git repository, because the `sc_*` actions the tests below drive
/// (`sc_do_commit`, `sc_stage_all`, `sc_unstage_all`, …) shell out to real
/// `git -C <engine.cwd>` commands. Several of those tests document their
/// expectations as "will fail silently since we're not in a real git repo" —
/// which was only true when the suite ran somewhere unversioned. Run from a
/// checkout (i.e. always), `Engine::new()`'s `cwd` *is* the vimcode repo, so
/// `test_sc_unified_dispatch_stage_all` ran `git add -A` over the developer's
/// working tree and `test_sc_commit_ctrl_enter_commits` then committed it
/// under the message `"test\nmultiline"` — silently swallowing every
/// uncommitted change in the checkout the suite was run from. Observed for
/// real while fixing #821.
fn make_sc_engine_with_files() -> Engine {
let mut engine = Engine::new();
engine.cwd = sc_scratch_cwd();
engine.sc_file_statuses = vec![
git::FileStatus {
path: "a.rs".to_string(),
Expand All @@ -14101,6 +14129,30 @@ fn make_sc_engine_with_files() -> Engine {
engine
}

/// Guard for the hazard documented on [`make_sc_engine_with_files`]: the SC
/// fixture's `cwd` must never be inside a git work tree, or the `sc_*` tests
/// below stage and commit the checkout the suite is being run from.
///
/// Fails (as intended) against the pre-fix fixture, whose `cwd` was
/// `Engine::new()`'s — the repo root.
#[test]
fn sc_fixture_cwd_is_never_inside_a_git_work_tree() {
let engine = make_sc_engine_with_files();
let out = std::process::Command::new("git")
.arg("-C")
.arg(&engine.cwd)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.expect("git must be runnable to check the fixture's cwd");
assert!(
!out.status.success(),
"the SC fixture's cwd ({}) is inside a git work tree — the sc_* tests \
run real `git add -A` / `git commit` against it and would swallow \
every uncommitted change in that checkout",
engine.cwd.display()
);
}

#[test]
fn test_sc_commit_input_mode_toggle() {
let mut engine = make_sc_engine_with_files();
Expand Down Expand Up @@ -16031,7 +16083,7 @@ fn test_panel_hover_show_and_dismiss() {
assert_eq!(ph.panel_name, "test_panel");
assert_eq!(ph.item_id, "item_1");
assert_eq!(ph.item_index, 0);
assert!(!ph.rendered.lines.is_empty());
assert!(!ph.line_text.is_empty());
e.dismiss_panel_hover_now();
assert!(e.panel_hover.is_none());
}
Expand All @@ -16043,7 +16095,7 @@ fn test_panel_hover_links_extracted() {
let ph = e.panel_hover.as_ref().unwrap();
// Should have at least one link extracted from the markdown
assert!(
!ph.links.is_empty() || !ph.rendered.lines.is_empty(),
!ph.links.is_empty() || !ph.line_text.is_empty(),
"hover should have rendered content"
);
}
Expand Down
Loading
Loading