mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Support LSP document links (#56011)
Closes https://github.com/zed-industries/zed/issues/33587 https://github.com/user-attachments/assets/bbaea8a9-402e-485b-800e-2f4486142956 Release Notes: - Supported LSP document links (enabled by default, use `"lsp_document_links": false` to turn it off)
This commit is contained in:
parent
a5457029cc
commit
3e77442f2e
24 changed files with 2427 additions and 127 deletions
|
|
@ -1500,6 +1500,10 @@
|
|||
// 4. Draw a background behind the color text..
|
||||
// "lsp_document_colors": "background",
|
||||
"lsp_document_colors": "inlay",
|
||||
// Whether to query and display LSP `textDocument/documentLink` links in the editor.
|
||||
//
|
||||
// Default: true
|
||||
"lsp_document_links": true,
|
||||
// Diagnostics configuration.
|
||||
"diagnostics": {
|
||||
// Whether to show the project diagnostics button in the status bar.
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ impl Server {
|
|||
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferById>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::SynchronizeBuffers>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::ResolveDocumentLink>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenImageByPath>)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ use std::{
|
|||
num::NonZeroU32,
|
||||
ops::{Deref as _, Range},
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr as _,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{self, AtomicBool, AtomicUsize},
|
||||
|
|
@ -2709,6 +2710,317 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_lsp_document_links(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let executor = cx_a.executor();
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
|
||||
.await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
|
||||
cx_a.update(editor::init);
|
||||
cx_b.update(editor::init);
|
||||
|
||||
for cx in [&mut *cx_a, &mut *cx_b] {
|
||||
cx.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.editor.lsp_document_links = Some(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let capabilities = lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(true),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
};
|
||||
client_a.language_registry().add(rust_lang());
|
||||
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
capabilities: capabilities.clone(),
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
client_b.language_registry().add(rust_lang());
|
||||
client_b.language_registry().register_fake_lsp_adapter(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
capabilities,
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
let other_contents = concat!(
|
||||
"fn first() {}\n",
|
||||
"fn second() {}\n",
|
||||
"fn third(x: i32) {}\n",
|
||||
"fn fourth() {}\n",
|
||||
"fn fifth() {}\n",
|
||||
);
|
||||
client_a
|
||||
.fs()
|
||||
.insert_tree(
|
||||
path!("/a"),
|
||||
json!({
|
||||
"main.rs": "// see LICENSE for details\nfn main() {}",
|
||||
"other.rs": other_contents,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
active_call_b
|
||||
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
|
||||
let _editor_a = workspace_a
|
||||
.update_in(cx_a, |workspace, window, cx| {
|
||||
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
let fake_language_server = fake_language_servers.next().await.unwrap();
|
||||
|
||||
let link_range = lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 0,
|
||||
character: 7,
|
||||
},
|
||||
end: lsp::Position {
|
||||
line: 0,
|
||||
character: 14,
|
||||
},
|
||||
};
|
||||
let other_uri = lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap();
|
||||
// The server points at line 3, column 5 (1-based) of `other.rs` using the
|
||||
// json-language-server fragment convention.
|
||||
let other_uri_with_fragment =
|
||||
lsp::Uri::from_str(&format!("{}#3,5", other_uri.as_str())).unwrap();
|
||||
let other_target = other_uri_with_fragment.to_string();
|
||||
let tooltip = "Open other.rs";
|
||||
let resolve_marker = serde_json::json!({"id": 42});
|
||||
|
||||
let document_link_requests = Arc::new(AtomicUsize::new(0));
|
||||
let document_link_count = Arc::clone(&document_link_requests);
|
||||
let resolve_marker_for_links = resolve_marker.clone();
|
||||
let mut document_link_handle = fake_language_server
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |params, _| {
|
||||
let document_link_count = Arc::clone(&document_link_count);
|
||||
let resolve_marker = resolve_marker_for_links.clone();
|
||||
async move {
|
||||
assert_eq!(
|
||||
params.text_document.uri,
|
||||
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
|
||||
);
|
||||
document_link_count.fetch_add(1, atomic::Ordering::Release);
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: None,
|
||||
tooltip: None,
|
||||
data: Some(resolve_marker),
|
||||
}]))
|
||||
}
|
||||
});
|
||||
|
||||
let resolve_requests = Arc::new(AtomicUsize::new(0));
|
||||
let resolve_count = Arc::clone(&resolve_requests);
|
||||
let other_uri_for_resolve = other_uri_with_fragment.clone();
|
||||
let resolve_marker_for_resolve = resolve_marker.clone();
|
||||
let _resolve_handle = fake_language_server
|
||||
.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(move |link, _| {
|
||||
let resolve_count = Arc::clone(&resolve_count);
|
||||
let other_uri = other_uri_for_resolve.clone();
|
||||
let expected_marker = resolve_marker_for_resolve.clone();
|
||||
async move {
|
||||
assert_eq!(link.range, link_range);
|
||||
assert_eq!(link.data.as_ref(), Some(&expected_marker));
|
||||
resolve_count.fetch_add(1, atomic::Ordering::Release);
|
||||
Ok(lsp::DocumentLink {
|
||||
range: link.range,
|
||||
target: Some(other_uri),
|
||||
tooltip: Some(tooltip.to_string()),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
document_link_handle.next().await.unwrap();
|
||||
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
1,
|
||||
document_link_requests.load(atomic::Ordering::Acquire),
|
||||
"Host opening the file should issue exactly one documentLink request"
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
resolve_requests.load(atomic::Ordering::Acquire),
|
||||
"No resolve happens until a hover triggers it"
|
||||
);
|
||||
|
||||
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
let editor_b = workspace_b
|
||||
.update_in(cx_b, |workspace, window, cx| {
|
||||
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
1,
|
||||
document_link_requests.load(atomic::Ordering::Acquire),
|
||||
"Guest's proto fetch should be served from the host's cached document links \
|
||||
without issuing a fresh documentLink LSP request"
|
||||
);
|
||||
|
||||
let guest_buffer = editor_b
|
||||
.read_with(cx_b, |editor, cx| editor.buffer().read(cx).as_singleton())
|
||||
.unwrap();
|
||||
let buffer_id = guest_buffer.read_with(cx_b, |buffer, _| buffer.remote_id());
|
||||
let unresolved = project_b
|
||||
.read_with(cx_b, |project, cx| {
|
||||
project
|
||||
.lsp_store()
|
||||
.read(cx)
|
||||
.document_links_for_buffer(buffer_id)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.into_values()
|
||||
.flat_map(|per_server| per_server.into_values())
|
||||
.next()
|
||||
.expect("guest should mirror the fetched document link");
|
||||
assert!(
|
||||
!unresolved.resolved,
|
||||
"freshly fetched links must come back unresolved"
|
||||
);
|
||||
|
||||
let resolve_task = editor_b
|
||||
.update(cx_b, |editor, cx| {
|
||||
editor.document_links_at(guest_buffer.clone(), unresolved.range.start, cx)
|
||||
})
|
||||
.expect("editor should have a cached link covering the position");
|
||||
let resolved_links = resolve_task.await;
|
||||
assert_eq!(
|
||||
1,
|
||||
resolved_links.len(),
|
||||
"`document_links_at` should yield the single matching link"
|
||||
);
|
||||
executor.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
1,
|
||||
resolve_requests.load(atomic::Ordering::Acquire),
|
||||
"Guest's resolve should reach the host's LSP exactly once"
|
||||
);
|
||||
|
||||
let guest_links = project_b.read_with(cx_b, |project, cx| {
|
||||
project
|
||||
.lsp_store()
|
||||
.read(cx)
|
||||
.document_links_for_buffer(buffer_id)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
assert_eq!(
|
||||
1,
|
||||
guest_links.values().map(|m| m.len()).sum::<usize>(),
|
||||
"Guest should mirror exactly one document link from the host"
|
||||
);
|
||||
let link = guest_links
|
||||
.values()
|
||||
.flat_map(|per_server| per_server.values())
|
||||
.next()
|
||||
.expect("guest cache should contain the mirrored link");
|
||||
assert_eq!(
|
||||
link.target.as_deref(),
|
||||
Some(other_target.as_str()),
|
||||
"Guest should see the resolved file:// target from the host"
|
||||
);
|
||||
assert_eq!(link.tooltip.as_deref(), Some(tooltip));
|
||||
|
||||
let click_anchor = guest_buffer.read_with(cx_b, |buffer, _| buffer.anchor_before(10));
|
||||
let resolved_at_click = editor_b
|
||||
.update(cx_b, |editor, cx| {
|
||||
editor.document_links_at(guest_buffer.clone(), click_anchor, cx)
|
||||
})
|
||||
.expect("cached document link should cover the click anchor")
|
||||
.await;
|
||||
let (click_server_id, click_link) = resolved_at_click
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("resolved links should not be empty");
|
||||
let click_target = click_link
|
||||
.target
|
||||
.as_deref()
|
||||
.expect("link should be resolved")
|
||||
.to_owned();
|
||||
let navigated = editor_b
|
||||
.update_in(cx_b, |editor, window, cx| {
|
||||
let hover_link = editor::hover_links::document_link_target_to_hover_link(
|
||||
&click_target,
|
||||
click_server_id,
|
||||
);
|
||||
editor.navigate_to_hover_links(None, vec![hover_link], None, false, window, cx)
|
||||
})
|
||||
.await
|
||||
.expect("navigation task should complete");
|
||||
assert_eq!(
|
||||
navigated,
|
||||
editor::Navigated::Yes,
|
||||
"Clicking a resolved file:// document link should navigate",
|
||||
);
|
||||
executor.run_until_parked();
|
||||
|
||||
let other_editor = workspace_b.update(cx_b, |workspace, cx| {
|
||||
workspace.active_item_as::<Editor>(cx).unwrap()
|
||||
});
|
||||
other_editor.update(cx_b, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
|
||||
assert_eq!(
|
||||
buffer.read(cx).text(),
|
||||
other_contents,
|
||||
"Following the resolved link should open other.rs from the same worktree",
|
||||
);
|
||||
let head = editor
|
||||
.selections
|
||||
.newest::<Point>(&editor.display_snapshot(cx))
|
||||
.head();
|
||||
assert_eq!(
|
||||
head,
|
||||
Point::new(2, 4),
|
||||
"Cursor should land at the URI fragment's line/column (1-based 3,5 -> 0-based 2,4)",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async fn test_lsp_pull_diagnostics(
|
||||
should_stream_workspace_diagnostic: bool,
|
||||
cx_a: &mut TestAppContext,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use collections::{HashMap, HashSet};
|
|||
|
||||
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
|
||||
use debugger_ui::debugger_panel::DebugPanel;
|
||||
use editor::{Editor, EditorMode, MultiBuffer};
|
||||
use editor::{Editor, EditorMode, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultiBuffer};
|
||||
use extension::ExtensionHostProxy;
|
||||
use fs::{FakeFs, Fs as _, RemoveOptions};
|
||||
use futures::StreamExt as _;
|
||||
|
|
@ -1367,3 +1367,260 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
|
|||
"should have no restricted worktrees after trusting both"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ssh_document_links_resolve(
|
||||
cx_a: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
cx_a.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
project::trusted_worktrees::init(HashMap::default(), cx);
|
||||
});
|
||||
server_cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
project::trusted_worktrees::init(HashMap::default(), cx);
|
||||
});
|
||||
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
|
||||
let document_link_count = Arc::new(AtomicUsize::new(0));
|
||||
let resolve_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let (opts, server_ssh, _) = RemoteClient::fake_server(cx_a, server_cx);
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
remote_fs
|
||||
.insert_tree(
|
||||
path!("/code"),
|
||||
json!({
|
||||
"main.rs": "// see LICENSE for details\nfn main() {}",
|
||||
"other.rs": "fn other() {}\n",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let remote_http_client = Arc::new(BlockedHttpClient);
|
||||
let node = NodeRuntime::unavailable();
|
||||
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
|
||||
languages.add(rust_lang());
|
||||
|
||||
let capabilities = lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(true),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
};
|
||||
let other_path_for_remote = path!("/code/other.rs");
|
||||
let mut fake_language_servers = languages.register_fake_lsp(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
capabilities: capabilities.clone(),
|
||||
initializer: Some(Box::new({
|
||||
let document_link_count = document_link_count.clone();
|
||||
let resolve_count = resolve_count.clone();
|
||||
move |fake_server| {
|
||||
let document_link_count = document_link_count.clone();
|
||||
fake_server.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>({
|
||||
move |_params, _| {
|
||||
let document_link_count = document_link_count.clone();
|
||||
async move {
|
||||
document_link_count.fetch_add(1, Ordering::Release);
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 0,
|
||||
character: 7,
|
||||
},
|
||||
end: lsp::Position {
|
||||
line: 0,
|
||||
character: 14,
|
||||
},
|
||||
},
|
||||
target: None,
|
||||
tooltip: None,
|
||||
data: Some(serde_json::json!({"id": 7})),
|
||||
}]))
|
||||
}
|
||||
}
|
||||
});
|
||||
let resolve_count = resolve_count.clone();
|
||||
fake_server.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>({
|
||||
move |link, _| {
|
||||
let resolve_count = resolve_count.clone();
|
||||
async move {
|
||||
resolve_count.fetch_add(1, Ordering::Release);
|
||||
Ok(lsp::DocumentLink {
|
||||
range: link.range,
|
||||
target: Some(
|
||||
lsp::Uri::from_file_path(other_path_for_remote).unwrap(),
|
||||
),
|
||||
tooltip: Some("Open other.rs".into()),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})),
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
let _headless_project = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_ssh,
|
||||
fs: remote_fs.clone(),
|
||||
http_client: remote_http_client,
|
||||
node_runtime: node,
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
startup_time: std::time::Instant::now(),
|
||||
},
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = RemoteClient::connect_mock(opts, cx_a).await;
|
||||
let (project_a, worktree_id) = client_a
|
||||
.build_ssh_project(path!("/code"), client_ssh.clone(), true, cx_a)
|
||||
.await;
|
||||
|
||||
cx_a.run_until_parked();
|
||||
let trusted_worktrees =
|
||||
cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global"));
|
||||
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
|
||||
trusted_worktrees.update(cx_a, |store, cx| {
|
||||
store.trust(
|
||||
&worktree_store,
|
||||
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx_a.run_until_parked();
|
||||
|
||||
cx_a.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.editor.lsp_document_links = Some(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
project_a.update(cx_a, |project, _| {
|
||||
project.languages().add(rust_lang());
|
||||
project.languages().register_fake_lsp_adapter(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
capabilities,
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let (buffer, _registration) = project_a
|
||||
.update(cx_a, |project, cx| {
|
||||
project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.read_with(cx_a, |buffer, _| buffer.remote_id());
|
||||
cx_a.run_until_parked();
|
||||
let _fake_language_server = fake_language_servers.next().await.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
|
||||
Editor::new(
|
||||
EditorMode::full(),
|
||||
cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)),
|
||||
Some(project_a.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx_a.executor()
|
||||
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
|
||||
cx_a.run_until_parked();
|
||||
|
||||
let fetched = project_a.read_with(cx_a, |project, cx| {
|
||||
project
|
||||
.lsp_store()
|
||||
.read(cx)
|
||||
.document_links_for_buffer(buffer_id)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
assert_eq!(
|
||||
fetched.values().map(|links| links.len()).sum::<usize>(),
|
||||
1,
|
||||
"Editor should auto-pull a single document link via SSH"
|
||||
);
|
||||
assert!(
|
||||
document_link_count.load(Ordering::Acquire) >= 1,
|
||||
"Remote LSP should have served the fetch request"
|
||||
);
|
||||
|
||||
let unresolved = fetched
|
||||
.values()
|
||||
.flat_map(|per_server| per_server.values())
|
||||
.next()
|
||||
.expect("local cache should mirror the remote document link");
|
||||
assert!(
|
||||
!unresolved.resolved,
|
||||
"freshly fetched links must come back unresolved"
|
||||
);
|
||||
|
||||
let anchor = buffer.read_with(cx_a, |buffer, _| buffer.anchor_after(10));
|
||||
let resolved = editor
|
||||
.update(cx_a, |editor, cx| {
|
||||
editor.document_links_at(buffer.clone(), anchor, cx)
|
||||
})
|
||||
.expect("editor should expose the cached document link at the cursor")
|
||||
.await;
|
||||
cx_a.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
resolved.len(),
|
||||
1,
|
||||
"Editor should surface exactly one resolved link at the cursor"
|
||||
);
|
||||
assert!(
|
||||
resolve_count.load(Ordering::Acquire) >= 1,
|
||||
"Local resolve should be forwarded over SSH and run on the remote LSP"
|
||||
);
|
||||
|
||||
let other_uri = lsp::Uri::from_file_path(path!("/code/other.rs"))
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let links = project_a.read_with(cx_a, |project, cx| {
|
||||
project
|
||||
.lsp_store()
|
||||
.read(cx)
|
||||
.document_links_for_buffer(buffer_id)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
assert_eq!(
|
||||
1,
|
||||
links.values().map(|m| m.len()).sum::<usize>(),
|
||||
"Local cache should mirror the single document link"
|
||||
);
|
||||
let link = links
|
||||
.values()
|
||||
.flat_map(|per_server| per_server.values())
|
||||
.next()
|
||||
.expect("local cache should contain the mirrored link");
|
||||
assert_eq!(
|
||||
link.target.as_deref(),
|
||||
Some(other_uri.as_str()),
|
||||
"Local should see the file:// target resolved on the remote"
|
||||
);
|
||||
assert_eq!(link.tooltip.as_deref(), Some("Open other.rs"));
|
||||
|
||||
let executor = cx_a.executor();
|
||||
client_ssh.update(cx_a, |a, _| {
|
||||
a.shutdown_processes(Some(proto::ShutdownRemoteServer {}), executor)
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ fn try_show_references(
|
|||
let nav_entry = editor.navigation_entry(editor.selections.newest_anchor().head(), cx);
|
||||
let links = locations
|
||||
.into_iter()
|
||||
.map(|location| HoverLink::InlayHint(location, server_id))
|
||||
.map(|location| HoverLink::LspLocation(location, server_id))
|
||||
.collect();
|
||||
editor
|
||||
.navigate_to_hover_links(None, links, nav_entry, false, window, cx)
|
||||
|
|
|
|||
209
crates/editor/src/document_links.rs
Normal file
209
crates/editor/src/document_links.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
use collections::HashMap;
|
||||
use futures::future::join_all;
|
||||
use gpui::{App, Entity, Task};
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, BufferSnapshot};
|
||||
use lsp::LanguageServerId;
|
||||
use project::lsp_store::{BufferDocumentLinks, LspDocumentLink, ResolvedDocumentLink};
|
||||
use settings::Settings;
|
||||
use text::BufferId;
|
||||
use ui::Context;
|
||||
|
||||
use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, editor_settings::EditorSettings};
|
||||
|
||||
pub(super) struct LspDocumentLinks {
|
||||
pub(super) enabled: bool,
|
||||
pub(super) per_buffer: HashMap<BufferId, BufferDocumentLinks>,
|
||||
pub(super) refresh_task: Task<()>,
|
||||
}
|
||||
|
||||
impl LspDocumentLinks {
|
||||
pub(super) fn new(cx: &App) -> Self {
|
||||
Self {
|
||||
enabled: EditorSettings::get_global(cx).lsp_document_links,
|
||||
per_buffer: HashMap::default(),
|
||||
refresh_task: Task::ready(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub(super) fn refresh_document_links(
|
||||
&mut self,
|
||||
for_buffer: Option<BufferId>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.lsp_data_enabled() || !self.lsp_document_links.enabled {
|
||||
return;
|
||||
}
|
||||
let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let buffers_to_query = self
|
||||
.visible_buffers(cx)
|
||||
.into_iter()
|
||||
.filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx))
|
||||
.chain(for_buffer.and_then(|id| self.buffer.read(cx).buffer(id)))
|
||||
.filter(|buffer| {
|
||||
let id = buffer.read(cx).remote_id();
|
||||
for_buffer.is_none_or(|target| target == id)
|
||||
&& self.registered_buffers.contains_key(&id)
|
||||
})
|
||||
.unique_by(|buffer| buffer.read(cx).remote_id())
|
||||
.collect::<Vec<_>>();
|
||||
if buffers_to_query.is_empty() {
|
||||
self.lsp_document_links.refresh_task = Task::ready(());
|
||||
return;
|
||||
}
|
||||
|
||||
self.lsp_document_links.refresh_task = cx.spawn(async move |editor, cx| {
|
||||
cx.background_executor()
|
||||
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
|
||||
.await;
|
||||
|
||||
let Some(tasks_for_buffers) = project
|
||||
.update(cx, |project, cx| {
|
||||
project.lsp_store().update(cx, |lsp_store, cx| {
|
||||
buffers_to_query
|
||||
.into_iter()
|
||||
.map(|buffer| {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let task = lsp_store.fetch_document_links(&buffer, cx);
|
||||
async move { (buffer_id, task.await) }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let new_links_for_buffers = join_all(tasks_for_buffers).await;
|
||||
editor
|
||||
.update(cx, |editor, _| {
|
||||
for (buffer_id, links) in new_links_for_buffers {
|
||||
let Some(links) = links else {
|
||||
continue;
|
||||
};
|
||||
if links.is_empty() {
|
||||
editor.lsp_document_links.per_buffer.remove(&buffer_id);
|
||||
} else {
|
||||
editor
|
||||
.lsp_document_links
|
||||
.per_buffer
|
||||
.insert(buffer_id, links);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a task yielding the resolved document links covering `position`
|
||||
/// in `buffer`, paired with the language server that owns each link.
|
||||
/// Resolution is deduplicated through `LspStore`'s per-`(server_id,
|
||||
/// link_id)` `Shared` task; the editor's mirror is updated when the
|
||||
/// resolves complete so subsequent renders/hovers find resolved data
|
||||
/// without re-issuing requests.
|
||||
///
|
||||
/// Returns `None` when nothing is cached at `position` so callers can skip
|
||||
/// spawning anything.
|
||||
pub fn document_links_at(
|
||||
&mut self,
|
||||
buffer: Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Task<Vec<(LanguageServerId, LspDocumentLink)>>> {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let matches = self
|
||||
.lsp_document_links
|
||||
.per_buffer
|
||||
.get(&buffer_id)?
|
||||
.iter()
|
||||
.flat_map(|(server_id, per_server)| {
|
||||
per_server
|
||||
.iter()
|
||||
.map(move |(link_id, link)| (*server_id, *link_id, link))
|
||||
})
|
||||
.filter(|(_, _, link)| link_contains(link, &position, &snapshot))
|
||||
.map(|(server_id, link_id, link)| (server_id, link_id, link.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let project = self.project.clone()?;
|
||||
let mut resolved_links = Vec::with_capacity(matches.len());
|
||||
let mut pending = Vec::new();
|
||||
project.update(cx, |project, cx| {
|
||||
project.lsp_store().update(cx, |lsp_store, cx| {
|
||||
for (server_id, link_id, _link) in matches {
|
||||
match lsp_store.resolved_document_link(&buffer, server_id, link_id, cx) {
|
||||
Some(ResolvedDocumentLink::Resolved(resolved)) => {
|
||||
resolved_links.push((server_id, link_id, resolved));
|
||||
}
|
||||
Some(ResolvedDocumentLink::Resolving(task)) => {
|
||||
pending.push((server_id, task));
|
||||
}
|
||||
None => {
|
||||
// Cache no longer holds the link (likely a version
|
||||
// bump between the mirror snapshot and now); skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if pending.is_empty() {
|
||||
return Some(Task::ready(
|
||||
resolved_links
|
||||
.into_iter()
|
||||
.map(|(server_id, _, link)| (server_id, link))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(cx.spawn(async move |editor, cx| {
|
||||
let pending_results =
|
||||
join_all(pending.into_iter().map(|(server_id, task)| async move {
|
||||
task.await.map(|(link_id, link)| (server_id, link_id, link))
|
||||
}))
|
||||
.await;
|
||||
resolved_links.extend(pending_results.into_iter().flatten());
|
||||
editor
|
||||
.update(cx, |editor, cx| {
|
||||
if let Some(by_server) =
|
||||
editor.lsp_document_links.per_buffer.get_mut(&buffer_id)
|
||||
{
|
||||
for (server_id, link_id, resolved) in &resolved_links {
|
||||
if let Some(slot) = by_server
|
||||
.get_mut(server_id)
|
||||
.and_then(|links| links.get_mut(link_id))
|
||||
{
|
||||
*slot = resolved.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
resolved_links
|
||||
.into_iter()
|
||||
.map(|(server_id, _, link)| (server_id, link))
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn link_contains(
|
||||
link: &LspDocumentLink,
|
||||
position: &text::Anchor,
|
||||
snapshot: &BufferSnapshot,
|
||||
) -> bool {
|
||||
link.range.start.cmp(position, snapshot).is_le()
|
||||
&& link.range.end.cmp(position, snapshot).is_ge()
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ impl Editor {
|
|||
if !self.lsp_data_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(project) = self.project.clone() else {
|
||||
let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -191,9 +191,9 @@ impl Editor {
|
|||
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
|
||||
.await;
|
||||
|
||||
let Some(tasks) = editor
|
||||
.update(cx, |_, cx| {
|
||||
project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
|
||||
let Some(tasks) = project
|
||||
.update(cx, |project, cx| {
|
||||
project.lsp_store().update(cx, |lsp_store, cx| {
|
||||
buffers_to_query
|
||||
.into_iter()
|
||||
.map(|buffer| {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub mod code_context_menus;
|
|||
mod code_lens;
|
||||
pub mod display_map;
|
||||
mod document_colors;
|
||||
mod document_links;
|
||||
mod document_symbols;
|
||||
mod editor_settings;
|
||||
mod element;
|
||||
|
|
@ -26,7 +27,7 @@ mod fold;
|
|||
mod folding_ranges;
|
||||
mod git;
|
||||
mod highlight_matching_bracket;
|
||||
mod hover_links;
|
||||
pub mod hover_links;
|
||||
pub mod hover_popover;
|
||||
mod indent_guides;
|
||||
mod inlays;
|
||||
|
|
@ -140,6 +141,7 @@ use convert_case::{Case, Casing};
|
|||
use dap::TelemetrySpawnLocation;
|
||||
use display_map::*;
|
||||
use document_colors::LspColorData;
|
||||
use document_links::LspDocumentLinks;
|
||||
use edit_prediction_types::{
|
||||
EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionDiscardReason,
|
||||
EditPredictionGranularity, SuggestionDisplayType,
|
||||
|
|
@ -1114,6 +1116,7 @@ pub struct Editor {
|
|||
semantic_token_state: SemanticTokenState,
|
||||
pub(crate) refresh_matching_bracket_highlights_task: Task<()>,
|
||||
refresh_document_symbols_task: Shared<Task<()>>,
|
||||
lsp_document_links: LspDocumentLinks,
|
||||
lsp_document_symbols: HashMap<BufferId, Vec<OutlineItem<text::Anchor>>>,
|
||||
refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>,
|
||||
outline_symbols_at_cursor: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
|
||||
|
|
@ -2322,6 +2325,7 @@ impl Editor {
|
|||
number_deleted_lines: false,
|
||||
refresh_matching_bracket_highlights_task: Task::ready(()),
|
||||
refresh_document_symbols_task: Task::ready(()).shared(),
|
||||
lsp_document_links: LspDocumentLinks::new(cx),
|
||||
lsp_document_symbols: HashMap::default(),
|
||||
refresh_outline_symbols_at_cursor_at_cursor_task: Task::ready(()),
|
||||
outline_symbols_at_cursor: None,
|
||||
|
|
@ -9319,6 +9323,8 @@ impl Editor {
|
|||
self.registered_buffers.remove(buffer_id);
|
||||
self.clear_runnables(Some(*buffer_id));
|
||||
self.semantic_token_state.invalidate_buffer(buffer_id);
|
||||
self.lsp_document_symbols.remove(buffer_id);
|
||||
self.lsp_document_links.per_buffer.remove(buffer_id);
|
||||
self.display_map.update(cx, |display_map, cx| {
|
||||
display_map.invalidate_semantic_highlights(*buffer_id);
|
||||
display_map.clear_lsp_folding_ranges(*buffer_id, cx);
|
||||
|
|
@ -9542,6 +9548,17 @@ impl Editor {
|
|||
self.toggle_code_lens(code_lens_inline, window, cx);
|
||||
}
|
||||
|
||||
let lsp_document_links_enabled = EditorSettings::get_global(cx).lsp_document_links;
|
||||
if lsp_document_links_enabled != self.lsp_document_links.enabled {
|
||||
self.lsp_document_links.enabled = lsp_document_links_enabled;
|
||||
if lsp_document_links_enabled {
|
||||
self.refresh_document_links(None, cx);
|
||||
} else {
|
||||
self.lsp_document_links.per_buffer.clear();
|
||||
self.lsp_document_links.refresh_task = Task::ready(());
|
||||
}
|
||||
}
|
||||
|
||||
self.refresh_inlay_hints(
|
||||
InlayHintRefreshReason::SettingsChange(inlay_hint_settings(
|
||||
self.selections.newest_anchor().head(),
|
||||
|
|
@ -10472,6 +10489,7 @@ impl Editor {
|
|||
}
|
||||
self.refresh_semantic_tokens(for_buffer, None, cx);
|
||||
self.refresh_document_colors(for_buffer, window, cx);
|
||||
self.refresh_document_links(for_buffer, cx);
|
||||
self.refresh_folding_ranges(for_buffer, window, cx);
|
||||
self.refresh_code_lenses(for_buffer, window, cx);
|
||||
self.refresh_document_symbols(for_buffer, cx);
|
||||
|
|
@ -10650,6 +10668,7 @@ impl Editor {
|
|||
self.colorize_brackets(false, cx);
|
||||
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
|
||||
self.resolve_visible_code_lenses(cx);
|
||||
|
||||
if !self.buffer().read(cx).is_singleton() || self.needs_initial_data_update {
|
||||
self.needs_initial_data_update = false;
|
||||
self.update_lsp_data(None, window, cx);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ pub struct EditorSettings {
|
|||
pub drag_and_drop_selection: DragAndDropSelection,
|
||||
pub code_lens: CodeLens,
|
||||
pub lsp_document_colors: DocumentColorsRenderMode,
|
||||
pub lsp_document_links: bool,
|
||||
pub minimum_contrast_for_highlights: f32,
|
||||
pub completion_menu_scrollbar: ShowScrollbar,
|
||||
pub completion_detail_alignment: CompletionDetailAlignment,
|
||||
|
|
@ -300,6 +301,7 @@ impl Settings for EditorSettings {
|
|||
},
|
||||
code_lens: editor.code_lens.unwrap(),
|
||||
lsp_document_colors: editor.lsp_document_colors.unwrap(),
|
||||
lsp_document_links: editor.lsp_document_links.unwrap(),
|
||||
minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0,
|
||||
completion_menu_scrollbar: editor
|
||||
.completion_menu_scrollbar
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@ use crate::{
|
|||
HighlightKey, Navigated, PointForPosition, SelectPhase,
|
||||
editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
|
||||
};
|
||||
use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px};
|
||||
use gpui::{
|
||||
App, AsyncWindowContext, Context, Entity, HighlightStyle, Modifiers, Pixels, Task,
|
||||
UnderlineStyle, Window, px,
|
||||
};
|
||||
use language::{Bias, ToOffset};
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use lsp::LanguageServerId;
|
||||
use project::{InlayId, LocationLink, Project, ResolvedPath};
|
||||
use regex::Regex;
|
||||
use settings::Settings;
|
||||
use std::{ops::Range, sync::LazyLock};
|
||||
use std::{ops::Range, str::FromStr as _, sync::LazyLock};
|
||||
use text::OffsetRangeExt;
|
||||
use theme::ActiveTheme as _;
|
||||
use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition};
|
||||
use util::{ResultExt, TryFutureExt as _, paths::PathWithPosition};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HoveredLinkState {
|
||||
|
|
@ -65,7 +68,52 @@ pub enum HoverLink {
|
|||
Url(String),
|
||||
File(ResolvedFileTarget),
|
||||
Text(LocationLink),
|
||||
InlayHint(lsp::Location, LanguageServerId),
|
||||
/// Navigate to an LSP-given location whose buffer may not be loaded yet.
|
||||
/// Used by inlay-hint hover, code-lens references, and document-link
|
||||
/// targets that point inside a workspace file (e.g. `file:///foo#9,16`).
|
||||
LspLocation(lsp::Location, LanguageServerId),
|
||||
}
|
||||
|
||||
/// Convert a `documentLink` target URI into a [`HoverLink`], reusing the
|
||||
/// existing navigation paths: `file://` URIs go through the LSP location
|
||||
/// pipeline (so an optional `#line[,column]` fragment is honored), while
|
||||
/// any other scheme is opened as a regular URL.
|
||||
pub fn document_link_target_to_hover_link(target: &str, server_id: LanguageServerId) -> HoverLink {
|
||||
if let Ok(url) = url::Url::parse(target)
|
||||
&& url.scheme() == "file"
|
||||
&& let Ok(uri) = lsp::Uri::from_str(target)
|
||||
{
|
||||
let position = url
|
||||
.fragment()
|
||||
.and_then(parse_uri_fragment_position)
|
||||
.unwrap_or_default();
|
||||
return HoverLink::LspLocation(
|
||||
lsp::Location {
|
||||
uri,
|
||||
range: lsp::Range::new(position, position),
|
||||
},
|
||||
server_id,
|
||||
);
|
||||
}
|
||||
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)]
|
||||
|
|
@ -239,7 +287,7 @@ impl Editor {
|
|||
else {
|
||||
return Task::ready(Ok(Navigated::No));
|
||||
};
|
||||
let Some(mb_anchor) = self
|
||||
let Some(multi_buffer_anchor) = self
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.snapshot(cx)
|
||||
|
|
@ -258,7 +306,7 @@ impl Editor {
|
|||
}
|
||||
})
|
||||
.collect();
|
||||
let nav_entry = self.navigation_entry(mb_anchor, cx);
|
||||
let nav_entry = self.navigation_entry(multi_buffer_anchor, cx);
|
||||
let split = Self::is_alt_pressed(&modifiers, cx);
|
||||
let navigate_task =
|
||||
self.navigate_to_hover_links(None, links, nav_entry, split, window, cx);
|
||||
|
|
@ -341,7 +389,7 @@ pub fn show_link_definition(
|
|||
|| hovered_link_state
|
||||
.links
|
||||
.first()
|
||||
.is_some_and(|d| matches!(d, HoverLink::Url(_)));
|
||||
.is_some_and(|d| matches!(d, HoverLink::Url(_) | HoverLink::LspLocation(_, _)));
|
||||
|
||||
if same_kind {
|
||||
if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
|
||||
|
|
@ -361,60 +409,107 @@ pub fn show_link_definition(
|
|||
let project = editor.project.clone();
|
||||
let provider = editor.semantics_provider.clone();
|
||||
|
||||
let snapshot = snapshot.buffer_snapshot().clone();
|
||||
hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
async move {
|
||||
// LSP document links take priority: the server explicitly
|
||||
// declares which ranges are clickable, so they are more
|
||||
// accurate than the heuristic-based URL/file detection.
|
||||
//
|
||||
// Resolution is deduplicated by `LspStore`; awaiting here only
|
||||
// blocks until either the cached resolved entry is returned or
|
||||
// the in-flight `Shared` task completes.
|
||||
let resolved_document_links = this
|
||||
.update(cx, |editor, cx| {
|
||||
editor.document_links_at(buffer.clone(), anchor, cx)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let resolved_document_links = match resolved_document_links {
|
||||
Some(task) => task.await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let snapshot = this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
|
||||
let detected_document_link =
|
||||
resolved_document_links
|
||||
.into_iter()
|
||||
.find_map(|(server_id, link)| {
|
||||
let multi_buffer_range =
|
||||
snapshot.buffer_anchor_range_to_anchor_range(link.range.clone())?;
|
||||
Some((link.range, multi_buffer_range, link.target, server_id))
|
||||
});
|
||||
drop(snapshot);
|
||||
|
||||
let result = match &trigger_point {
|
||||
TriggerPoint::Text(_) => {
|
||||
if let Some((url_range, url)) = find_url(&buffer, anchor, cx.clone()) {
|
||||
this.read_with(cx, |_, _| {
|
||||
let range = maybe!({
|
||||
let range =
|
||||
snapshot.buffer_anchor_range_to_anchor_range(url_range)?;
|
||||
Some(RangeInEditor::Text(range))
|
||||
});
|
||||
(range, vec![HoverLink::Url(url)])
|
||||
})
|
||||
.ok()
|
||||
let mut links = Vec::new();
|
||||
let mut symbol_range = None;
|
||||
|
||||
// LSP-provided document link wins over heuristic URL/file
|
||||
// detection at the same position: the server tells us the
|
||||
// exact range and target, while `find_url`/`find_file` are
|
||||
// best-effort text matches.
|
||||
if let Some((_, multi_buffer_range, Some(target), server_id)) =
|
||||
detected_document_link.clone()
|
||||
{
|
||||
symbol_range = Some(RangeInEditor::Text(multi_buffer_range));
|
||||
links.push(document_link_target_to_hover_link(&target, server_id));
|
||||
} else if let Some((url_range, url)) = find_url(&buffer, anchor, cx) {
|
||||
let snapshot =
|
||||
this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
|
||||
if let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(url_range)
|
||||
{
|
||||
symbol_range = Some(RangeInEditor::Text(range));
|
||||
}
|
||||
links.push(HoverLink::Url(url));
|
||||
} else if let Some((filename_range, file_target)) =
|
||||
find_file(&buffer, project.clone(), anchor, cx).await
|
||||
{
|
||||
let range = maybe!({
|
||||
let range =
|
||||
snapshot.buffer_anchor_range_to_anchor_range(filename_range)?;
|
||||
Some(RangeInEditor::Text(range))
|
||||
});
|
||||
let snapshot =
|
||||
this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
|
||||
if let Some(range) =
|
||||
snapshot.buffer_anchor_range_to_anchor_range(filename_range)
|
||||
{
|
||||
symbol_range = Some(RangeInEditor::Text(range));
|
||||
}
|
||||
links.push(HoverLink::File(file_target));
|
||||
}
|
||||
|
||||
Some((range, vec![HoverLink::File(file_target)]))
|
||||
} else if let Some(provider) = provider {
|
||||
// Always also collect LSP definitions so that cmd-click
|
||||
// reveals every applicable target (e.g. a position that
|
||||
// carries both a document link and a definition).
|
||||
if let Some(provider) = provider {
|
||||
let task = cx.update(|_, cx| {
|
||||
provider.definitions(&buffer, anchor, preferred_kind, cx)
|
||||
})?;
|
||||
if let Some(task) = task {
|
||||
task.await.ok().flatten().map(|definition_result| {
|
||||
(
|
||||
definition_result.iter().find_map(|link| {
|
||||
link.origin.as_ref().and_then(|origin| {
|
||||
let range = snapshot
|
||||
.buffer_anchor_range_to_anchor_range(
|
||||
origin.range.clone(),
|
||||
)?;
|
||||
Some(RangeInEditor::Text(range))
|
||||
})
|
||||
}),
|
||||
definition_result.into_iter().map(HoverLink::Text).collect(),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if let Some(task) = task
|
||||
&& let Some(definition_result) = task.await.ok().flatten()
|
||||
{
|
||||
if symbol_range.is_none() {
|
||||
let snapshot = this.read_with(cx, |editor, cx| {
|
||||
editor.buffer.read(cx).snapshot(cx)
|
||||
})?;
|
||||
symbol_range = definition_result.iter().find_map(|link| {
|
||||
link.origin.as_ref().and_then(|origin| {
|
||||
let range = snapshot.buffer_anchor_range_to_anchor_range(
|
||||
origin.range.clone(),
|
||||
)?;
|
||||
Some(RangeInEditor::Text(range))
|
||||
})
|
||||
});
|
||||
}
|
||||
links.extend(definition_result.into_iter().map(HoverLink::Text));
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if links.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((symbol_range, links))
|
||||
}
|
||||
}
|
||||
TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
|
||||
Some(RangeInEditor::Inlay(highlight.clone())),
|
||||
vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
|
||||
vec![HoverLink::LspLocation(lsp_location.clone(), *server_id)],
|
||||
)),
|
||||
};
|
||||
|
||||
|
|
@ -428,7 +523,18 @@ pub fn show_link_definition(
|
|||
hovered_link_state.preferred_kind = preferred_kind;
|
||||
hovered_link_state.symbol_range = result
|
||||
.as_ref()
|
||||
.and_then(|(symbol_range, _)| symbol_range.clone());
|
||||
.and_then(|(symbol_range, _)| symbol_range.clone())
|
||||
.or_else(|| {
|
||||
// Even if we have no click target yet (e.g. an
|
||||
// unresolved document link), record the link's range
|
||||
// so subsequent mouse moves on the same link
|
||||
// short-circuit in `show_link_definition`.
|
||||
detected_document_link
|
||||
.as_ref()
|
||||
.map(|(_, multi_buffer_range, _, _)| {
|
||||
RangeInEditor::Text(multi_buffer_range.clone())
|
||||
})
|
||||
});
|
||||
|
||||
if let Some((symbol_range, definitions)) = result {
|
||||
hovered_link_state.links = definitions;
|
||||
|
|
@ -437,17 +543,18 @@ pub fn show_link_definition(
|
|||
|| hovered_link_state.symbol_range.is_some();
|
||||
|
||||
if underline_hovered_link {
|
||||
let style = gpui::HighlightStyle {
|
||||
underline: Some(gpui::UnderlineStyle {
|
||||
let style = HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
..Default::default()
|
||||
..UnderlineStyle::default()
|
||||
}),
|
||||
color: Some(cx.theme().colors().link_text_hover),
|
||||
..Default::default()
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
let highlight_range =
|
||||
symbol_range.unwrap_or_else(|| match &trigger_point {
|
||||
TriggerPoint::Text(trigger_anchor) => {
|
||||
let snapshot = editor.buffer.read(cx).snapshot(cx);
|
||||
// If no symbol range returned from language server, use the surrounding word.
|
||||
let (offset_range, _) =
|
||||
snapshot.surrounding_word(*trigger_anchor, None);
|
||||
|
|
@ -476,6 +583,22 @@ pub fn show_link_definition(
|
|||
),
|
||||
}
|
||||
}
|
||||
} else if let Some((_, multi_buffer_range, _, _)) = detected_document_link.as_ref()
|
||||
{
|
||||
let style = HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
..UnderlineStyle::default()
|
||||
}),
|
||||
color: Some(cx.theme().colors().link_text_hover),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
editor.highlight_text(
|
||||
HighlightKey::HoveredLinkState,
|
||||
vec![multi_buffer_range.clone()],
|
||||
style,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
editor.hide_hovered_link(cx);
|
||||
}
|
||||
|
|
@ -493,11 +616,11 @@ pub fn show_link_definition(
|
|||
pub(crate) fn find_url(
|
||||
buffer: &Entity<language::Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: AsyncWindowContext,
|
||||
cx: &AsyncWindowContext,
|
||||
) -> Option<(Range<text::Anchor>, String)> {
|
||||
const LIMIT: usize = 2048;
|
||||
|
||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
|
||||
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
|
||||
|
||||
let offset = position.to_offset(&snapshot);
|
||||
let mut token_start = offset;
|
||||
|
|
@ -553,11 +676,11 @@ pub(crate) fn find_url(
|
|||
pub(crate) fn find_url_from_range(
|
||||
buffer: &Entity<language::Buffer>,
|
||||
range: Range<text::Anchor>,
|
||||
cx: AsyncWindowContext,
|
||||
cx: &AsyncWindowContext,
|
||||
) -> Option<String> {
|
||||
const LIMIT: usize = 2048;
|
||||
|
||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
|
||||
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
|
||||
|
||||
let start_offset = range.start.to_offset(&snapshot);
|
||||
let end_offset = range.end.to_offset(&snapshot);
|
||||
|
|
@ -609,7 +732,7 @@ pub(crate) fn find_url_from_range(
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedFileTarget {
|
||||
pub struct ResolvedFileTarget {
|
||||
pub resolved_path: ResolvedPath,
|
||||
pub row: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
|
|
@ -942,9 +1065,98 @@ mod tests {
|
|||
use lsp::request::{GotoDefinition, GotoTypeDefinition};
|
||||
use multi_buffer::MultiBufferOffset;
|
||||
use settings::InlayHintSettingsContent;
|
||||
use std::str::FromStr;
|
||||
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);
|
||||
let target = "file:///Users/me/work/local_test/document-links-test.json#9,16";
|
||||
match document_link_target_to_hover_link(target, server_id) {
|
||||
HoverLink::LspLocation(location, returned_id) => {
|
||||
assert_eq!(returned_id, server_id);
|
||||
assert_eq!(
|
||||
location.uri.as_str(),
|
||||
"file:///Users/me/work/local_test/document-links-test.json#9,16",
|
||||
);
|
||||
assert_eq!(
|
||||
location.range,
|
||||
lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 8,
|
||||
character: 15,
|
||||
},
|
||||
end: lsp::Position {
|
||||
line: 8,
|
||||
character: 15,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
other => panic!("expected LspLocation variant, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_link_target_to_hover_link_http_url() {
|
||||
let server_id = LanguageServerId(0);
|
||||
let target = "https://opensource.org/licenses/MIT";
|
||||
match document_link_target_to_hover_link(target, server_id) {
|
||||
HoverLink::Url(url) => assert_eq!(url, target),
|
||||
other => panic!("expected Url variant, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
|
@ -2384,4 +2596,510 @@ Sentence ending file2.rs.
|
|||
fn «do_workˇ»() { test(); }
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_links(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(false),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
// See LICENSE for details
|
||||
fn main() {
|
||||
println!(\"hello\");
|
||||
}ˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// See «LICENSE» for details
|
||||
fn main() {
|
||||
println!(\"hello\");
|
||||
}
|
||||
"});
|
||||
|
||||
let mut requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
|
||||
move |_, _| async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
|
||||
),
|
||||
tooltip: Some("Open license".to_string()),
|
||||
data: None,
|
||||
}]))
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger document link fetch via LSP data refresh
|
||||
cx.run_until_parked();
|
||||
requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
// Cmd-hover over "LICENSE" should highlight it as a link
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See LICˇENSE for details
|
||||
fn main() {
|
||||
println!(\"hello\");
|
||||
}
|
||||
"});
|
||||
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.assert_editor_text_highlights(
|
||||
HighlightKey::HoveredLinkState,
|
||||
indoc! {"
|
||||
// See «LICENSEˇ» for details
|
||||
fn main() {
|
||||
println!(\"hello\");
|
||||
}
|
||||
"},
|
||||
);
|
||||
|
||||
// Clicking opens the URL
|
||||
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
||||
assert_eq!(
|
||||
cx.opened_url(),
|
||||
Some("https://opensource.org/licenses/MIT".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_links_take_priority_over_url_detection(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(false),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Text contains a URL, but the LSP provides a document link that
|
||||
// covers a broader range and points to a different target.
|
||||
cx.set_state(indoc! {"
|
||||
// See https://example.com for more infoˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// «See https://example.com for more info»
|
||||
"});
|
||||
|
||||
let mut requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
|
||||
move |_, _| async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://lsp-provided.example.com").unwrap(),
|
||||
),
|
||||
tooltip: None,
|
||||
data: None,
|
||||
}]))
|
||||
},
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See https://examˇple.com for more info
|
||||
"});
|
||||
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
||||
cx.run_until_parked();
|
||||
|
||||
// LSP document link range is highlighted, not just the URL portion
|
||||
cx.assert_editor_text_highlights(
|
||||
HighlightKey::HoveredLinkState,
|
||||
indoc! {"
|
||||
// «See https://example.com for more infoˇ»
|
||||
"},
|
||||
);
|
||||
|
||||
// Clicking navigates to the LSP-provided target, not the detected URL.
|
||||
// (Uri::to_string normalizes "https://host" to "https://host/")
|
||||
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
||||
assert_eq!(
|
||||
cx.opened_url(),
|
||||
Some("https://lsp-provided.example.com/".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_cmd_hover_aggregates_document_link_and_definition(cx: &mut gpui::TestAppContext) {
|
||||
// VSCode behavior: when a position carries multiple link sources
|
||||
// (LSP document link, go-to-definition, ...), cmd-click should reveal
|
||||
// every applicable target. We assert this by inspecting the
|
||||
// aggregated `hovered_link_state.links` after a cmd-hover.
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(false),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
definition_provider: Some(lsp::OneOf::Left(true)),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
// See LICENSE for details
|
||||
fn definition() {}ˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// See «LICENSE» for details
|
||||
fn definition() {}
|
||||
"});
|
||||
let definition_target_range = cx.lsp_range(indoc! {"
|
||||
// See LICENSE for details
|
||||
fn «definition»() {}
|
||||
"});
|
||||
|
||||
let mut document_link_requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
|
||||
move |_, _| async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
|
||||
),
|
||||
tooltip: Some("Open license".to_string()),
|
||||
data: None,
|
||||
}]))
|
||||
},
|
||||
);
|
||||
|
||||
let mut definition_requests =
|
||||
cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
|
||||
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
|
||||
lsp::LocationLink {
|
||||
origin_selection_range: Some(link_range),
|
||||
target_uri: url.clone(),
|
||||
target_range: definition_target_range,
|
||||
target_selection_range: definition_target_range,
|
||||
},
|
||||
])))
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
document_link_requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See LICˇENSE for details
|
||||
fn definition() {}
|
||||
"});
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
||||
definition_requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, _, _| {
|
||||
let links = &editor
|
||||
.hovered_link_state
|
||||
.as_ref()
|
||||
.expect("cmd-hover should populate `hovered_link_state`")
|
||||
.links;
|
||||
let url_count = links
|
||||
.iter()
|
||||
.filter(|link| matches!(link, HoverLink::Url(_)))
|
||||
.count();
|
||||
let text_count = links
|
||||
.iter()
|
||||
.filter(|link| matches!(link, HoverLink::Text(_)))
|
||||
.count();
|
||||
assert_eq!(
|
||||
url_count, 1,
|
||||
"document link should contribute exactly one Url hover link, got {links:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
text_count, 1,
|
||||
"go-to-definition should contribute exactly one Text hover link, got {links:?}"
|
||||
);
|
||||
});
|
||||
|
||||
// Cmd-click resolves the in-buffer location (definition) since the
|
||||
// mixed Url + Text case lets `navigate_to_hover_links` prefer the
|
||||
// location target over the external URL.
|
||||
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
// See LICENSE for details
|
||||
fn «definitionˇ»() {}
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_link_tooltip_popover(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(false),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
// See LICENSE for detailsˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// See «LICENSE» for details
|
||||
"});
|
||||
|
||||
let mut requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
|
||||
move |_, _| async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
|
||||
),
|
||||
tooltip: Some("Open license".to_string()),
|
||||
data: None,
|
||||
}]))
|
||||
},
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See LICˇENSE for details
|
||||
"});
|
||||
// Plain hover (no modifier) is enough; the doc-link tooltip stacks
|
||||
// alongside the regular LSP hover popovers.
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
|
||||
let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
|
||||
cx.background_executor
|
||||
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
let tooltip_text = editor
|
||||
.hover_state
|
||||
.info_popovers
|
||||
.iter()
|
||||
.find_map(|popover| {
|
||||
let parsed = popover.parsed_content.as_ref()?;
|
||||
let text = parsed.read(cx).parsed_markdown().source().to_string();
|
||||
(text == "Open license").then_some(text)
|
||||
})
|
||||
.expect("doc-link tooltip should appear in info_popovers on plain hover");
|
||||
assert_eq!(tooltip_text, "Open license");
|
||||
});
|
||||
|
||||
// Move the mouse off the link; `show_hover` re-fires for the new
|
||||
// position and rebuilds `info_popovers` without the tooltip.
|
||||
let off_link = cx.pixel_position(indoc! {"
|
||||
// ˇSee LICENSE for details
|
||||
"});
|
||||
cx.simulate_mouse_move(off_link, None, Modifiers::none());
|
||||
cx.background_executor
|
||||
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
|
||||
cx.run_until_parked();
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
let still_present = editor.hover_state.info_popovers.iter().any(|popover| {
|
||||
popover
|
||||
.parsed_content
|
||||
.as_ref()
|
||||
.map(|parsed| *parsed.read(cx).parsed_markdown().source() == "Open license")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(
|
||||
!still_present,
|
||||
"doc-link tooltip should be cleared once the mouse leaves the link"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_link_resolve_on_hover(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(true),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
// See LICENSE for detailsˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// See «LICENSE» for details
|
||||
"});
|
||||
let resolve_data = serde_json::json!({"id": 42});
|
||||
|
||||
let mut document_link_requests = {
|
||||
let resolve_data = resolve_data.clone();
|
||||
cx.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |_, _| {
|
||||
let resolve_data = resolve_data.clone();
|
||||
async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: None,
|
||||
tooltip: None,
|
||||
data: Some(resolve_data),
|
||||
}]))
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let mut resolve_requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(
|
||||
move |req, _| async move {
|
||||
Ok(lsp::DocumentLink {
|
||||
range: req.range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
|
||||
),
|
||||
tooltip: Some("Resolved tooltip".to_string()),
|
||||
data: None,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
document_link_requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See LICˇENSE for details
|
||||
"});
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
|
||||
let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
|
||||
cx.background_executor
|
||||
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
|
||||
cx.run_until_parked();
|
||||
// Hover triggers resolve, not a viewport sweep.
|
||||
resolve_requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
let tooltip_text = editor
|
||||
.hover_state
|
||||
.info_popovers
|
||||
.iter()
|
||||
.find_map(|popover| {
|
||||
let parsed = popover.parsed_content.as_ref()?;
|
||||
let text = parsed.read(cx).parsed_markdown().source().to_string();
|
||||
(text == "Resolved tooltip").then_some(text)
|
||||
})
|
||||
.expect("resolved doc-link tooltip should appear in info_popovers");
|
||||
assert_eq!(tooltip_text, "Resolved tooltip");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_link_tooltip_respects_hover_popover_enabled(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
cx.update(|cx| {
|
||||
use gpui::BorrowAppContext as _;
|
||||
cx.update_global::<settings::SettingsStore, _>(|settings, cx| {
|
||||
settings.update_user_settings(cx, |settings| {
|
||||
settings.editor.hover_popover_enabled = Some(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
document_link_provider: Some(lsp::DocumentLinkOptions {
|
||||
resolve_provider: Some(false),
|
||||
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
|
||||
}),
|
||||
..lsp::ServerCapabilities::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
// See LICENSE for detailsˇ
|
||||
"});
|
||||
|
||||
let link_range = cx.lsp_range(indoc! {"
|
||||
// See «LICENSE» for details
|
||||
"});
|
||||
|
||||
let mut requests = cx
|
||||
.lsp
|
||||
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
|
||||
move |_, _| async move {
|
||||
Ok(Some(vec![lsp::DocumentLink {
|
||||
range: link_range,
|
||||
target: Some(
|
||||
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
|
||||
),
|
||||
tooltip: Some("Open license".to_string()),
|
||||
data: None,
|
||||
}]))
|
||||
},
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
requests.next().await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let screen_coord = cx.pixel_position(indoc! {"
|
||||
// See LICˇENSE for details
|
||||
"});
|
||||
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
|
||||
cx.background_executor
|
||||
.advance_clock(std::time::Duration::from_millis(2000));
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, _, _| {
|
||||
assert!(
|
||||
editor.hover_state.info_popovers.is_empty(),
|
||||
"no popovers should appear when hover_popover_enabled is false"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -510,6 +510,27 @@ fn show_hover(
|
|||
})
|
||||
}
|
||||
|
||||
let doc_link_task = this
|
||||
.update(cx, |editor, cx| {
|
||||
editor.document_links_at(buffer.clone(), buffer_position, cx)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let doc_link_tooltips = match doc_link_task {
|
||||
Some(task) => task
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|(_, link)| {
|
||||
let multi_buffer_range = snapshot
|
||||
.buffer_snapshot()
|
||||
.buffer_anchor_range_to_anchor_range(link.range.clone())?;
|
||||
let tooltip = link.tooltip?;
|
||||
Some((multi_buffer_range, tooltip))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
for hover_result in hovers_response {
|
||||
// Create symbol range of anchors for highlighting and filtering of future requests.
|
||||
let range = hover_result
|
||||
|
|
@ -552,6 +573,32 @@ fn show_hover(
|
|||
});
|
||||
}
|
||||
|
||||
for (multi_buffer_range, tooltip) in doc_link_tooltips {
|
||||
let blocks = vec![HoverBlock {
|
||||
text: tooltip.to_string(),
|
||||
kind: HoverBlockKind::Markdown,
|
||||
}];
|
||||
let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
|
||||
let scroll_handle = ScrollHandle::new();
|
||||
let subscription = this
|
||||
.update(cx, |_, cx| {
|
||||
parsed_content.as_ref().map(|parsed_content| {
|
||||
cx.observe(parsed_content, |_, _, cx| cx.notify())
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
info_popovers.push(InfoPopover {
|
||||
symbol_range: RangeInEditor::Text(multi_buffer_range),
|
||||
parsed_content,
|
||||
scroll_handle,
|
||||
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
|
||||
anchor: Some(anchor),
|
||||
last_bounds: Rc::new(Cell::new(None)),
|
||||
_subscription: subscription,
|
||||
});
|
||||
}
|
||||
|
||||
this.update_in(cx, |editor, window, cx| {
|
||||
if hover_highlights.is_empty() {
|
||||
editor.clear_background_highlights(HighlightKey::HoverState, cx);
|
||||
|
|
|
|||
|
|
@ -1074,9 +1074,9 @@ impl Editor {
|
|||
|
||||
let url_finder = cx.spawn_in(window, async move |_editor, cx| {
|
||||
let url = if let Some(end_pos) = end_position {
|
||||
find_url_from_range(&buffer, start_position..end_pos, cx.clone())
|
||||
find_url_from_range(&buffer, start_position..end_pos, cx)
|
||||
} else {
|
||||
find_url(&buffer, start_position, cx.clone()).map(|(_, url)| url)
|
||||
find_url(&buffer, start_position, cx).map(|(_, url)| url)
|
||||
};
|
||||
|
||||
if let Some(url) = url {
|
||||
|
|
@ -1576,7 +1576,7 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn navigate_to_hover_links(
|
||||
pub fn navigate_to_hover_links(
|
||||
&mut self,
|
||||
kind: Option<GotoDefinitionKind>,
|
||||
definitions: Vec<HoverLink>,
|
||||
|
|
@ -1591,7 +1591,7 @@ impl Editor {
|
|||
.into_iter()
|
||||
.filter_map(|def| match def {
|
||||
HoverLink::Text(link) => Some(Task::ready(anyhow::Ok(Some(link.target)))),
|
||||
HoverLink::InlayHint(lsp_location, server_id) => {
|
||||
HoverLink::LspLocation(lsp_location, server_id) => {
|
||||
let computation =
|
||||
self.compute_target_location(lsp_location, server_id, window, cx);
|
||||
Some(cx.background_spawn(computation))
|
||||
|
|
|
|||
|
|
@ -1000,6 +1000,10 @@ impl LanguageServer {
|
|||
color_provider: Some(DocumentColorClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
}),
|
||||
document_link: Some(DocumentLinkClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
tooltip_support: Some(true),
|
||||
}),
|
||||
folding_range: Some(FoldingRangeClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
line_folding_only: Some(false),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::{
|
|||
InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
|
||||
LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse,
|
||||
ProjectTransaction, PulledDiagnostics, ResolveState,
|
||||
lsp_store::{LocalLspStore, LspFoldingRange, LspStore},
|
||||
lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -304,6 +304,9 @@ pub(crate) struct GetDocumentColor;
|
|||
#[derive(Debug, Copy, Clone)]
|
||||
pub(crate) struct GetFoldingRanges;
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub(crate) struct GetDocumentLinks;
|
||||
|
||||
impl GetCodeLens {
|
||||
pub(crate) fn can_resolve_lens(capabilities: &ServerCapabilities) -> bool {
|
||||
capabilities
|
||||
|
|
@ -4906,6 +4909,147 @@ impl LspCommand for GetFoldingRanges {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl LspCommand for GetDocumentLinks {
|
||||
type Response = Vec<LspDocumentLink>;
|
||||
type LspRequest = lsp::request::DocumentLinkRequest;
|
||||
type ProtoRequest = proto::GetDocumentLinks;
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Document links"
|
||||
}
|
||||
|
||||
fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
|
||||
server_capabilities
|
||||
.server_capabilities
|
||||
.document_link_provider
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn to_lsp(
|
||||
&self,
|
||||
path: &Path,
|
||||
_: &Buffer,
|
||||
_: &Arc<LanguageServer>,
|
||||
_: &App,
|
||||
) -> Result<lsp::DocumentLinkParams> {
|
||||
Ok(lsp::DocumentLinkParams {
|
||||
text_document: make_text_document_identifier(path)?,
|
||||
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
|
||||
partial_result_params: lsp::PartialResultParams::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn response_from_lsp(
|
||||
self,
|
||||
message: Option<Vec<lsp::DocumentLink>>,
|
||||
_: Entity<LspStore>,
|
||||
buffer: Entity<Buffer>,
|
||||
_: LanguageServerId,
|
||||
cx: AsyncApp,
|
||||
) -> Result<Self::Response> {
|
||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
|
||||
Ok(message
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|link| {
|
||||
let start = snapshot.clip_point_utf16(
|
||||
Unclipped(PointUtf16::new(
|
||||
link.range.start.line,
|
||||
link.range.start.character,
|
||||
)),
|
||||
Bias::Left,
|
||||
);
|
||||
let end = snapshot.clip_point_utf16(
|
||||
Unclipped(PointUtf16::new(
|
||||
link.range.end.line,
|
||||
link.range.end.character,
|
||||
)),
|
||||
Bias::Right,
|
||||
);
|
||||
LspDocumentLink {
|
||||
range: snapshot.anchor_after(start)..snapshot.anchor_before(end),
|
||||
target: link.target.map(|url| url.to_string().into()),
|
||||
tooltip: link.tooltip.map(SharedString::from),
|
||||
data: link.data,
|
||||
resolved: false,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest {
|
||||
proto::GetDocumentLinks {
|
||||
project_id,
|
||||
buffer_id: buffer.remote_id().to_proto(),
|
||||
version: serialize_version(&buffer.version()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn from_proto(
|
||||
_: Self::ProtoRequest,
|
||||
_: Entity<LspStore>,
|
||||
_: Entity<Buffer>,
|
||||
_: AsyncApp,
|
||||
) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
fn response_to_proto(
|
||||
response: Self::Response,
|
||||
_: &mut LspStore,
|
||||
_: PeerId,
|
||||
buffer_version: &clock::Global,
|
||||
_: &mut App,
|
||||
) -> proto::GetDocumentLinksResponse {
|
||||
proto::GetDocumentLinksResponse {
|
||||
links: response
|
||||
.into_iter()
|
||||
.map(|link| proto::DocumentLinkProto {
|
||||
range: Some(serialize_anchor_range(link.range)),
|
||||
target: link.target.map(String::from),
|
||||
tooltip: link.tooltip.map(String::from),
|
||||
data: link
|
||||
.data
|
||||
.map(|d| serde_json::to_string(&d).unwrap_or_default()),
|
||||
})
|
||||
.collect(),
|
||||
version: serialize_version(buffer_version),
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_from_proto(
|
||||
self,
|
||||
message: proto::GetDocumentLinksResponse,
|
||||
_: Entity<LspStore>,
|
||||
buffer: Entity<Buffer>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<Self::Response> {
|
||||
buffer
|
||||
.update(&mut cx, |buffer, _| {
|
||||
buffer.wait_for_version(deserialize_version(&message.version))
|
||||
})
|
||||
.await?;
|
||||
message
|
||||
.links
|
||||
.into_iter()
|
||||
.map(|link| {
|
||||
Ok(LspDocumentLink {
|
||||
range: deserialize_anchor_range(link.range.context("missing range")?)?,
|
||||
target: link.target.map(SharedString::from),
|
||||
tooltip: link.tooltip.map(SharedString::from),
|
||||
data: link.data.and_then(|d| serde_json::from_str(&d).ok()),
|
||||
resolved: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result<BufferId> {
|
||||
BufferId::new(message.buffer_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_related_documents(
|
||||
diagnostics: &mut HashMap<lsp::Uri, LspPullDiagnostics>,
|
||||
server_id: LanguageServerId,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
pub mod clangd_ext;
|
||||
pub mod code_lens;
|
||||
mod document_colors;
|
||||
mod document_links;
|
||||
mod document_symbols;
|
||||
mod folding_ranges;
|
||||
mod inlay_hints;
|
||||
|
|
@ -24,6 +25,7 @@ pub mod vue_language_server_ext;
|
|||
|
||||
use self::code_lens::CodeLensData;
|
||||
use self::document_colors::DocumentColorData;
|
||||
use self::document_links::DocumentLinksData;
|
||||
use self::document_symbols::DocumentSymbolsData;
|
||||
use self::inlay_hints::BufferInlayHints;
|
||||
use crate::{
|
||||
|
|
@ -146,6 +148,10 @@ use util::{
|
|||
};
|
||||
|
||||
pub use document_colors::DocumentColors;
|
||||
pub use document_links::{
|
||||
BufferDocumentLinks, DocumentLinkId, DocumentLinkResolveTask, LspDocumentLink,
|
||||
ResolvedDocumentLink,
|
||||
};
|
||||
pub use folding_ranges::LspFoldingRange;
|
||||
pub use fs::*;
|
||||
pub use language::Location;
|
||||
|
|
@ -4006,6 +4012,7 @@ pub struct BufferLspData {
|
|||
code_lens: Option<CodeLensData>,
|
||||
semantic_tokens: Option<SemanticTokensData>,
|
||||
folding_ranges: Option<FoldingRangeData>,
|
||||
document_links: Option<DocumentLinksData>,
|
||||
document_symbols: Option<DocumentSymbolsData>,
|
||||
inlay_hints: BufferInlayHints,
|
||||
lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
|
||||
|
|
@ -4026,6 +4033,7 @@ impl BufferLspData {
|
|||
code_lens: None,
|
||||
semantic_tokens: None,
|
||||
folding_ranges: None,
|
||||
document_links: None,
|
||||
document_symbols: None,
|
||||
inlay_hints: BufferInlayHints::new(buffer, cx),
|
||||
lsp_requests: HashMap::default(),
|
||||
|
|
@ -4052,6 +4060,10 @@ impl BufferLspData {
|
|||
folding_ranges.ranges.remove(&for_server);
|
||||
}
|
||||
|
||||
if let Some(document_links) = &mut self.document_links {
|
||||
document_links.remove_server_data(for_server);
|
||||
}
|
||||
|
||||
if let Some(document_symbols) = &mut self.document_symbols {
|
||||
document_symbols.remove_server_data(for_server);
|
||||
}
|
||||
|
|
@ -4174,6 +4186,7 @@ impl LspStore {
|
|||
client.add_entity_request_handler(Self::handle_apply_code_action);
|
||||
client.add_entity_request_handler(Self::handle_get_project_symbols);
|
||||
client.add_entity_request_handler(Self::handle_resolve_inlay_hint);
|
||||
client.add_entity_request_handler(Self::handle_resolve_document_link);
|
||||
client.add_entity_request_handler(Self::handle_get_color_presentation);
|
||||
client.add_entity_request_handler(Self::handle_open_buffer_for_symbol);
|
||||
client.add_entity_request_handler(Self::handle_refresh_inlay_hints);
|
||||
|
|
@ -9260,6 +9273,36 @@ impl LspStore {
|
|||
)
|
||||
.await?;
|
||||
}
|
||||
Request::GetDocumentLinks(get_document_links) => {
|
||||
let (buffer_version, buffer) = Self::wait_for_buffer_version::<GetDocumentLinks>(
|
||||
&lsp_store,
|
||||
&get_document_links,
|
||||
&mut cx,
|
||||
)
|
||||
.await?;
|
||||
lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
let document_links_task = lsp_store.fetch_document_links(&buffer, cx);
|
||||
let fetch_task = cx.background_spawn(async move {
|
||||
document_links_task
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(server_id, links)| {
|
||||
(server_id, links.into_values().collect::<Vec<_>>())
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
lsp_store.serve_lsp_query::<GetDocumentLinks>(
|
||||
server_id,
|
||||
sender_id,
|
||||
lsp_request_id,
|
||||
&buffer,
|
||||
buffer_version,
|
||||
fetch_task,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
Request::GetHover(get_hover) => {
|
||||
let position = get_hover.position.clone().and_then(deserialize_anchor);
|
||||
Self::query_lsp_locally::<GetHover>(
|
||||
|
|
@ -9457,66 +9500,19 @@ impl LspStore {
|
|||
.await?;
|
||||
let for_server = semantic_tokens.for_server.map(LanguageServerId::from_proto);
|
||||
lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
if let Some((client, project_id)) = lsp_store.downstream_client.clone() {
|
||||
let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
|
||||
let key = LspKey {
|
||||
request_type: TypeId::of::<SemanticTokensFull>(),
|
||||
server_queried: server_id,
|
||||
};
|
||||
if <SemanticTokensFull as LspCommand>::ProtoRequest::stop_previous_requests() {
|
||||
if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) {
|
||||
lsp_requests.clear();
|
||||
};
|
||||
}
|
||||
|
||||
lsp_data.lsp_requests.entry(key).or_default().insert(
|
||||
lsp_request_id,
|
||||
cx.spawn(async move |lsp_store, cx| {
|
||||
let tokens_fetch = lsp_store
|
||||
.update(cx, |lsp_store, cx| {
|
||||
lsp_store
|
||||
.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx)
|
||||
})
|
||||
.ok();
|
||||
if let Some(tokens_fetch) = tokens_fetch {
|
||||
let new_tokens = tokens_fetch.await;
|
||||
if let Some(new_tokens) = new_tokens {
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, cx| {
|
||||
let response = new_tokens
|
||||
.into_iter()
|
||||
.map(|(server_id, response)| {
|
||||
(
|
||||
server_id.to_proto(),
|
||||
SemanticTokensFull::response_to_proto(
|
||||
response,
|
||||
lsp_store,
|
||||
sender_id,
|
||||
&buffer_version,
|
||||
cx,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
match client.send_lsp_response::<<SemanticTokensFull as LspCommand>::ProtoRequest>(
|
||||
project_id,
|
||||
lsp_request_id,
|
||||
response,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to send semantic tokens LSP response: {e:#}",
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
let semantic_tokens_task =
|
||||
lsp_store.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx);
|
||||
lsp_store.serve_lsp_query::<SemanticTokensFull>(
|
||||
server_id,
|
||||
sender_id,
|
||||
lsp_request_id,
|
||||
&buffer,
|
||||
buffer_version,
|
||||
cx.background_spawn(async move {
|
||||
semantic_tokens_task.await.unwrap_or_default()
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -13479,6 +13475,72 @@ impl LspStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_lsp_query<T>(
|
||||
&mut self,
|
||||
server_id: Option<LanguageServerId>,
|
||||
sender_id: proto::PeerId,
|
||||
lsp_request_id: LspRequestId,
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_version: Global,
|
||||
fetch_task: Task<HashMap<LanguageServerId, T::Response>>,
|
||||
cx: &mut Context<Self>,
|
||||
) where
|
||||
T: LspCommand + 'static,
|
||||
T::ProtoRequest: proto::LspRequestMessage,
|
||||
<T::ProtoRequest as proto::RequestMessage>::Response:
|
||||
Into<<T::ProtoRequest as proto::LspRequestMessage>::Response>,
|
||||
{
|
||||
let Some((client, project_id)) = self.downstream_client.clone() else {
|
||||
return;
|
||||
};
|
||||
let lsp_data = self.latest_lsp_data(buffer, cx);
|
||||
let key = LspKey {
|
||||
request_type: TypeId::of::<T>(),
|
||||
server_queried: server_id,
|
||||
};
|
||||
if T::ProtoRequest::stop_previous_requests() {
|
||||
if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) {
|
||||
lsp_requests.clear();
|
||||
}
|
||||
}
|
||||
lsp_data.lsp_requests.entry(key).or_default().insert(
|
||||
lsp_request_id,
|
||||
cx.spawn(async move |lsp_store, cx| {
|
||||
let by_server = fetch_task.await;
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, cx| {
|
||||
let response = by_server
|
||||
.into_iter()
|
||||
.map(|(server_id, response)| {
|
||||
(
|
||||
server_id.to_proto(),
|
||||
T::response_to_proto(
|
||||
response,
|
||||
lsp_store,
|
||||
sender_id,
|
||||
&buffer_version,
|
||||
cx,
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
if let Err(e) = client.send_lsp_response::<T::ProtoRequest>(
|
||||
project_id,
|
||||
lsp_request_id,
|
||||
response,
|
||||
) {
|
||||
log::error!(
|
||||
"Failed to send {} LSP response: {e:#}",
|
||||
std::any::type_name::<T>()
|
||||
);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_buffer_version<T>(
|
||||
lsp_store: &Entity<Self>,
|
||||
proto_request: &T::ProtoRequest,
|
||||
|
|
|
|||
456
crates/project/src/lsp_store/document_links.rs
Normal file
456
crates/project/src/lsp_store/document_links.rs
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
use std::ops::Range;
|
||||
use std::str::FromStr as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use clock::Global;
|
||||
use collections::HashMap;
|
||||
use futures::FutureExt as _;
|
||||
use futures::future::{Shared, join_all};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, Entity, SharedString, Task};
|
||||
use language::{Buffer, point_to_lsp};
|
||||
use lsp::LanguageServerId;
|
||||
use lsp::request::DocumentLinkResolve;
|
||||
use rpc::{TypedEnvelope, proto};
|
||||
use settings::Settings as _;
|
||||
use text::{Anchor, BufferId, ToPointUtf16 as _};
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::lsp_command::{GetDocumentLinks, LspCommand as _};
|
||||
use crate::lsp_store::LspStore;
|
||||
use crate::project_settings::ProjectSettings;
|
||||
|
||||
#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub struct DocumentLinkId(u64);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LspDocumentLink {
|
||||
pub range: Range<Anchor>,
|
||||
pub target: Option<SharedString>,
|
||||
pub tooltip: Option<SharedString>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
pub type BufferDocumentLinks = HashMap<LanguageServerId, HashMap<DocumentLinkId, LspDocumentLink>>;
|
||||
|
||||
pub(super) type DocumentLinksTask =
|
||||
Shared<Task<std::result::Result<Option<BufferDocumentLinks>, Arc<anyhow::Error>>>>;
|
||||
|
||||
pub type DocumentLinkResolveTask = Shared<Task<Option<(DocumentLinkId, LspDocumentLink)>>>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct DocumentLinksData {
|
||||
pub(super) links: BufferDocumentLinks,
|
||||
pub(super) next_id: u64,
|
||||
links_update: Option<(Global, DocumentLinksTask)>,
|
||||
pub(super) link_resolves: HashMap<(LanguageServerId, DocumentLinkId), DocumentLinkResolveTask>,
|
||||
}
|
||||
|
||||
impl DocumentLinksData {
|
||||
pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) {
|
||||
self.links.remove(&server_id);
|
||||
self.link_resolves
|
||||
.retain(|(resolved_server, _), _| *resolved_server != server_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of [`crate::lsp_store::ResolvedHint`] for document links: callers
|
||||
/// either get the resolved entry directly, an in-flight `Shared` task to await
|
||||
/// (deduplicated across editors), or `None` when the cache no longer contains
|
||||
/// a matching link.
|
||||
pub enum ResolvedDocumentLink {
|
||||
Resolved(LspDocumentLink),
|
||||
Resolving(DocumentLinkResolveTask),
|
||||
}
|
||||
|
||||
impl LspStore {
|
||||
/// `Some(..)` means the underlying state was actually refreshed; `None`
|
||||
/// means the fetch was skipped or failed, and the caller should keep its
|
||||
/// previous data.
|
||||
pub fn fetch_document_links(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Option<BufferDocumentLinks>> {
|
||||
let version_queried_for = buffer.read(cx).version();
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
|
||||
let current_language_servers = self.as_local().map(|local| {
|
||||
local
|
||||
.buffers_opened_in_servers
|
||||
.get(&buffer_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
if let Some(lsp_data) = self.current_lsp_data(buffer_id)
|
||||
&& let Some(cached) = &lsp_data.document_links
|
||||
&& !version_queried_for.changed_since(&lsp_data.buffer_version)
|
||||
{
|
||||
let has_different_servers =
|
||||
current_language_servers.is_some_and(|current_language_servers| {
|
||||
current_language_servers != cached.links.keys().copied().collect()
|
||||
});
|
||||
if !has_different_servers {
|
||||
return Task::ready(Some(cached.links.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let links_lsp_data = self
|
||||
.latest_lsp_data(buffer, cx)
|
||||
.document_links
|
||||
.get_or_insert_default();
|
||||
if let Some((updating_for, running_update)) = &links_lsp_data.links_update
|
||||
&& !version_queried_for.changed_since(updating_for)
|
||||
{
|
||||
let running = running_update.clone();
|
||||
return cx.background_spawn(async move { running.await.ok().flatten() });
|
||||
}
|
||||
|
||||
let buffer = buffer.clone();
|
||||
let query_version = version_queried_for.clone();
|
||||
let new_task = cx
|
||||
.spawn(async move |lsp_store, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(30))
|
||||
.await;
|
||||
|
||||
let fetched = lsp_store
|
||||
.update(cx, |lsp_store, cx| {
|
||||
lsp_store.fetch_document_links_for_buffer(&buffer, cx)
|
||||
})
|
||||
.map_err(Arc::new)?
|
||||
.await
|
||||
.context("fetching document links")
|
||||
.map_err(Arc::new);
|
||||
|
||||
let fetched = match fetched {
|
||||
Ok(fetched) => fetched,
|
||||
Err(e) => {
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
if let Some(lsp_data) = lsp_store.lsp_data.get_mut(&buffer_id)
|
||||
&& let Some(document_links) = &mut lsp_data.document_links
|
||||
{
|
||||
document_links.links_update = None;
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, cx| {
|
||||
let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
|
||||
let links_data = lsp_data.document_links.get_or_insert_default();
|
||||
links_data.links_update = None;
|
||||
|
||||
let Some(fetched_links) = fetched else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut tagged = BufferDocumentLinks::default();
|
||||
for (server_id, server_links) in fetched_links {
|
||||
let mut by_id = HashMap::default();
|
||||
by_id.reserve(server_links.len());
|
||||
for link in server_links {
|
||||
let id = DocumentLinkId(links_data.next_id);
|
||||
links_data.next_id += 1;
|
||||
by_id.insert(id, link);
|
||||
}
|
||||
tagged.insert(server_id, by_id);
|
||||
}
|
||||
|
||||
if lsp_data.buffer_version == query_version {
|
||||
for (server_id, new_links) in &tagged {
|
||||
links_data.links.insert(*server_id, new_links.clone());
|
||||
}
|
||||
// The newly inserted links are unresolved by definition; drop any
|
||||
// pending resolves that were keyed against the prior entries for
|
||||
// those servers so callers re-issue against the fresh ids.
|
||||
links_data.link_resolves.clear();
|
||||
Some(links_data.links.clone())
|
||||
} else if !lsp_data.buffer_version.changed_since(&query_version) {
|
||||
lsp_data.buffer_version = query_version;
|
||||
links_data.links = tagged;
|
||||
links_data.link_resolves.clear();
|
||||
Some(links_data.links.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_err(Arc::new)
|
||||
})
|
||||
.shared();
|
||||
|
||||
links_lsp_data.links_update = Some((version_queried_for, new_task.clone()));
|
||||
|
||||
cx.background_spawn(async move { new_task.await.ok().flatten() })
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn document_links_for_buffer(&self, buffer_id: BufferId) -> Option<BufferDocumentLinks> {
|
||||
let data = self.lsp_data.get(&buffer_id)?;
|
||||
let document_links = data.document_links.as_ref()?;
|
||||
Some(document_links.links.clone())
|
||||
}
|
||||
|
||||
fn fetch_document_links_for_buffer(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<LspDocumentLink>>>>> {
|
||||
if let Some((client, project_id)) = self.upstream_client() {
|
||||
let request = GetDocumentLinks;
|
||||
if !self.is_capable_for_proto_request(buffer, &request, cx) {
|
||||
return Task::ready(Ok(None));
|
||||
}
|
||||
|
||||
let request_timeout = ProjectSettings::get_global(cx)
|
||||
.global_lsp_settings
|
||||
.get_request_timeout();
|
||||
let request_task = client.request_lsp(
|
||||
project_id,
|
||||
None,
|
||||
request_timeout,
|
||||
cx.background_executor().clone(),
|
||||
request.to_proto(project_id, buffer.read(cx)),
|
||||
);
|
||||
let buffer = buffer.clone();
|
||||
cx.spawn(async move |weak_lsp_store, cx| {
|
||||
let Some(lsp_store) = weak_lsp_store.upgrade() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(responses) = request_task.await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let document_links = join_all(responses.payload.into_iter().map(|response| {
|
||||
let lsp_store = lsp_store.clone();
|
||||
let buffer = buffer.clone();
|
||||
let cx = cx.clone();
|
||||
async move {
|
||||
let server_id = LanguageServerId::from_proto(response.server_id);
|
||||
let links = GetDocumentLinks
|
||||
.response_from_proto(response.response, lsp_store, buffer, cx)
|
||||
.await;
|
||||
(server_id, links)
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut has_errors = false;
|
||||
let result = document_links
|
||||
.into_iter()
|
||||
.filter_map(|(server_id, links)| match links {
|
||||
Ok(links) => Some((server_id, links)),
|
||||
Err(e) => {
|
||||
has_errors = true;
|
||||
log::error!(
|
||||
"Failed to fetch document links for server {server_id}: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
anyhow::ensure!(
|
||||
!has_errors || !result.is_empty(),
|
||||
"Failed to fetch document links"
|
||||
);
|
||||
Ok(Some(result))
|
||||
})
|
||||
} else {
|
||||
let links_task =
|
||||
self.request_multiple_lsp_locally(buffer, None::<usize>, GetDocumentLinks, cx);
|
||||
cx.background_spawn(async move { Ok(Some(links_task.await.into_iter().collect())) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the resolved state for a cached document link, deduplicating
|
||||
/// in-flight `documentLink/resolve` requests across editors via a `Shared`
|
||||
/// task stored on `DocumentLinksData`.
|
||||
///
|
||||
/// `link_id` is the [`DocumentLinkId`] stamped on the cached link by
|
||||
/// [`Self::fetch_document_links`]; sibling links sharing the same buffer
|
||||
/// range are disambiguated by it. `None` is returned when the cache no
|
||||
/// longer holds a matching link (likely a version bump in between).
|
||||
pub fn resolved_document_link(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
server_id: LanguageServerId,
|
||||
link_id: DocumentLinkId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<ResolvedDocumentLink> {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
|
||||
let document_links = self.lsp_data.get(&buffer_id)?.document_links.as_ref()?;
|
||||
let cached_link = document_links.links.get(&server_id)?.get(&link_id)?.clone();
|
||||
|
||||
if cached_link.resolved {
|
||||
return Some(ResolvedDocumentLink::Resolved(cached_link));
|
||||
}
|
||||
|
||||
let key = (server_id, link_id);
|
||||
if let Some(running) = document_links.link_resolves.get(&key) {
|
||||
return Some(ResolvedDocumentLink::Resolving(running.clone()));
|
||||
}
|
||||
|
||||
let resolve_task = self.resolve_document_link_request(buffer, server_id, &cached_link, cx);
|
||||
let query_version = self.lsp_data.get(&buffer_id)?.buffer_version.clone();
|
||||
let resolve_task = cx
|
||||
.spawn(async move |lsp_store, cx| {
|
||||
let resolved = resolve_task.await;
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
let lsp_data = lsp_store.lsp_data.get_mut(&buffer_id)?;
|
||||
if lsp_data.buffer_version != query_version {
|
||||
return None;
|
||||
}
|
||||
let links_data = lsp_data.document_links.as_mut()?;
|
||||
links_data.link_resolves.remove(&key);
|
||||
let updated = match resolved {
|
||||
Some(resolved) => lsp_store
|
||||
.cache_resolved_link(buffer_id, server_id, link_id, &resolved)?,
|
||||
None => {
|
||||
// No further resolution is possible (no capability,
|
||||
// missing server, or LSP error); mark as resolved so we
|
||||
// do not keep retrying on every hover, and yield the
|
||||
// entry as-is so awaiters can still surface it.
|
||||
let links_data = lsp_data.document_links.as_mut()?;
|
||||
let link =
|
||||
links_data.links.get_mut(&server_id)?.get_mut(&link_id)?;
|
||||
link.resolved = true;
|
||||
link.clone()
|
||||
}
|
||||
};
|
||||
Some((link_id, updated))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.shared();
|
||||
|
||||
let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?;
|
||||
document_links
|
||||
.link_resolves
|
||||
.insert(key, resolve_task.clone());
|
||||
Some(ResolvedDocumentLink::Resolving(resolve_task))
|
||||
}
|
||||
|
||||
/// Builds the LSP/proto request task for a single unresolved link. Returns
|
||||
/// a task that yields `None` when the resolve request cannot be issued
|
||||
/// (no upstream capability, no local server, or no `resolveProvider`).
|
||||
fn resolve_document_link_request(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
server_id: LanguageServerId,
|
||||
cached_link: &LspDocumentLink,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Option<lsp::DocumentLink>> {
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let lsp_link = lsp::DocumentLink {
|
||||
range: lsp::Range {
|
||||
start: point_to_lsp(cached_link.range.start.to_point_utf16(&snapshot)),
|
||||
end: point_to_lsp(cached_link.range.end.to_point_utf16(&snapshot)),
|
||||
},
|
||||
target: cached_link
|
||||
.target
|
||||
.as_ref()
|
||||
.and_then(|s| lsp::Uri::from_str(s).ok()),
|
||||
tooltip: cached_link.tooltip.as_deref().map(str::to_string),
|
||||
data: cached_link.data.clone(),
|
||||
};
|
||||
|
||||
if let Some((upstream_client, project_id)) = self.upstream_client() {
|
||||
if !self.check_if_capable_for_proto_request(buffer, can_resolve_link, cx) {
|
||||
return Task::ready(None);
|
||||
}
|
||||
let request = proto::ResolveDocumentLink {
|
||||
project_id,
|
||||
buffer_id: buffer_id.into(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
lsp_link: serde_json::to_vec(&lsp_link).unwrap_or_default(),
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
let response = upstream_client.request(request).await.log_err()?;
|
||||
serde_json::from_slice::<lsp::DocumentLink>(&response.lsp_link).log_err()
|
||||
})
|
||||
} else {
|
||||
let Some(server) = self.language_server_for_id(server_id) else {
|
||||
return Task::ready(None);
|
||||
};
|
||||
if !can_resolve_link(&server.capabilities()) {
|
||||
return Task::ready(None);
|
||||
}
|
||||
let request_timeout = ProjectSettings::get_global(cx)
|
||||
.global_lsp_settings
|
||||
.get_request_timeout();
|
||||
cx.background_spawn(async move {
|
||||
server
|
||||
.request::<DocumentLinkResolve>(lsp_link, request_timeout)
|
||||
.await
|
||||
.into_response()
|
||||
.log_err()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_resolved_link(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
server_id: LanguageServerId,
|
||||
link_id: DocumentLinkId,
|
||||
resolved: &lsp::DocumentLink,
|
||||
) -> Option<LspDocumentLink> {
|
||||
let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?;
|
||||
let link = document_links
|
||||
.links
|
||||
.get_mut(&server_id)?
|
||||
.get_mut(&link_id)?;
|
||||
link.target = resolved.target.as_ref().map(|u| u.to_string().into());
|
||||
if let Some(tooltip) = &resolved.tooltip {
|
||||
link.tooltip = Some(tooltip.clone().into());
|
||||
}
|
||||
link.data = resolved.data.clone();
|
||||
link.resolved = true;
|
||||
Some(link.clone())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_resolve_document_link(
|
||||
lsp_store: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::ResolveDocumentLink>,
|
||||
mut cx: AsyncApp,
|
||||
) -> anyhow::Result<proto::ResolveDocumentLinkResponse> {
|
||||
let lsp_link: lsp::DocumentLink = serde_json::from_slice(&envelope.payload.lsp_link)
|
||||
.context("deserializing document link to resolve")?;
|
||||
let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id);
|
||||
|
||||
let resolve_task = lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
let server = lsp_store
|
||||
.language_server_for_id(server_id)
|
||||
.with_context(|| format!("No language server {server_id}"))?;
|
||||
let timeout = ProjectSettings::get_global(cx)
|
||||
.global_lsp_settings
|
||||
.get_request_timeout();
|
||||
anyhow::Ok(server.request::<DocumentLinkResolve>(lsp_link, timeout))
|
||||
})?;
|
||||
let resolved = resolve_task.await.into_response()?;
|
||||
|
||||
Ok(proto::ResolveDocumentLinkResponse {
|
||||
lsp_link: serde_json::to_vec(&resolved)
|
||||
.context("serializing resolved document link")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn can_resolve_link(capabilities: &lsp::ServerCapabilities) -> bool {
|
||||
capabilities
|
||||
.document_link_provider
|
||||
.as_ref()
|
||||
.and_then(|opts| opts.resolve_provider)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
|
@ -122,6 +122,35 @@ message GetDocumentSymbolsResponse {
|
|||
repeated DocumentSymbol symbols = 1;
|
||||
}
|
||||
|
||||
message GetDocumentLinks {
|
||||
uint64 project_id = 1;
|
||||
uint64 buffer_id = 2;
|
||||
repeated VectorClockEntry version = 3;
|
||||
}
|
||||
|
||||
message GetDocumentLinksResponse {
|
||||
repeated DocumentLinkProto links = 1;
|
||||
repeated VectorClockEntry version = 2;
|
||||
}
|
||||
|
||||
message DocumentLinkProto {
|
||||
optional AnchorRange range = 1;
|
||||
optional string target = 2;
|
||||
optional string tooltip = 3;
|
||||
optional string data = 4;
|
||||
}
|
||||
|
||||
message ResolveDocumentLink {
|
||||
uint64 project_id = 1;
|
||||
uint64 buffer_id = 2;
|
||||
uint64 language_server_id = 3;
|
||||
bytes lsp_link = 4;
|
||||
}
|
||||
|
||||
message ResolveDocumentLinkResponse {
|
||||
bytes lsp_link = 1;
|
||||
}
|
||||
|
||||
message DocumentSymbol {
|
||||
string name = 1;
|
||||
int32 kind = 2;
|
||||
|
|
@ -853,6 +882,7 @@ message LspQuery {
|
|||
SemanticTokens semantic_tokens = 16;
|
||||
GetFoldingRanges get_folding_ranges = 17;
|
||||
GetDocumentSymbols get_document_symbols = 18;
|
||||
GetDocumentLinks get_document_links = 19;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,6 +909,7 @@ message LspResponse {
|
|||
SemanticTokensResponse semantic_tokens_response = 14;
|
||||
GetFoldingRangesResponse get_folding_ranges_response = 15;
|
||||
GetDocumentSymbolsResponse get_document_symbols_response = 16;
|
||||
GetDocumentLinksResponse get_document_links_response = 17;
|
||||
}
|
||||
uint64 server_id = 7;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -484,7 +484,11 @@ message Envelope {
|
|||
SearchCommits search_commits = 449;
|
||||
SearchCommitsResponse search_commits_response = 450;
|
||||
GetInitialGraphData get_initial_graph_data = 451;
|
||||
GetInitialGraphDataResponse get_initial_graph_data_response = 452; // current max
|
||||
GetInitialGraphDataResponse get_initial_graph_data_response = 452;
|
||||
GetDocumentLinks get_document_links = 453;
|
||||
GetDocumentLinksResponse get_document_links_response = 454;
|
||||
ResolveDocumentLink resolve_document_link = 455;
|
||||
ResolveDocumentLinkResponse resolve_document_link_response = 456; // current max
|
||||
}
|
||||
|
||||
reserved 87 to 88;
|
||||
|
|
|
|||
|
|
@ -224,6 +224,10 @@ messages!(
|
|||
(GetDocumentColorResponse, Background),
|
||||
(GetColorPresentation, Background),
|
||||
(GetColorPresentationResponse, Background),
|
||||
(GetDocumentLinks, Background),
|
||||
(GetDocumentLinksResponse, Background),
|
||||
(ResolveDocumentLink, Background),
|
||||
(ResolveDocumentLinkResponse, Background),
|
||||
(GetFoldingRanges, Background),
|
||||
(GetFoldingRangesResponse, Background),
|
||||
(RefreshCodeLens, Background),
|
||||
|
|
@ -469,6 +473,8 @@ request_messages!(
|
|||
),
|
||||
(ResolveInlayHint, ResolveInlayHintResponse),
|
||||
(GetDocumentColor, GetDocumentColorResponse),
|
||||
(GetDocumentLinks, GetDocumentLinksResponse),
|
||||
(ResolveDocumentLink, ResolveDocumentLinkResponse),
|
||||
(GetFoldingRanges, GetFoldingRangesResponse),
|
||||
(GetColorPresentation, GetColorPresentationResponse),
|
||||
(RespondToChannelInvite, Ack),
|
||||
|
|
@ -595,6 +601,7 @@ lsp_messages!(
|
|||
(GetDocumentColor, GetDocumentColorResponse, true),
|
||||
(GetFoldingRanges, GetFoldingRangesResponse, true),
|
||||
(GetDocumentSymbols, GetDocumentSymbolsResponse, true),
|
||||
(GetDocumentLinks, GetDocumentLinksResponse, true),
|
||||
(GetHover, GetHoverResponse, true),
|
||||
(GetCodeActions, GetCodeActionsResponse, true),
|
||||
(GetSignatureHelp, GetSignatureHelpResponse, true),
|
||||
|
|
@ -628,6 +635,8 @@ entity_messages!(
|
|||
CreateImageForPeer,
|
||||
CreateProjectEntry,
|
||||
GetDocumentColor,
|
||||
GetDocumentLinks,
|
||||
ResolveDocumentLink,
|
||||
GetFoldingRanges,
|
||||
DeleteProjectEntry,
|
||||
ExpandProjectEntry,
|
||||
|
|
@ -979,6 +988,7 @@ impl LspQuery {
|
|||
Some(lsp_query::Request::GetDocumentColor(_)) => ("GetDocumentColor", false),
|
||||
Some(lsp_query::Request::GetFoldingRanges(_)) => ("GetFoldingRanges", false),
|
||||
Some(lsp_query::Request::GetDocumentSymbols(_)) => ("GetDocumentSymbols", false),
|
||||
Some(lsp_query::Request::GetDocumentLinks(_)) => ("GetDocumentLinks", false),
|
||||
Some(lsp_query::Request::InlayHints(_)) => ("InlayHints", false),
|
||||
Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false),
|
||||
None => ("<unknown>", true),
|
||||
|
|
|
|||
|
|
@ -417,6 +417,9 @@ impl AnyProtoClient {
|
|||
Response::GetDocumentSymbolsResponse(response) => {
|
||||
to_any_envelope(&envelope, response)
|
||||
}
|
||||
Response::GetDocumentLinksResponse(response) => {
|
||||
to_any_envelope(&envelope, response)
|
||||
}
|
||||
};
|
||||
Some(proto::ProtoLspResponse {
|
||||
server_id,
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ impl VsCodeSettings {
|
|||
code_lens: None,
|
||||
jupyter: None,
|
||||
lsp_document_colors: None,
|
||||
lsp_document_links: self.read_bool("editor.links"),
|
||||
lsp_highlight_debounce: None,
|
||||
middle_click_paste: None,
|
||||
minimap: self.minimap_content(),
|
||||
|
|
|
|||
|
|
@ -226,6 +226,10 @@ pub struct EditorSettingsContent {
|
|||
///
|
||||
/// Default: [`DocumentColorsRenderMode::Inlay`]
|
||||
pub lsp_document_colors: Option<DocumentColorsRenderMode>,
|
||||
/// Whether to query and display LSP `textDocument/documentLink` links in the editor.
|
||||
///
|
||||
/// Default: true
|
||||
pub lsp_document_links: Option<bool>,
|
||||
/// When to show the scrollbar in the completion menu.
|
||||
/// This setting can take four values:
|
||||
///
|
||||
|
|
|
|||
|
|
@ -3004,6 +3004,16 @@ Configuration for various AI model providers including API URLs and authenticati
|
|||
3. `border`: Draw a border around the color text.
|
||||
4. `none`: Do not query and render document colors.
|
||||
|
||||
## LSP Document Links
|
||||
|
||||
- Description: Whether to query and display LSP `textDocument/documentLink` links in the editor
|
||||
- Setting: `lsp_document_links`
|
||||
- Default: `true`
|
||||
|
||||
**Options**
|
||||
|
||||
`boolean` values
|
||||
|
||||
## Max Tabs
|
||||
|
||||
- Description: Maximum number of tabs to show in the tab bar
|
||||
|
|
|
|||
|
|
@ -382,6 +382,8 @@ TBD: Centered layout related settings
|
|||
|
||||
// How to render LSP `textDocument/documentColor` colors in the editor.
|
||||
"lsp_document_colors": "inlay", // none, inlay, border, background
|
||||
// Whether to query and display LSP document links in the editor.
|
||||
"lsp_document_links": true,
|
||||
// When to show the scrollbar in the completion menu.
|
||||
"completion_menu_scrollbar": "never", // auto, system, always, never
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue