Open relative file links at line numbers (#61006)
## Summary - Open relative file links with GitHub-style line fragments at the referenced line - Support line navigation from agent responses and Markdown previews - Preserve decoded-first path resolution with literal percent-escape fallback ## Testing - cargo test -q -p util test_source_line_from_fragment -- --nocapture - cargo test -q -p acp_thread test_hyperlink_percent_escapes_are_decoded -- --nocapture - cargo test -q -p agent_ui test_open_link -- --nocapture - cargo test -q -p markdown_preview -- --nocapture - cargo fmt --all -- --check Release Notes: - Improved relative file links with line numbers to open at the referenced line.
This commit is contained in:
1 parent
50b773f78e
commit
298b3e8d7b
5 files changed
+244
-89
No files matched your search
@@ -709,7 +709,7 @@ fn normalize_path_mention(
|
||||
/// traverses. Invalid sequences and non-UTF-8 results leave the input
|
||||
/// unchanged. Returns `Cow::Owned` iff decoding changed the input
|
||||
/// (`parse_hyperlink_literal` relies on this).
|
||||
fn decode_path_escapes(input: &str) -> Cow<'_, str> {
|
||||
pub fn decode_path_escapes(input: &str) -> Cow<'_, str> {
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::cell::RefCell;
|
||||
|
||||
use acp_thread::{
|
||||
Elicitation, ElicitationEntryId, ElicitationStatus, PlanEntry, SandboxAuthorizationDetails,
|
||||
SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason,
|
||||
SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason, decode_path_escapes,
|
||||
};
|
||||
use agent::{
|
||||
SandboxStatusKey, SandboxStatusRefresh, SkillLoadingIssue, SkillLoadingIssueKind,
|
||||
@@ -45,6 +45,7 @@ use ui::{
|
||||
ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton,
|
||||
SplitButtonStyle, Tab, ToggleState,
|
||||
};
|
||||
use util::markdown::{source_position_from_fragment, split_local_url_fragment};
|
||||
use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME};
|
||||
|
||||
use super::elicitation::{
|
||||
@@ -12287,6 +12288,33 @@ pub(crate) fn open_link(
|
||||
};
|
||||
|
||||
let path_style = workspace.read(cx).path_style(cx);
|
||||
let (relative_path, fragment) = split_local_url_fragment(&url);
|
||||
if let Some(fragment) = fragment
|
||||
&& !relative_path.is_empty()
|
||||
&& !path_style.is_absolute(relative_path)
|
||||
{
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let decoded_path = decode_path_escapes(relative_path);
|
||||
let abs_path = project.update(cx, |project, cx| {
|
||||
let resolve_path = |path: &str| {
|
||||
let project_path = project.find_project_path(path, cx)?;
|
||||
project.entry_for_path(&project_path, cx)?;
|
||||
project.absolute_path(&project_path, cx)
|
||||
};
|
||||
resolve_path(&decoded_path).or_else(|| resolve_path(relative_path))
|
||||
});
|
||||
if let Some(abs_path) = abs_path {
|
||||
let point = fragment
|
||||
.strip_prefix('L')
|
||||
.and_then(source_position_from_fragment)
|
||||
.map(|(row, _)| Point::new(row, 0));
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
open_abs_path_at_point(workspace, abs_path, point, window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mention) = MentionUri::parse_hyperlink(&url, path_style).log_err() {
|
||||
// Percent escapes in bare paths are ambiguous: prefer the decoded
|
||||
// interpretation, falling back to the literal one (e.g. a file
|
||||
@@ -12503,8 +12531,11 @@ mod tests {
|
||||
crate::test_support::init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}}))
|
||||
.await;
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({"src": {"main.rs": "first\nsecond\nthird\n"}}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
let (multi_workspace, cx) =
|
||||
@@ -12525,6 +12556,21 @@ mod tests {
|
||||
assert!(*active.path == *"src/main.rs");
|
||||
});
|
||||
|
||||
multi_workspace.update_in(cx, |_, window, cx| {
|
||||
open_link("src/main.rs#L2".into(), &workspace_weak, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let editor = workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.downcast::<Editor>())
|
||||
.expect("file should be open in an editor")
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
|
||||
});
|
||||
|
||||
// Absolute path
|
||||
let abs_path: SharedString = path!("/project/src/main.rs").to_string().into();
|
||||
multi_workspace.update_in(cx, |_, window, cx| {
|
||||
@@ -12548,10 +12594,10 @@ mod tests {
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"a%20b.rs": "literal",
|
||||
"a b.rs": "decoded",
|
||||
"c d.rs": "",
|
||||
"e%20f.rs": "",
|
||||
"a%20b.rs": "literal\nsecond\n",
|
||||
"a b.rs": "decoded\nsecond\n",
|
||||
"c d.rs": "first\nsecond\n",
|
||||
"e%20f.rs": "first\nsecond\n",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -12587,6 +12633,25 @@ mod tests {
|
||||
// Only the literally-named file exists: fall back to it.
|
||||
let path = open_link_and_active_path(path!("/project/e%20f.rs").to_string(), cx);
|
||||
assert_eq!(*path, *"e%20f.rs");
|
||||
|
||||
let path = open_link_and_active_path("a%20b.rs#L2".to_string(), cx);
|
||||
assert_eq!(*path, *"a b.rs");
|
||||
|
||||
let path = open_link_and_active_path("c%20d.rs#L2".to_string(), cx);
|
||||
assert_eq!(*path, *"c d.rs");
|
||||
|
||||
let path = open_link_and_active_path("e%20f.rs#L2".to_string(), cx);
|
||||
assert_eq!(*path, *"e%20f.rs");
|
||||
let editor = workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.downcast::<Editor>())
|
||||
.expect("file should be open in an editor")
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -17,7 +17,9 @@ use settings::Settings;
|
||||
use std::{ops::Range, str::FromStr as _, sync::LazyLock};
|
||||
use text::OffsetRangeExt;
|
||||
use theme::ActiveTheme as _;
|
||||
use util::{ResultExt, TryFutureExt as _, paths::PathWithPosition};
|
||||
use util::{
|
||||
ResultExt, TryFutureExt as _, markdown::source_position_from_fragment, paths::PathWithPosition,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HoveredLinkState {
|
||||
@@ -85,7 +87,8 @@ pub fn document_link_target_to_hover_link(target: &str, server_id: LanguageServe
|
||||
{
|
||||
let position = url
|
||||
.fragment()
|
||||
.and_then(parse_uri_fragment_position)
|
||||
.and_then(source_position_from_fragment)
|
||||
.map(|(line, character)| lsp::Position { line, character })
|
||||
.unwrap_or_default();
|
||||
return HoverLink::LspLocation(
|
||||
lsp::Location {
|
||||
@@ -98,24 +101,6 @@ pub fn document_link_target_to_hover_link(target: &str, server_id: LanguageServe
|
||||
HoverLink::Url(target.to_string())
|
||||
}
|
||||
|
||||
/// Parse a URI fragment such as `9,16`, `9:16`, `L9`, or `L9:16` into an
|
||||
/// LSP position (1-based input, 0-based output). Servers like the JSON
|
||||
/// language server attach this fragment to `file://` document link
|
||||
/// targets to point at a specific row/column inside the file.
|
||||
fn parse_uri_fragment_position(fragment: &str) -> Option<lsp::Position> {
|
||||
let stripped = fragment.strip_prefix('L').unwrap_or(fragment);
|
||||
let (line_str, column_str) = match stripped.split_once([',', ':']) {
|
||||
Some((line, column)) => (line, Some(column)),
|
||||
None => (stripped, None),
|
||||
};
|
||||
let line = line_str.parse::<u32>().ok()?.checked_sub(1)?;
|
||||
let character = column_str
|
||||
.and_then(|column| column.parse::<u32>().ok())
|
||||
.and_then(|column| column.checked_sub(1))
|
||||
.unwrap_or(0);
|
||||
Some(lsp::Position { line, character })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InlayHighlight {
|
||||
pub inlay: InlayId,
|
||||
@@ -1090,55 +1075,6 @@ mod tests {
|
||||
use util::{assert_set_eq, path};
|
||||
use workspace::item::Item;
|
||||
|
||||
#[test]
|
||||
fn test_parse_uri_fragment_position() {
|
||||
// json-language-server style: 1-based `line,column`.
|
||||
assert_eq!(
|
||||
parse_uri_fragment_position("9,16"),
|
||||
Some(lsp::Position {
|
||||
line: 8,
|
||||
character: 15,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_uri_fragment_position("33,33"),
|
||||
Some(lsp::Position {
|
||||
line: 32,
|
||||
character: 32,
|
||||
})
|
||||
);
|
||||
|
||||
// GitHub-style `L<line>` and `L<line>:<col>`.
|
||||
assert_eq!(
|
||||
parse_uri_fragment_position("L42"),
|
||||
Some(lsp::Position {
|
||||
line: 41,
|
||||
character: 0,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_uri_fragment_position("L42:7"),
|
||||
Some(lsp::Position {
|
||||
line: 41,
|
||||
character: 6,
|
||||
})
|
||||
);
|
||||
|
||||
// Bare line number, no column.
|
||||
assert_eq!(
|
||||
parse_uri_fragment_position("5"),
|
||||
Some(lsp::Position {
|
||||
line: 4,
|
||||
character: 0,
|
||||
})
|
||||
);
|
||||
|
||||
// Garbage / unparseable / 0-based fragments are rejected.
|
||||
assert_eq!(parse_uri_fragment_position(""), None);
|
||||
assert_eq!(parse_uri_fragment_position("section-name"), None);
|
||||
assert_eq!(parse_uri_fragment_position("0,0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_link_target_to_hover_link_file_uri_with_fragment() {
|
||||
let server_id = LanguageServerId(0);
|
||||
|
||||
@@ -14,7 +14,7 @@ use gpui::{
|
||||
InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache,
|
||||
ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px,
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use language::{LanguageRegistry, Point};
|
||||
use markdown::{
|
||||
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont,
|
||||
MarkdownOptions, MarkdownStyle,
|
||||
@@ -26,8 +26,10 @@ use theme::{SystemAppearance, Theme, ThemeRegistry};
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::utils::WithRemSize;
|
||||
use ui::{ContextMenu, LinkPreview, WithScrollbar, prelude::*, right_click_menu};
|
||||
use util::ResultExt;
|
||||
use util::markdown::split_local_url_fragment;
|
||||
use util::{
|
||||
ResultExt,
|
||||
markdown::{source_position_from_fragment, split_local_url_fragment},
|
||||
};
|
||||
use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions, SerializableItem};
|
||||
use workspace::notifications::NotifyResultExt;
|
||||
use workspace::searchable::{
|
||||
@@ -1067,6 +1069,7 @@ fn handle_url_click(
|
||||
} else {
|
||||
open_preview_url(
|
||||
SharedString::from(path_part.to_string()),
|
||||
fragment.map(|fragment| SharedString::from(fragment.to_string())),
|
||||
base_directory,
|
||||
workspace,
|
||||
window,
|
||||
@@ -1077,15 +1080,30 @@ fn handle_url_click(
|
||||
|
||||
fn open_preview_url(
|
||||
url: SharedString,
|
||||
fragment: Option<SharedString>,
|
||||
base_directory: Option<PathBuf>,
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let (path_text, _) = split_preview_url(url.as_ref());
|
||||
let decoded_path = urlencoding::decode(&url).unwrap_or_else(|_| Cow::Borrowed(&url));
|
||||
|
||||
// URL-decode the path for proper handling of encoded characters
|
||||
let decoded_path = urlencoding::decode(path_text).unwrap_or_else(|_| Cow::Borrowed(path_text));
|
||||
if let Some(row) = fragment
|
||||
.as_deref()
|
||||
.and_then(|fragment| fragment.strip_prefix('L'))
|
||||
.and_then(source_position_from_fragment)
|
||||
.map(|(row, _)| row)
|
||||
&& open_preview_path_at_line(
|
||||
decoded_path.to_string(),
|
||||
base_directory.clone(),
|
||||
workspace,
|
||||
row,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -1096,11 +1114,75 @@ fn open_preview_url(
|
||||
}
|
||||
}
|
||||
|
||||
fn split_preview_url(url: &str) -> (&str, Option<&str>) {
|
||||
match url.split_once('#') {
|
||||
Some((path, fragment)) => (path, Some(fragment)),
|
||||
None => (url, None),
|
||||
}
|
||||
enum PreviewPathTarget {
|
||||
Project(ProjectPath),
|
||||
Absolute(PathBuf),
|
||||
}
|
||||
|
||||
fn open_preview_path_at_line(
|
||||
path: String,
|
||||
base_directory: Option<PathBuf>,
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
row: u32,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let path_style = project.read(cx).path_style(cx);
|
||||
let target = if path_style.is_absolute(&path) {
|
||||
Some(PreviewPathTarget::Absolute(PathBuf::from(path)))
|
||||
} else if project.read(cx).is_local()
|
||||
&& let Some(resolved) = base_directory
|
||||
.as_deref()
|
||||
.map(|base_directory| base_directory.join(&path))
|
||||
&& resolved.exists()
|
||||
{
|
||||
Some(PreviewPathTarget::Absolute(resolved))
|
||||
} else {
|
||||
project
|
||||
.update(cx, |project, cx| project.find_project_path(&path, cx))
|
||||
.map(PreviewPathTarget::Project)
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let task = workspace.update(cx, |workspace, cx| match target {
|
||||
PreviewPathTarget::Project(project_path) => {
|
||||
workspace.open_path(project_path, None, true, window, cx)
|
||||
}
|
||||
PreviewPathTarget::Absolute(abs_path) => workspace.open_abs_path(
|
||||
abs_path,
|
||||
workspace::OpenOptions {
|
||||
focus: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
});
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let item = task.await?;
|
||||
let Some(editor) = item.downcast::<Editor>() else {
|
||||
return anyhow::Ok(());
|
||||
};
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let point = Point::new(row, 0);
|
||||
editor.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::center()),
|
||||
window,
|
||||
cx,
|
||||
|selections| selections.select_ranges([point..point]),
|
||||
);
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
true
|
||||
}
|
||||
|
||||
fn resolve_preview_image(
|
||||
@@ -1721,6 +1803,8 @@ mod tests {
|
||||
use buffer_diff::BufferDiff;
|
||||
use editor::Editor;
|
||||
use gpui::{AppContext as _, Entity, Focusable as _, TestAppContext, WindowHandle};
|
||||
use language::Point;
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -1733,7 +1817,7 @@ mod tests {
|
||||
AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths,
|
||||
};
|
||||
|
||||
use super::MarkdownPreviewView;
|
||||
use super::{MarkdownPreviewView, open_preview_url};
|
||||
|
||||
#[test]
|
||||
fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() {
|
||||
@@ -1762,6 +1846,47 @@ mod tests {
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn opens_preview_file_link_at_line(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({"src": {"main.rs": "first\nsecond\nthird\n"}}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let workspace =
|
||||
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
||||
let workspace_weak = workspace.downgrade();
|
||||
|
||||
multi_workspace.update_in(cx, |_, window, cx| {
|
||||
open_preview_url(
|
||||
"src/main.rs".into(),
|
||||
Some("L2".into()),
|
||||
None,
|
||||
&workspace_weak,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let editor = workspace.read_with(cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.downcast::<Editor>())
|
||||
.expect("file should be open in an editor")
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
assert_eq!(editor.selections.newest::<Point>(&snapshot).head().row, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn toggles_task_checkbox_and_saves_when_preview_is_active(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
|
||||
@@ -57,6 +57,23 @@ pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_position_from_fragment(fragment: &str) -> Option<(u32, u32)> {
|
||||
let fragment = fragment.strip_prefix('L').unwrap_or(fragment);
|
||||
let (line, column) = match fragment.split_once([',', ':']) {
|
||||
Some((line, column)) => (line, Some(column)),
|
||||
None => (
|
||||
fragment.split_once('-').map_or(fragment, |(line, _)| line),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let line = line.parse::<u32>().ok()?.checked_sub(1)?;
|
||||
let column = column
|
||||
.and_then(|column| column.parse::<u32>().ok())
|
||||
.and_then(|column| column.checked_sub(1))
|
||||
.unwrap_or(0);
|
||||
Some((line, column))
|
||||
}
|
||||
|
||||
/// Indicates that the wrapped `String` is markdown text.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownString(pub String);
|
||||
@@ -350,6 +367,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_position_from_fragment() {
|
||||
assert_eq!(source_position_from_fragment("9,16"), Some((8, 15)));
|
||||
assert_eq!(source_position_from_fragment("33,33"), Some((32, 32)));
|
||||
assert_eq!(source_position_from_fragment("L42"), Some((41, 0)));
|
||||
assert_eq!(source_position_from_fragment("L42:7"), Some((41, 6)));
|
||||
assert_eq!(source_position_from_fragment("5"), Some((4, 0)));
|
||||
assert_eq!(source_position_from_fragment("L10-L20"), Some((9, 0)));
|
||||
assert_eq!(source_position_from_fragment("heading"), None);
|
||||
assert_eq!(source_position_from_fragment("0,0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_heading_slug() {
|
||||
assert_eq!(generate_heading_slug("Hello World"), "hello-world");
|
||||
|
||||
Reference in new issue
Block a user