Files
zed/crates/language_selector/src/language_selector.rs
T
2a15bf630d Require multibuffer excerpts to be ordered and nonoverlapping (#52364)
TODO:
- [x] merge main
- [x] nonshrinking `set_excerpts_for_path`
- [x] Test-drive potential problem areas in the app
- [x] prepare cloud side
- [x] test collaboration
- [ ] docstrings
- [ ] ???

## Context

### Background

Currently, a multibuffer consists of an arbitrary list of
anchor-delimited excerpts from individual buffers. Excerpt ranges for a
fixed buffer are permitted to overlap, and can appear in any order in
the multibuffer, possibly separated by excerpts from other buffers.
However, in practice all code that constructs multibuffers does so using
the APIs defined in the `path_key` submodule of the `multi_buffer` crate
(`set_excerpts_for_path` etc.) If you only use these APIs, the resulting
multibuffer will maintain the following invariants:

- All excerpts for the same buffer appear contiguously in the
multibuffer
- Excerpts for the same buffer cannot overlap
- Excerpts for the same buffer appear in order
- The placement of the excerpts for a specific buffer in the multibuffer
are determined by the `PathKey` passed to `set_excerpts_for_path`. There
is exactly one `PathKey` per buffer in the multibuffer

### Purpose of this PR

This PR changes the multibuffer so that the invariants maintained by the
`path_key` APIs *always* hold. It's no longer possible to construct a
multibuffer with overlapping excerpts, etc. The APIs that permitted
this, like `insert_excerpts_with_ids_after`, have been removed in favor
of the `path_key` suite.

The main upshot of this is that given a `text::Anchor` and a
multibuffer, it's possible to efficiently figure out the unique excerpt
that includes that anchor, if any:

```
impl MultiBufferSnapshot {
    fn buffer_anchor_to_anchor(&self, anchor: text::Anchor) -> Option<multi_buffer::Anchor>;
}
```

And in the other direction, given a `multi_buffer::Anchor`, we can look
at its `text::Anchor` to locate the excerpt that contains it. That means
we don't need an `ExcerptId` to create or resolve
`multi_buffer::Anchor`, and in fact we can delete `ExcerptId` entirely,
so that excerpts no longer have any identity outside their
`Range<text::Anchor>`.

There are a large number of changes to `editor` and other downstream
crates as a result of removing `ExcerptId` and multibuffer APIs that
assumed it.

### Other changes

There are some other improvements that are not immediate consequences of
that big change, but helped make it smoother. Notably:

- The `buffer_id` field of `text::Anchor` is no longer optional.
`text::Anchor::{MIN, MAX}` have been removed in favor of
`min_for_buffer`, etc.
- `multi_buffer::Anchor` is now a three-variant enum (inlined slightly):

```
enum Anchor {
    Min,
    Excerpt {
        text_anchor: text::Anchor,
        path_key_index: PathKeyIndex,
        diff_base_anchor: Option<text::Anchor>,
    },
    Max,
}
```

That means it's no longer possible to unconditionally access the
`text_anchor` field, which is good because most of the places that were
doing that were buggy for min/max! Instead, we have a new API that
correctly resolves min/max to the start of the first excerpt or the end
of the last excerpt:


```
impl MultiBufferSnapshot {
    fn anchor_to_buffer_anchor(&self, anchor: multi_buffer::Anchor) -> Option<text::Anchor>;
}
```
- `MultiBufferExcerpt` has been removed in favor of a new
`map_excerpt_ranges` API directly on `MultiBufferSnapshot`.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
Co-authored-by: Conrad <conrad@zed.dev>
2026-04-01 17:25:32 +00:00

673 lines
23 KiB
Rust

mod active_buffer_language;
pub use active_buffer_language::ActiveBufferLanguage;
use anyhow::Context as _;
use editor::Editor;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement,
Render, Styled, WeakEntity, Window, actions,
};
use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry};
use open_path_prompt::file_finder_settings::FileFinderSettings;
use picker::{Picker, PickerDelegate};
use project::Project;
use settings::Settings;
use std::{ops::Not as _, path::Path, sync::Arc};
use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
use workspace::{ModalView, Workspace};
actions!(
language_selector,
[
/// Toggles the language selector modal.
Toggle
]
);
pub fn init(cx: &mut App) {
cx.observe_new(LanguageSelector::register).detach();
}
pub struct LanguageSelector {
picker: Entity<Picker<LanguageSelectorDelegate>>,
}
impl LanguageSelector {
fn register(
workspace: &mut Workspace,
_window: Option<&mut Window>,
_: &mut Context<Workspace>,
) {
workspace.register_action(move |workspace, _: &Toggle, window, cx| {
Self::toggle(workspace, window, cx);
});
}
fn toggle(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Option<()> {
let registry = workspace.app_state().languages.clone();
let buffer = workspace
.active_item(cx)?
.act_as::<Editor>(cx)?
.read(cx)
.active_buffer(cx)?;
let project = workspace.project().clone();
workspace.toggle_modal(window, cx, move |window, cx| {
LanguageSelector::new(buffer, project, registry, window, cx)
});
Some(())
}
fn new(
buffer: Entity<Buffer>,
project: Entity<Project>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let current_language_name = buffer
.read(cx)
.language()
.map(|language| language.name().as_ref().to_string());
let delegate = LanguageSelectorDelegate::new(
cx.entity().downgrade(),
buffer,
project,
language_registry,
current_language_name,
);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
Self { picker }
}
}
impl Render for LanguageSelector {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("LanguageSelector")
.w(rems(34.))
.child(self.picker.clone())
}
}
impl Focusable for LanguageSelector {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for LanguageSelector {}
impl ModalView for LanguageSelector {}
pub struct LanguageSelectorDelegate {
language_selector: WeakEntity<LanguageSelector>,
buffer: Entity<Buffer>,
project: Entity<Project>,
language_registry: Arc<LanguageRegistry>,
candidates: Vec<StringMatchCandidate>,
matches: Vec<StringMatch>,
selected_index: usize,
current_language_candidate_index: Option<usize>,
}
impl LanguageSelectorDelegate {
fn new(
language_selector: WeakEntity<LanguageSelector>,
buffer: Entity<Buffer>,
project: Entity<Project>,
language_registry: Arc<LanguageRegistry>,
current_language_name: Option<String>,
) -> Self {
let candidates = language_registry
.language_names()
.into_iter()
.filter_map(|name| {
language_registry
.available_language_for_name(name.as_ref())?
.hidden()
.not()
.then_some(name)
})
.enumerate()
.map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name.as_ref()))
.collect::<Vec<_>>();
let current_language_candidate_index = current_language_name.as_ref().and_then(|name| {
candidates
.iter()
.position(|candidate| candidate.string == *name)
});
Self {
language_selector,
buffer,
project,
language_registry,
candidates,
matches: vec![],
selected_index: current_language_candidate_index.unwrap_or(0),
current_language_candidate_index,
}
}
fn language_data_for_match(&self, mat: &StringMatch, cx: &App) -> (String, Option<Icon>) {
let mut label = mat.string.clone();
let buffer_language = self.buffer.read(cx).language();
let need_icon = FileFinderSettings::get_global(cx).file_icons;
if let Some(buffer_language) = buffer_language
.filter(|buffer_language| buffer_language.name().as_ref() == mat.string.as_str())
{
label.push_str(" (current)");
let icon = need_icon
.then(|| self.language_icon(&buffer_language.config().matcher, cx))
.flatten();
(label, icon)
} else {
let icon = need_icon
.then(|| {
let language_name = LanguageName::new(mat.string.as_str());
self.language_registry
.available_language_for_name(language_name.as_ref())
.and_then(|available_language| {
self.language_icon(available_language.matcher(), cx)
})
})
.flatten();
(label, icon)
}
}
fn language_icon(&self, matcher: &LanguageMatcher, cx: &App) -> Option<Icon> {
matcher
.path_suffixes
.iter()
.find_map(|extension| file_icons::FileIcons::get_icon(Path::new(extension), cx))
.map(Icon::from_path)
.map(|icon| icon.color(Color::Muted))
}
}
impl PickerDelegate for LanguageSelectorDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a language…".into()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(mat) = self.matches.get(self.selected_index) {
let language_name = &self.candidates[mat.candidate_id].string;
let language = self.language_registry.language_for_name(language_name);
let project = self.project.downgrade();
let buffer = self.buffer.downgrade();
cx.spawn_in(window, async move |_, cx| {
let language = language.await?;
let project = project.upgrade().context("project was dropped")?;
let buffer = buffer.upgrade().context("buffer was dropped")?;
project.update(cx, |project, cx| {
project.set_language_for_buffer(&buffer, language, cx);
});
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
self.dismissed(window, cx);
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.language_selector
.update(cx, |_, cx| cx.emit(DismissEvent))
.log_err();
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> gpui::Task<()> {
let background = cx.background_executor().clone();
let candidates = self.candidates.clone();
let query_is_empty = query.is_empty();
cx.spawn_in(window, async move |this, cx| {
let matches = if query_is_empty {
candidates
.into_iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
match_strings(
&candidates,
&query,
false,
true,
100,
&Default::default(),
background,
)
.await
};
this.update_in(cx, |this, window, cx| {
if matches.is_empty() {
this.delegate.matches = matches;
this.delegate.selected_index = 0;
cx.notify();
return;
}
let selected_index = if query_is_empty {
this.delegate
.current_language_candidate_index
.and_then(|current_language_candidate_index| {
matches.iter().position(|mat| {
mat.candidate_id == current_language_candidate_index
})
})
.unwrap_or(0)
} else {
0
};
this.delegate.matches = matches;
this.set_selected_index(selected_index, None, false, window, cx);
cx.notify();
})
.log_err();
})
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let mat = &self.matches.get(ix)?;
let (label, language_icon) = self.language_data_for_match(mat, cx);
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.start_slot::<Icon>(language_icon)
.child(HighlightedLabel::new(label, mat.positions.clone())),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use editor::Editor;
use gpui::{TestAppContext, VisualTestContext};
use language::{Language, LanguageConfig};
use project::{Project, ProjectPath};
use serde_json::json;
use std::sync::Arc;
use util::{path, rel_path::rel_path};
use workspace::{AppState, MultiWorkspace, Workspace};
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let app_state = AppState::test(cx);
settings::init(cx);
super::init(cx);
editor::init(cx);
app_state
})
}
fn register_test_languages(project: &Entity<Project>, cx: &mut VisualTestContext) {
project.read_with(cx, |project, _| {
let language_registry = project.languages();
for (language_name, path_suffix) in [
("C", "c"),
("Go", "go"),
("Ruby", "rb"),
("Rust", "rs"),
("TypeScript", "ts"),
] {
language_registry.add(Arc::new(Language::new(
LanguageConfig {
name: language_name.into(),
matcher: LanguageMatcher {
path_suffixes: vec![path_suffix.to_string()],
..Default::default()
},
..Default::default()
},
None,
)));
}
});
}
async fn open_file_editor(
workspace: &Entity<Workspace>,
project: &Entity<Project>,
file_path: &str,
cx: &mut VisualTestContext,
) -> Entity<Editor> {
let worktree_id = project.update(cx, |project, cx| {
project
.worktrees(cx)
.next()
.expect("project should have a worktree")
.read(cx)
.id()
});
let project_path = ProjectPath {
worktree_id,
path: rel_path(file_path).into(),
};
let opened_item = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_path(project_path, None, true, window, cx)
})
.await
.expect("file should open");
cx.update(|_, cx| {
opened_item
.act_as::<Editor>(cx)
.expect("opened item should be an editor")
})
}
async fn open_empty_editor(
workspace: &Entity<Workspace>,
project: &Entity<Project>,
cx: &mut VisualTestContext,
) -> Entity<Editor> {
let editor = open_new_buffer_editor(workspace, project, cx).await;
// Ensure the buffer has no language after the editor is created
let buffer = editor.read_with(cx, |editor, cx| {
editor
.active_buffer(cx)
.expect("editor should have an active buffer")
});
buffer.update(cx, |buffer, cx| {
buffer.set_language(None, cx);
});
editor
}
async fn open_new_buffer_editor(
workspace: &Entity<Workspace>,
project: &Entity<Project>,
cx: &mut VisualTestContext,
) -> Entity<Editor> {
let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx));
let buffer = create_buffer.await.expect("empty buffer should be created");
let editor = cx.new_window_entity(|window, cx| {
Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_center(Box::new(editor.clone()), window, cx);
});
editor
}
async fn set_editor_language(
project: &Entity<Project>,
editor: &Entity<Editor>,
language_name: &str,
cx: &mut VisualTestContext,
) {
let language = project
.read_with(cx, |project, _| {
project.languages().language_for_name(language_name)
})
.await
.expect("language should exist in registry");
editor.update(cx, move |editor, cx| {
let buffer = editor
.active_buffer(cx)
.expect("editor should have an active excerpt");
buffer.update(cx, |buffer, cx| {
buffer.set_language(Some(language), cx);
});
});
}
fn active_picker(
workspace: &Entity<Workspace>,
cx: &mut VisualTestContext,
) -> Entity<Picker<LanguageSelectorDelegate>> {
workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<LanguageSelector>(cx)
.expect("language selector should be open")
.read(cx)
.picker
.clone()
})
}
fn open_selector(
workspace: &Entity<Workspace>,
cx: &mut VisualTestContext,
) -> Entity<Picker<LanguageSelectorDelegate>> {
cx.dispatch_action(Toggle);
cx.run_until_parked();
active_picker(workspace, cx)
}
fn close_selector(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
cx.dispatch_action(Toggle);
cx.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
assert!(
workspace.active_modal::<LanguageSelector>(cx).is_none(),
"language selector should be closed"
);
});
}
fn assert_selected_language_for_editor(
workspace: &Entity<Workspace>,
editor: &Entity<Editor>,
expected_language_name: Option<&str>,
cx: &mut VisualTestContext,
) {
workspace.update_in(cx, |workspace, window, cx| {
let was_activated = workspace.activate_item(editor, true, true, window, cx);
assert!(
was_activated,
"editor should be activated before opening the modal"
);
});
cx.run_until_parked();
let picker = open_selector(workspace, cx);
picker.read_with(cx, |picker, _| {
let selected_match = picker
.delegate
.matches
.get(picker.delegate.selected_index)
.expect("selected index should point to a match");
let selected_candidate = picker
.delegate
.candidates
.get(selected_match.candidate_id)
.expect("selected match should map to a candidate");
if let Some(expected_language_name) = expected_language_name {
let current_language_candidate_index = picker
.delegate
.current_language_candidate_index
.expect("current language should map to a candidate");
assert_eq!(
selected_match.candidate_id,
current_language_candidate_index
);
assert_eq!(selected_candidate.string, expected_language_name);
} else {
assert!(picker.delegate.current_language_candidate_index.is_none());
assert_eq!(picker.delegate.selected_index, 0);
}
});
close_selector(workspace, cx);
}
#[gpui::test]
async fn test_language_selector_selects_current_language_per_active_editor(
cx: &mut TestAppContext,
) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/test"),
json!({
"rust_file.rs": "fn main() {}\n",
"typescript_file.ts": "const value = 1;\n",
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace =
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
register_test_languages(&project, cx);
let rust_editor = open_file_editor(&workspace, &project, "rust_file.rs", cx).await;
let typescript_editor =
open_file_editor(&workspace, &project, "typescript_file.ts", cx).await;
let empty_editor = open_empty_editor(&workspace, &project, cx).await;
set_editor_language(&project, &rust_editor, "Rust", cx).await;
set_editor_language(&project, &typescript_editor, "TypeScript", cx).await;
cx.run_until_parked();
assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx);
assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx);
// Ensure the empty editor's buffer has no language before asserting
let buffer = empty_editor.read_with(cx, |editor, cx| {
editor
.active_buffer(cx)
.expect("editor should have an active excerpt")
});
buffer.update(cx, |buffer, cx| {
buffer.set_language(None, cx);
});
assert_selected_language_for_editor(&workspace, &empty_editor, None, cx);
}
#[gpui::test]
async fn test_language_selector_selects_first_match_after_querying_new_buffer(
cx: &mut TestAppContext,
) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/test"), json!({}))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace =
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
register_test_languages(&project, cx);
let editor = open_new_buffer_editor(&workspace, &project, cx).await;
workspace.update_in(cx, |workspace, window, cx| {
let was_activated = workspace.activate_item(&editor, true, true, window, cx);
assert!(
was_activated,
"editor should be activated before opening the modal"
);
});
cx.run_until_parked();
let picker = open_selector(&workspace, cx);
picker.read_with(cx, |picker, _| {
let selected_match = picker
.delegate
.matches
.get(picker.delegate.selected_index)
.expect("selected index should point to a match");
let selected_candidate = picker
.delegate
.candidates
.get(selected_match.candidate_id)
.expect("selected match should map to a candidate");
assert_eq!(selected_candidate.string, "Plain Text");
assert!(
picker
.delegate
.current_language_candidate_index
.is_some_and(|current_language_candidate_index| {
current_language_candidate_index > 1
}),
"test setup should place Plain Text after at least two earlier languages",
);
});
picker.update_in(cx, |picker, window, cx| {
picker.update_matches("ru".to_string(), window, cx)
});
cx.run_until_parked();
picker.read_with(cx, |picker, _| {
assert!(
picker.delegate.matches.len() > 1,
"query should return multiple matches"
);
assert_eq!(picker.delegate.selected_index, 0);
let first_match = picker
.delegate
.matches
.first()
.expect("query should produce at least one match");
let selected_match = picker
.delegate
.matches
.get(picker.delegate.selected_index)
.expect("selected index should point to a match");
assert_eq!(selected_match.candidate_id, first_match.candidate_id);
});
}
}