Fix resolved lens causing flickers (#56047)

Kirill Bulatov created

Based on
https://github.com/zed-industries/zed/pull/54100#issuecomment-4394534078

* Adjusts the code lens display closer to what VSCode does: have blank
placeholders for the code lens need resolving.
Zed will remove them if resolve returns nothing, so some small amount of
jitter is still there.

* Also reworks LspStore layer to provide a simple resolve method,
without any ranges involved, grouping that logic in the editor itself.
This allows to process each resolve request separately, updating editor
blocks as soon as possible.

Before:


https://github.com/user-attachments/assets/d6759a90-0087-4658-abf8-8e2767bc63a2

After:


https://github.com/user-attachments/assets/cb8f976c-b3fc-4f66-bb9f-812108255c90


Release Notes:

- Fixed resolved lens causing flickers

Change summary

crates/collab/tests/integration/editor_tests.rs |   7 
crates/editor/src/code_lens.rs                  | 516 +++++++++++++++---
crates/project/src/lsp_store.rs                 |   2 
crates/project/src/lsp_store/code_lens.rs       | 289 ++++++----
4 files changed, 595 insertions(+), 219 deletions(-)

Detailed changes

crates/collab/tests/integration/editor_tests.rs 🔗

@@ -1399,7 +1399,12 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte
         "Should have fetched one code lens action, but got: {resulting_lens_actions:?}"
     );
     assert_eq!(
-        resulting_lens_actions.first().unwrap().lsp_action.title(),
+        resulting_lens_actions
+            .values()
+            .next()
+            .unwrap()
+            .lsp_action
+            .title(),
         "LSP Command 1",
         "Only the final code lens action should be in the data"
     )

crates/editor/src/code_lens.rs 🔗

@@ -1,13 +1,14 @@
 use std::sync::Arc;
 
 use collections::{HashMap, HashSet};
-use futures::future::join_all;
+use futures::{StreamExt as _, future::join_all, stream::FuturesUnordered};
 use gpui::{MouseButton, SharedString, Task, TaskExt, WeakEntity};
 use itertools::Itertools;
 use language::{BufferId, ClientCommand};
 use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _};
-use project::{CodeAction, TaskSourceKind};
+use project::{CodeAction, TaskSourceKind, lsp_store::code_lens::CodeLensActions};
 use task::TaskContext;
+use text::ToOffset as _;
 
 use ui::{Context, Window, div, prelude::*};
 
@@ -27,7 +28,7 @@ struct CodeLensLine {
 
 #[derive(Clone, Debug)]
 struct CodeLensItem {
-    title: SharedString,
+    title: Option<SharedString>,
     action: CodeAction,
 }
 
@@ -39,7 +40,7 @@ pub(super) struct CodeLensBlock {
 
 pub(super) struct CodeLensState {
     pub(super) blocks: HashMap<BufferId, Vec<CodeLensBlock>>,
-    actions: HashMap<BufferId, Vec<CodeAction>>,
+    actions: HashMap<BufferId, CodeLensActions>,
     resolve_task: Task<()>,
 }
 
@@ -203,7 +204,7 @@ impl Editor {
                 .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
                 .await;
 
-            let Some(tasks) = project
+            let Some(tasks_per_buffer) = project
                 .update(cx, |project, cx| {
                     project.lsp_store().update(cx, |lsp_store, cx| {
                         buffers_to_query
@@ -221,15 +222,15 @@ impl Editor {
                 return;
             };
 
-            let results = join_all(tasks).await;
-            if results.is_empty() {
+            let code_lens_per_buffer = join_all(tasks_per_buffer).await;
+            if code_lens_per_buffer.is_empty() {
                 return;
             }
 
             editor
                 .update(cx, |editor, cx| {
                     let snapshot = editor.buffer().read(cx).snapshot(cx);
-                    for (buffer_id, result) in results {
+                    for (buffer_id, result) in code_lens_per_buffer {
                         let actions = match result {
                             Ok(Some(actions)) => actions,
                             Ok(None) => continue,
@@ -248,58 +249,50 @@ impl Editor {
         });
     }
 
-    /// Reconciles the set of blocks for `buffer_id` with `actions`. For each
-    /// existing block at row `R`:
-    /// - if the new fetch has no lens at `R` → remove the block (the lens is
-    ///   gone, e.g. the function was deleted);
-    /// - if the new fetch has a titled lens at `R` whose rendered text
-    ///   differs from the block's current line → swap the renderer in place
-    ///   via [`Editor::replace_blocks`];
-    /// - if the new fetch has a titled lens at `R` with the same rendered
-    ///   text → keep the block as-is;
-    /// - if the new fetch has a lens at `R` but no `command` yet (the server
-    ///   sent a shallow response that needs a separate `resolve`) → keep the
-    ///   block as-is. The previously rendered (resolved) content stays on
-    ///   screen until the next viewport-driven `resolve` produces a new
-    ///   title; only then does the comparison-and-replace happen. This is
-    ///   what keeps the post-edit screen from flickering for shallow servers
-    ///   like `rust-analyzer`.
+    /// Reconcile blocks for `buffer_id` against the latest `actions`.
     ///
-    /// Rows present in the new fetch with a title but no existing block get
-    /// a fresh block inserted.
+    /// Lenses without a `command` keep a placeholder block so the line
+    /// stays reserved while the resolve is in flight — this is what avoids
+    /// the post-edit flicker on `rust-analyzer`-style servers. Lenses
+    /// whose resolve already came back without a usable title are dropped
+    /// (`resolve_visible_code_lenses` won't retry them), otherwise they'd
+    /// leave a permanent blank line.
+    ///
+    /// When the new fetch has only placeholders for a row but the old
+    /// block was already resolved we keep the old block, so the line
+    /// doesn't blank out until the fresh resolve lands.
     fn apply_lens_actions_for_buffer(
         &mut self,
         buffer_id: BufferId,
-        actions: Vec<CodeAction>,
+        actions: CodeLensActions,
         snapshot: &MultiBufferSnapshot,
         cx: &mut Context<Self>,
     ) {
-        let mut rows_with_any_lens = HashSet::default();
-        let mut titled_lenses = Vec::new();
-        for action in &actions {
+        let mut all_lenses = Vec::new();
+        for (_, action) in actions.iter().sorted_by_key(|(id, _)| **id) {
             let Some(position) = snapshot.anchor_in_excerpt(action.range.start) else {
                 continue;
             };
-
-            rows_with_any_lens.insert(MultiBufferRow(position.to_point(snapshot).row));
             if let project::LspAction::CodeLens(lens) = &action.lsp_action {
-                if let Some(title) = lens
+                let title = lens
                     .command
                     .as_ref()
-                    .map(|cmd| SharedString::from(&cmd.title))
-                {
-                    titled_lenses.push((
-                        position,
-                        CodeLensItem {
-                            title,
-                            action: action.clone(),
-                        },
-                    ));
+                    .filter(|cmd| !cmd.title.is_empty())
+                    .map(|cmd| SharedString::from(&cmd.title));
+                if title.is_none() && action.resolved {
+                    continue;
                 }
+                all_lenses.push((
+                    position,
+                    CodeLensItem {
+                        title,
+                        action: action.clone(),
+                    },
+                ));
             }
         }
 
-        let mut new_lines_by_row = group_lenses_by_row(titled_lenses, snapshot)
+        let mut new_lines_by_row = group_lenses_by_row(all_lenses, snapshot)
             .map(|line| (MultiBufferRow(line.position.to_point(snapshot).row), line))
             .collect::<HashMap<_, _>>();
 
@@ -314,15 +307,17 @@ impl Editor {
 
         for old in old_blocks {
             let row = MultiBufferRow(old.anchor.to_point(snapshot).row);
-            if !rows_with_any_lens.contains(&row) {
+            let Some(new_line) = new_lines_by_row.remove(&row) else {
                 blocks_to_remove.insert(old.block_id);
                 continue;
-            }
+            };
             covered_rows.insert(row);
-            let Some(new_line) = new_lines_by_row.remove(&row) else {
+            let new_all_unresolved = new_line.items.iter().all(|item| item.title.is_none());
+            let old_has_resolved = old.line.items.iter().any(|item| item.title.is_some());
+            if new_all_unresolved && old_has_resolved {
                 kept_blocks.push(old);
                 continue;
-            };
+            }
             if rendered_text_matches(&old.line, &new_line) {
                 kept_blocks.push(old);
             } else {
@@ -436,61 +431,72 @@ impl Editor {
             return;
         };
 
-        let resolve_tasks = self
-            .visible_buffer_ranges(cx)
-            .into_iter()
-            .filter_map(|(snapshot, visible_range, _)| {
-                let buffer_id = snapshot.remote_id();
-                let buffer = self.buffer.read(cx).buffer(buffer_id)?;
-                let visible_anchor_range = snapshot.anchor_before(visible_range.start)
-                    ..snapshot.anchor_after(visible_range.end);
-                let task = project.update(cx, |project, cx| {
-                    project.lsp_store().update(cx, |lsp_store, cx| {
-                        lsp_store.resolve_visible_code_lenses(&buffer, visible_anchor_range, cx)
-                    })
+        let lsp_store = project.read(cx).lsp_store();
+
+        let mut pending_resolves = Vec::new();
+        for (buffer_snapshot, visible_range, _) in self.visible_buffer_ranges(cx) {
+            let buffer_id = buffer_snapshot.remote_id();
+            let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
+                continue;
+            };
+            let Some(actions) = self
+                .code_lens
+                .as_ref()
+                .and_then(|state| state.actions.get(&buffer_id))
+            else {
+                continue;
+            };
+            for (lens_id, action) in actions {
+                if action.resolved {
+                    continue;
+                }
+                if let project::LspAction::CodeLens(lens) = &action.lsp_action {
+                    if lens.command.is_some() {
+                        continue;
+                    }
+                }
+                let action_offset = action.range.start.to_offset(&buffer_snapshot);
+                if action_offset < visible_range.start.0 || action_offset > visible_range.end.0 {
+                    continue;
+                }
+                let resolve_task = lsp_store.update(cx, |lsp_store, cx| {
+                    lsp_store.resolve_code_lens(&buffer, action.server_id, *lens_id, cx)
                 });
-                Some((buffer_id, task))
-            })
-            .collect::<Vec<_>>();
-        if resolve_tasks.is_empty() {
+                pending_resolves.push((buffer_id, resolve_task));
+            }
+        }
+        if pending_resolves.is_empty() {
             return;
         }
 
         let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
         code_lens.resolve_task = cx.spawn(async move |editor, cx| {
-            let resolved_per_buffer = join_all(
-                resolve_tasks
-                    .into_iter()
-                    .map(|(buffer_id, task)| async move { (buffer_id, task.await) }),
-            )
-            .await;
-            editor
-                .update(cx, |editor, cx| {
-                    let snapshot = editor.buffer().read(cx).snapshot(cx);
-                    for (buffer_id, newly_resolved) in resolved_per_buffer {
-                        if newly_resolved.is_empty() {
-                            continue;
-                        }
+            let mut resolves_in_progress = pending_resolves
+                .into_iter()
+                .map(|(buffer_id, task)| async move { (buffer_id, task.await) })
+                .collect::<FuturesUnordered<_>>();
+            while let Some((buffer_id, resolve_result)) = resolves_in_progress.next().await {
+                let Some((resolved_id, resolved)) = resolve_result else {
+                    continue;
+                };
+                editor
+                    .update(cx, |editor, cx| {
+                        let snapshot = editor.buffer().read(cx).snapshot(cx);
                         let Some(mut actions) = editor
                             .code_lens
                             .as_ref()
                             .and_then(|state| state.actions.get(&buffer_id))
                             .cloned()
                         else {
-                            continue;
+                            return;
                         };
-                        for resolved in newly_resolved {
-                            if let Some(unresolved) = actions.iter_mut().find(|action| {
-                                action.server_id == resolved.server_id
-                                    && action.range == resolved.range
-                            }) {
-                                *unresolved = resolved;
-                            }
+                        if let Some(slot) = actions.get_mut(&resolved_id) {
+                            *slot = resolved;
                         }
                         editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
-                    }
-                })
-                .ok();
+                    })
+                    .ok();
+            }
         });
     }
 
@@ -551,12 +557,17 @@ fn group_lenses_by_row(
 
 fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> RenderBlock {
     Arc::new(move |cx| {
-        let mut children = Vec::with_capacity((2 * line.items.len()).saturating_sub(1));
+        let resolved_items = line
+            .items
+            .iter()
+            .filter_map(|item| item.title.as_ref().map(|title| (title, &item.action)))
+            .collect::<Vec<_>>();
+        let mut children = Vec::with_capacity((2 * resolved_items.len()).saturating_sub(1));
         let text_style = &cx.editor_style.text;
         let font = text_style.font();
         let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9;
 
-        for (i, item) in line.items.iter().enumerate() {
+        for (i, (title, action)) in resolved_items.iter().enumerate() {
             if i > 0 {
                 children.push(
                     div()
@@ -568,8 +579,8 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> R
                 );
             }
 
-            let title = item.title.clone();
-            let action = item.action.clone();
+            let title = (*title).clone();
+            let action = (*action).clone();
             let position = line.position;
             let editor_handle = editor.clone();
 
@@ -928,6 +939,322 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_code_lens_placeholder_block_before_resolve(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings.code_lens = Some(CodeLens::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_typescript(
+            lsp::ServerCapabilities {
+                code_lens_provider: Some(lsp::CodeLensOptions {
+                    resolve_provider: Some(true),
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut code_lens_request =
+            cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
+                let mut lenses = Vec::new();
+                lenses.push(lsp::CodeLens {
+                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
+                    command: None,
+                    data: Some(serde_json::json!({"id": "lens_1"})),
+                });
+                Ok(Some(lenses))
+            });
+
+        let (resolve_tx, resolve_rx) = futures::channel::oneshot::channel::<()>();
+        let resolve_rx = std::sync::Mutex::new(Some(resolve_rx));
+        cx.lsp
+            .set_request_handler::<lsp::request::CodeLensResolve, _, _>(move |lens, _| {
+                let rx = resolve_rx.lock().unwrap().take();
+                async move {
+                    if let Some(rx) = rx {
+                        rx.await.ok();
+                    }
+                    Ok(lsp::CodeLens {
+                        command: Some(lsp::Command {
+                            title: "1 reference".to_owned(),
+                            command: "resolved_cmd".to_owned(),
+                            arguments: None,
+                        }),
+                        ..lens
+                    })
+                }
+            });
+
+        cx.set_state("ˇfunction hello() {}");
+
+        assert!(
+            code_lens_request.next().await.is_some(),
+            "should have received the initial code lens request"
+        );
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, _| {
+            let total_blocks: usize = editor
+                .code_lens
+                .as_ref()
+                .map(|s| s.blocks.values().map(|v| v.len()).sum())
+                .unwrap_or(0);
+            assert_eq!(
+                total_blocks, 1,
+                "a placeholder block should be reserved before the resolve completes"
+            );
+        });
+
+        resolve_tx.send(()).ok();
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, _| {
+            let total_blocks: usize = editor
+                .code_lens
+                .as_ref()
+                .map(|s| s.blocks.values().map(|v| v.len()).sum())
+                .unwrap_or(0);
+            assert_eq!(
+                total_blocks, 1,
+                "the placeholder block should still be present after resolution"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_code_lens_block_removed_when_resolve_yields_empty_title(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings.code_lens = Some(CodeLens::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_typescript(
+            lsp::ServerCapabilities {
+                code_lens_provider: Some(lsp::CodeLensOptions {
+                    resolve_provider: Some(true),
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut code_lens_request =
+            cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
+                let mut lenses = Vec::new();
+                lenses.push(lsp::CodeLens {
+                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
+                    command: None,
+                    data: Some(serde_json::json!({"id": "lens_1"})),
+                });
+                Ok(Some(lenses))
+            });
+
+        cx.lsp
+            .set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
+                Ok(lsp::CodeLens {
+                    command: Some(lsp::Command {
+                        title: String::new(),
+                        command: "noop".to_owned(),
+                        arguments: None,
+                    }),
+                    ..lens
+                })
+            });
+
+        cx.set_state("ˇfunction hello() {}");
+
+        assert!(
+            code_lens_request.next().await.is_some(),
+            "should have received the initial code lens request"
+        );
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, _| {
+            let total_blocks: usize = editor
+                .code_lens
+                .as_ref()
+                .map(|s| s.blocks.values().map(|v| v.len()).sum())
+                .unwrap_or(0);
+            assert_eq!(
+                total_blocks, 0,
+                "placeholder block should be cleaned up when its lens resolves to a blank title"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_code_lens_same_range_lenses_resolve_independently(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings.code_lens = Some(CodeLens::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_typescript(
+            lsp::ServerCapabilities {
+                code_lens_provider: Some(lsp::CodeLensOptions {
+                    resolve_provider: Some(true),
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        // Two shallow lenses on the same range, distinguished only by `data`
+        // — exactly the shape vtsls/TypeScript-LS uses for the
+        // "references" + "implementations" pair on the same line.
+        let mut code_lens_request =
+            cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
+                Ok(Some(vec![
+                    lsp::CodeLens {
+                        range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
+                        command: None,
+                        data: Some(serde_json::json!({"kind": "references"})),
+                    },
+                    lsp::CodeLens {
+                        range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
+                        command: None,
+                        data: Some(serde_json::json!({"kind": "implementations"})),
+                    },
+                ]))
+            });
+
+        let resolve_calls = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
+        cx.lsp
+            .set_request_handler::<lsp::request::CodeLensResolve, _, _>({
+                let resolve_calls = resolve_calls.clone();
+                move |lens, _| {
+                    let resolve_calls = resolve_calls.clone();
+                    async move {
+                        let kind = lens
+                            .data
+                            .as_ref()
+                            .and_then(|d| d.get("kind"))
+                            .cloned()
+                            .unwrap_or(serde_json::Value::Null);
+                        resolve_calls.lock().unwrap().push(kind.clone());
+                        let title = match kind.as_str() {
+                            Some("references") => "2 references",
+                            Some("implementations") => "1 implementation",
+                            _ => "",
+                        };
+                        Ok(lsp::CodeLens {
+                            command: Some(lsp::Command {
+                                title: title.to_owned(),
+                                command: "noop".to_owned(),
+                                arguments: None,
+                            }),
+                            ..lens
+                        })
+                    }
+                }
+            });
+
+        cx.set_state("ˇfunction hello() {}");
+
+        assert!(
+            code_lens_request.next().await.is_some(),
+            "should have received the initial code lens request"
+        );
+        cx.run_until_parked();
+
+        let calls = resolve_calls.lock().unwrap().clone();
+        assert_eq!(
+            calls.len(),
+            2,
+            "both same-range lenses should be resolved independently, got {calls:?}"
+        );
+        let kinds: Vec<&str> = calls.iter().filter_map(|v| v.as_str()).collect();
+        assert_eq!(kinds.contains(&"references"), true);
+        assert_eq!(kinds.contains(&"implementations"), true);
+
+        cx.editor.read_with(&cx.cx.cx, |editor, _| {
+            let blocks = editor
+                .code_lens
+                .as_ref()
+                .map(|s| s.blocks.values().flatten().collect::<Vec<_>>())
+                .unwrap_or_default();
+            assert_eq!(
+                blocks.len(),
+                1,
+                "a single block should host both lens items"
+            );
+            let titles: Vec<String> = blocks[0]
+                .line
+                .items
+                .iter()
+                .filter_map(|item| item.title.as_ref().map(|t| t.to_string()))
+                .collect();
+            assert_eq!(titles.len(), 2, "both lens titles should be resolved");
+            assert_eq!(titles.contains(&"2 references".to_string()), true);
+            assert_eq!(titles.contains(&"1 implementation".to_string()), true);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_code_lens_block_removed_when_resolve_yields_no_command(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        update_test_editor_settings(cx, &|settings| {
+            settings.code_lens = Some(CodeLens::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_typescript(
+            lsp::ServerCapabilities {
+                code_lens_provider: Some(lsp::CodeLensOptions {
+                    resolve_provider: Some(true),
+                }),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut code_lens_request =
+            cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
+                Ok(Some(vec![lsp::CodeLens {
+                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
+                    command: None,
+                    data: Some(serde_json::json!({"id": "lens_1"})),
+                }]))
+            });
+
+        // Server acknowledges the resolve but still returns no `command` —
+        // a real-world scenario for buggy/incomplete servers. Without
+        // cleanup the placeholder line would be reserved forever because
+        // `resolve_visible_code_lenses` skips actions with `resolved=true`.
+        cx.lsp
+            .set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
+                Ok(lsp::CodeLens {
+                    command: None,
+                    ..lens
+                })
+            });
+
+        cx.set_state("ˇfunction hello() {}");
+
+        assert!(
+            code_lens_request.next().await.is_some(),
+            "should have received the initial code lens request"
+        );
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, _| {
+            let total_blocks: usize = editor
+                .code_lens
+                .as_ref()
+                .map(|s| s.blocks.values().map(|v| v.len()).sum())
+                .unwrap_or(0);
+            assert_eq!(
+                total_blocks, 0,
+                "placeholder block should be cleaned up when resolve yields no command"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) {
         init_test(cx, |_| {});
@@ -1254,9 +1581,14 @@ mod tests {
             .unwrap()
             .drain(..)
             .collect::<HashSet<_>>();
+        // Once the lenses are first applied we insert a placeholder block per
+        // lens row so the line is reserved while the resolve is in flight.
+        // Those placeholder blocks add display height, so after scrolling to
+        // the end the visible buffer-row range is slightly smaller than it
+        // would be without them, and lens row 60 is just outside it.
         assert_eq!(
             after_scroll_resolved,
-            HashSet::from_iter([60, 70, 80, 90]),
+            HashSet::from_iter([70, 80, 90]),
             "Only newly visible lenses at the bottom should be resolved, not middle ones"
         );
     }

crates/project/src/lsp_store.rs 🔗

@@ -10,7 +10,7 @@
 //!
 //! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate.
 pub mod clangd_ext;
-mod code_lens;
+pub mod code_lens;
 mod document_colors;
 mod document_symbols;
 mod folding_ranges;

crates/project/src/lsp_store/code_lens.rs 🔗

@@ -9,7 +9,7 @@ use futures::{
     future::{Shared, join_all},
 };
 use gpui::{AppContext as _, AsyncApp, Context, Entity, Task};
-use language::{Anchor, Buffer, ToOffset as _};
+use language::{Anchor, Buffer};
 use lsp::LanguageServerId;
 use rpc::{TypedEnvelope, proto};
 use settings::Settings as _;
@@ -22,19 +22,52 @@ use crate::{
     project_settings::ProjectSettings,
 };
 
+/// Opaque per-action identifier issued by [`LspStore`] at fetch time.
+///
+/// LSP `CodeLens.data` is the server's private payload for resolve
+/// round-trips, so we can't use it (or anything derived from it) to
+/// disambiguate sibling lenses that share the same buffer `range`
+/// (TypeScript's references + implementations is the canonical case).
+/// We tag every cached action with this id and require it back on resolve
+/// so each lens routes to its own request and slot.
+///
+/// Ids are issued in fetch order; sorting by id reproduces server-emit
+/// order, which is how callers recover a stable render order without
+/// paying for an ordered map.
+#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)]
+pub struct CodeLensActionId(u64);
+
+pub type CodeLensActions = HashMap<CodeLensActionId, CodeAction>;
+
 pub(super) type CodeLensTask =
-    Shared<Task<std::result::Result<Option<Vec<CodeAction>>, Arc<anyhow::Error>>>>;
+    Shared<Task<std::result::Result<Option<CodeLensActions>, Arc<anyhow::Error>>>>;
+
+pub type CodeLensResolveTask = Shared<Task<Option<(CodeLensActionId, CodeAction)>>>;
 
 #[derive(Debug, Default)]
 pub(super) struct CodeLensData {
-    pub(super) lens: HashMap<LanguageServerId, Vec<CodeAction>>,
+    pub(super) lens: HashMap<LanguageServerId, CodeLensActions>,
+    pub(super) next_id: u64,
     pub(super) update: Option<(Global, CodeLensTask)>,
+    pub(super) resolving: HashMap<(LanguageServerId, CodeLensActionId), CodeLensResolveTask>,
 }
 
 impl CodeLensData {
     pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) {
         self.lens.remove(&server_id);
+        self.resolving.retain(|(s, _), _| *s != server_id);
+    }
+}
+
+fn flatten_cache(lens: &HashMap<LanguageServerId, CodeLensActions>) -> CodeLensActions {
+    let mut out = CodeLensActions::default();
+    out.reserve(lens.values().map(|per_server| per_server.len()).sum());
+    for per_server in lens.values() {
+        for (id, action) in per_server {
+            out.insert(*id, action.clone());
+        }
     }
+    out
 }
 
 impl LspStore {
@@ -44,15 +77,14 @@ impl LspStore {
         }
     }
 
-    /// Fetches and returns all code lenses for the buffer.
-    ///
-    /// Resolution of individual lenses is the caller's responsibility; see
-    /// [`LspStore::resolve_visible_code_lenses`].
+    /// Fetches all code lenses for the buffer, each tagged with the
+    /// [`CodeLensActionId`] that callers must pass back to
+    /// [`Self::resolve_code_lens`]. Resolution is the caller's job.
     pub fn code_lens_actions(
         &mut self,
         buffer: &Entity<Buffer>,
         cx: &mut Context<Self>,
-    ) -> Task<Result<Option<Vec<CodeAction>>>> {
+    ) -> Task<Result<Option<CodeLensActions>>> {
         let buffer_id = buffer.read(cx).remote_id();
         let fetch_task = self.fetch_code_lenses(buffer, cx);
 
@@ -66,7 +98,7 @@ impl LspStore {
                     .lsp_data
                     .get(&buffer_id)
                     .and_then(|data| data.code_lens.as_ref())
-                    .map(|code_lens| code_lens.lens.values().flatten().cloned().collect())
+                    .map(|code_lens| flatten_cache(&code_lens.lens))
             })?;
             Ok(actions)
         })
@@ -94,10 +126,7 @@ impl LspStore {
                         existing_servers != cached_lens.lens.keys().copied().collect()
                     });
                     if !has_different_servers {
-                        return Task::ready(Ok(Some(
-                            cached_lens.lens.values().flatten().cloned().collect(),
-                        )))
-                        .shared();
+                        return Task::ready(Ok(Some(flatten_cache(&cached_lens.lens)))).shared();
                     }
                 } else if let Some((updating_for, running_update)) = cached_lens.update.as_ref() {
                     if !version_queried_for.changed_since(updating_for) {
@@ -145,27 +174,34 @@ impl LspStore {
                 };
 
                 lsp_store
-                    .update(cx, |lsp_store, cx| {
+                    .update(cx, |lsp_store, _| {
                         let lsp_data = lsp_store.current_lsp_data(buffer_id)?;
                         let code_lens = lsp_data.code_lens.as_mut()?;
                         if let Some(fetched_lens) = fetched_lens {
+                            let mut tagged: HashMap<LanguageServerId, CodeLensActions> =
+                                HashMap::default();
+                            for (server_id, actions) in fetched_lens {
+                                let mut cache = CodeLensActions::default();
+                                cache.reserve(actions.len());
+                                for action in actions {
+                                    let id = CodeLensActionId(code_lens.next_id);
+                                    code_lens.next_id += 1;
+                                    cache.insert(id, action);
+                                }
+                                tagged.insert(server_id, cache);
+                            }
                             if lsp_data.buffer_version == query_version_queried_for {
-                                code_lens.lens.extend(fetched_lens);
+                                code_lens.lens.extend(tagged);
                             } else if !lsp_data
                                 .buffer_version
                                 .changed_since(&query_version_queried_for)
                             {
                                 lsp_data.buffer_version = query_version_queried_for;
-                                code_lens.lens = fetched_lens;
-                            }
-                            let snapshot = buffer.read(cx).snapshot();
-                            for actions in code_lens.lens.values_mut() {
-                                actions
-                                    .sort_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot));
+                                code_lens.lens = tagged;
                             }
                         }
                         code_lens.update = None;
-                        Some(code_lens.lens.values().flatten().cloned().collect())
+                        Some(flatten_cache(&code_lens.lens))
                     })
                     .map_err(Arc::new)
             })
@@ -245,110 +281,107 @@ impl LspStore {
         }
     }
 
-    pub fn resolve_visible_code_lenses(
+    /// Resolves a single code lens via `codeLens/resolve`, identified by
+    /// the [`CodeLensActionId`] returned from [`Self::code_lens_actions`].
+    /// The returned task is shared and cached on [`CodeLensData::resolving`]
+    /// keyed by `(server, lens_id)`, so concurrent callers awaiting the
+    /// same lens only drive a single LSP request.
+    ///
+    /// `None` is yielded when the lens cannot be resolved (id no longer
+    /// cached, server gone, no `resolveProvider`, request failure, etc.).
+    /// On success, the cached entry is updated in place before the
+    /// `(id, resolved_action)` pair is returned.
+    ///
+    /// All visibility / batching policy lives in the caller. Remote (proto)
+    /// resolves are not yet supported and currently yield `None`.
+    pub fn resolve_code_lens(
         &mut self,
         buffer: &Entity<Buffer>,
-        visible_range: Range<Anchor>,
+        server_id: LanguageServerId,
+        lens_id: CodeLensActionId,
         cx: &mut Context<Self>,
-    ) -> Task<Vec<CodeAction>> {
+    ) -> CodeLensResolveTask {
         let buffer_id = buffer.read(cx).remote_id();
-        let snapshot = buffer.read(cx).snapshot();
-        let visible_start = visible_range.start.to_offset(&snapshot);
-        let visible_end = visible_range.end.to_offset(&snapshot);
 
         let Some(code_lens) = self
             .lsp_data
-            .get(&buffer_id)
-            .and_then(|data| data.code_lens.as_ref())
+            .get_mut(&buffer_id)
+            .and_then(|data| data.code_lens.as_mut())
         else {
-            return Task::ready(Vec::new());
+            return Task::ready(None).shared();
         };
-
-        let capable_servers = code_lens
-            .lens
-            .keys()
-            .filter_map(|server_id| {
-                let server = self.language_server_for_id(*server_id)?;
-                GetCodeLens::can_resolve_lens(&server.capabilities())
-                    .then_some((*server_id, server))
-            })
-            .collect::<HashMap<_, _>>();
-        if capable_servers.is_empty() {
-            return Task::ready(Vec::new());
+        let key = (server_id, lens_id);
+        if let Some(existing) = code_lens.resolving.get(&key) {
+            return existing.clone();
         }
-
-        let to_resolve = code_lens
+        let Some(cached) = code_lens
             .lens
-            .iter()
-            .flat_map(|(server_id, actions)| {
-                let start_idx =
-                    actions.partition_point(|a| a.range.start.to_offset(&snapshot) < visible_start);
-                let end_idx = start_idx
-                    + actions[start_idx..]
-                        .partition_point(|a| a.range.start.to_offset(&snapshot) <= visible_end);
-                actions[start_idx..end_idx].iter().enumerate().filter_map(
-                    move |(local_idx, action)| {
-                        let LspAction::CodeLens(lens) = &action.lsp_action else {
-                            return None;
-                        };
-                        if lens.command.is_some() {
-                            return None;
-                        }
-                        Some((*server_id, start_idx + local_idx, lens.clone()))
-                    },
-                )
-            })
-            .collect::<Vec<_>>();
-        if to_resolve.is_empty() {
-            return Task::ready(Vec::new());
+            .get(&server_id)
+            .and_then(|cache| cache.get(&lens_id))
+        else {
+            return Task::ready(None).shared();
+        };
+        if cached.resolved {
+            return Task::ready(Some((lens_id, cached.clone()))).shared();
         }
+        let LspAction::CodeLens(lens) = &cached.lsp_action else {
+            return Task::ready(None).shared();
+        };
+        let lens = lens.clone();
 
+        let Some(server) = self.language_server_for_id(server_id) else {
+            return Task::ready(None).shared();
+        };
+        if !GetCodeLens::can_resolve_lens(&server.capabilities()) {
+            return Task::ready(None).shared();
+        }
         let request_timeout = ProjectSettings::get_global(cx)
             .global_lsp_settings
             .get_request_timeout();
 
-        cx.spawn(async move |lsp_store, cx| {
-            let mut resolved = Vec::new();
-            for (server_id, index, lens) in to_resolve {
-                let Some(server) = capable_servers.get(&server_id) else {
-                    continue;
-                };
-                match server
-                    .request::<lsp::request::CodeLensResolve>(lens, request_timeout)
-                    .await
-                    .into_response()
-                {
-                    Ok(resolved_lens) => resolved.push((server_id, index, resolved_lens)),
-                    Err(e) => log::warn!("Failed to resolve code lens: {e:#}"),
+        let task = cx
+            .spawn({
+                async move |lsp_store, cx| {
+                    let response = server
+                        .request::<lsp::request::CodeLensResolve>(lens, request_timeout)
+                        .await
+                        .into_response();
+                    lsp_store
+                        .update(cx, |lsp_store, _| {
+                            let code_lens = lsp_store
+                                .lsp_data
+                                .get_mut(&buffer_id)
+                                .and_then(|data| data.code_lens.as_mut())?;
+                            code_lens.resolving.remove(&key);
+                            let resolved_lens = match response {
+                                Ok(resolved_lens) => resolved_lens,
+                                Err(e) => {
+                                    log::warn!("Failed to resolve code lens: {e:#}");
+                                    return None;
+                                }
+                            };
+                            let action = code_lens
+                                .lens
+                                .get_mut(&server_id)
+                                .and_then(|cache| cache.get_mut(&lens_id))?;
+                            action.resolved = true;
+                            action.lsp_action = LspAction::CodeLens(resolved_lens);
+                            Some((lens_id, action.clone()))
+                        })
+                        .ok()
+                        .flatten()
                 }
-            }
-            if resolved.is_empty() {
-                return Vec::new();
-            }
+            })
+            .shared();
 
-            lsp_store
-                .update(cx, |lsp_store, _| {
-                    let Some(code_lens) = lsp_store
-                        .lsp_data
-                        .get_mut(&buffer_id)
-                        .and_then(|data| data.code_lens.as_mut())
-                    else {
-                        return Vec::new();
-                    };
-                    let mut newly_resolved = Vec::new();
-                    for (server_id, index, resolved_lens) in resolved {
-                        if let Some(actions) = code_lens.lens.get_mut(&server_id) {
-                            if let Some(action) = actions.get_mut(index) {
-                                action.resolved = true;
-                                action.lsp_action = LspAction::CodeLens(resolved_lens);
-                                newly_resolved.push(action.clone());
-                            }
-                        }
-                    }
-                    newly_resolved
-                })
-                .unwrap_or_default()
-        })
+        if let Some(code_lens) = self
+            .lsp_data
+            .get_mut(&buffer_id)
+            .and_then(|data| data.code_lens.as_mut())
+        {
+            code_lens.resolving.insert(key, task.clone());
+        }
+        task
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -398,26 +431,32 @@ impl Project {
             lsp_store.update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx));
         let buffer = buffer.clone();
         cx.spawn(async move |_, cx| {
-            let mut actions = fetch_task.await?;
-            if let Some(actions) = &mut actions {
-                let resolve_task = lsp_store.update(cx, |lsp_store, cx| {
-                    lsp_store.resolve_visible_code_lenses(&buffer, range.clone(), cx)
-                });
-                let resolved = resolve_task.await;
-                for resolved_action in resolved {
-                    if let Some(action) = actions.iter_mut().find(|a| {
-                        a.server_id == resolved_action.server_id && a.range == resolved_action.range
-                    }) {
-                        *action = resolved_action;
-                    }
+            let Some(mut tagged) = fetch_task.await? else {
+                return Ok(None);
+            };
+            let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+            tagged.retain(|_, action| {
+                range.start.cmp(&action.range.start, &snapshot).is_ge()
+                    && range.end.cmp(&action.range.end, &snapshot).is_le()
+            });
+            let resolve_tasks = lsp_store.update(cx, |lsp_store, cx| {
+                tagged
+                    .iter()
+                    .filter(|(_, action)| !action.resolved)
+                    .map(|(id, action)| {
+                        lsp_store.resolve_code_lens(&buffer, action.server_id, *id, cx)
+                    })
+                    .collect::<Vec<_>>()
+            });
+            for (resolved_id, resolved) in join_all(resolve_tasks).await.into_iter().flatten() {
+                if let Some(slot) = tagged.get_mut(&resolved_id) {
+                    *slot = resolved;
                 }
-                let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
-                actions.retain(|action| {
-                    range.start.cmp(&action.range.start, &snapshot).is_ge()
-                        && range.end.cmp(&action.range.end, &snapshot).is_le()
-                });
             }
-            Ok(actions)
+            // Sort by id to recover server-emit order at the menu boundary.
+            let mut entries: Vec<_> = tagged.into_iter().collect();
+            entries.sort_by_key(|(id, _)| *id);
+            Ok(Some(entries.into_iter().map(|(_, a)| a).collect()))
         })
     }
 }