Fall back to file names in breadcumbs with LSP symbols (#61511)

Closes https://github.com/zed-industries/zed/issues/60905

Also makes the breadcrumbs to update on settings change — before, one
needed to focus them.

Release Notes:

- Improved breadcrumbs for `"document_symbols": "on"` case when no LSP
data is sent
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-07-23 11:35:26 +00:00
1 parent 146a568c79
commit 336679c8e1
3 files changed
+161 -30

No files matched your search

+140 -9
View File
@@ -333,12 +333,13 @@ mod tests {
};
use futures::StreamExt as _;
use gpui::TestAppContext;
use gpui::{App, TestAppContext};
use multi_buffer::ToPoint;
use settings::{DocumentSymbols, SettingsStore};
use text::Point;
use util::path;
use zed_actions::editor::{MoveDown, MoveUp};
use workspace::item::{Item, ItemEvent};
use zed_actions::editor::MoveDown;
use crate::{
Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
@@ -357,6 +358,16 @@ mod tests {
.collect()
}
fn breadcrumb_texts(editor: &Editor, cx: &App) -> Vec<String> {
editor
.breadcrumbs(cx)
.expect("Should have breadcrumbs")
.0
.into_iter()
.map(|segment| segment.text.to_string())
.collect()
}
fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
lsp::Range {
start: lsp::Position::new(start_line, start_char),
@@ -542,18 +553,13 @@ mod tests {
);
});
// Step 3: Switch back to tree-sitter
// Step 3: Switch back to tree-sitter, the symbols should refresh
// without any extra selection changes
update_test_language_settings(&mut cx.cx.cx, &|settings| {
settings.defaults.document_symbols = Some(DocumentSymbols::Off);
});
cx.run_until_parked();
// Force another selection change
cx.update_editor(|editor, window, cx| {
editor.move_up(&MoveUp, window, cx);
});
cx.run_until_parked();
cx.update_editor(|editor, _window, _cx| {
assert_eq!(
outline_symbol_names(editor),
@@ -1022,4 +1028,129 @@ mod tests {
);
});
}
#[gpui::test]
async fn test_breadcrumbs_keep_file_name_without_lsp_symbols(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_language_settings(cx, &|settings| {
settings.defaults.document_symbols = Some(DocumentSymbols::On);
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_symbol_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut symbol_request = cx
.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
move |_, _, _| async move {
Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
},
);
cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
assert!(symbol_request.next().await.is_some());
cx.run_until_parked();
cx.update_editor(|editor, _window, cx| {
assert_eq!(
breadcrumb_texts(editor, cx),
vec![path!("dir/file.rs").to_string()],
"Breadcrumbs should fall back to the file name when the language server returns no symbols"
);
editor.set_breadcrumb_header("Last 1000 lines in the log".to_string());
assert_eq!(
breadcrumb_texts(editor, cx),
vec!["Last 1000 lines in the log".to_string()],
"A custom breadcrumb header should never disappear"
);
});
}
#[gpui::test]
async fn test_breadcrumbs_refresh_on_document_symbols_setting_change(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_symbol_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let mut symbol_request = cx
.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
move |_, _, _| async move {
Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
},
);
cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
cx.run_until_parked();
cx.update_editor(|editor, _window, cx| {
assert_eq!(
breadcrumb_texts(editor, cx),
vec![path!("dir/file.rs").to_string(), "fn main".to_string()],
"With tree-sitter symbols, breadcrumbs should show the file name and the symbol"
);
});
let breadcrumb_updates = Arc::new(atomic::AtomicUsize::new(0));
let editor = cx.editor.clone();
let _subscription = cx.update(|_, cx| {
cx.subscribe(&editor, {
let breadcrumb_updates = breadcrumb_updates.clone();
move |_, event, _| {
Editor::to_item_events(event, &mut |item_event| {
if item_event == ItemEvent::UpdateBreadcrumbs {
breadcrumb_updates.fetch_add(1, atomic::Ordering::AcqRel);
}
});
}
})
});
update_test_language_settings(&mut cx.cx.cx, &|settings| {
settings.defaults.document_symbols = Some(DocumentSymbols::On);
});
assert!(symbol_request.next().await.is_some());
cx.run_until_parked();
cx.update_editor(|editor, _window, cx| {
assert_eq!(
breadcrumb_texts(editor, cx),
vec![path!("dir/file.rs").to_string()],
"After enabling LSP symbols that return nothing, breadcrumbs should keep the file name"
);
});
assert!(
breadcrumb_updates.load(atomic::Ordering::Acquire) > 0,
"Breadcrumbs should refresh on the setting change, without extra selection changes"
);
breadcrumb_updates.store(0, atomic::Ordering::Release);
update_test_language_settings(&mut cx.cx.cx, &|settings| {
settings.defaults.document_symbols = Some(DocumentSymbols::Off);
});
cx.run_until_parked();
cx.update_editor(|editor, _window, cx| {
assert_eq!(
breadcrumb_texts(editor, cx),
vec![path!("dir/file.rs").to_string(), "fn main".to_string()],
"After disabling LSP symbols, tree-sitter breadcrumbs should return"
);
});
assert!(
breadcrumb_updates.load(atomic::Ordering::Acquire) > 0,
"Breadcrumbs should refresh on the setting change, without extra selection changes"
);
}
}
+20 -20
View File
@@ -9887,6 +9887,7 @@ impl Editor {
if language_settings_changed {
self.clear_disabled_lsp_folding_ranges(window, cx);
self.refresh_document_symbols(None, cx);
self.refresh_outline_symbols_at_cursor(cx);
}
if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| {
@@ -10943,16 +10944,12 @@ impl Editor {
}
fn breadcrumbs_inner(&self, cx: &App) -> Option<Vec<HighlightedText>> {
let multibuffer = self.buffer().read(cx);
let is_singleton = multibuffer.is_singleton();
let (buffer_id, symbols) = self.outline_symbols_at_cursor.as_ref()?;
let buffer = multibuffer.buffer(*buffer_id)?;
let buffer = buffer.read(cx);
let multi_buffer = self.buffer().read(cx);
// In a multi-buffer layout, we don't want to include the filename in the breadcrumbs
let mut breadcrumbs = if is_singleton {
let mut breadcrumbs = if let Some(buffer) = multi_buffer.as_singleton() {
let text = self.breadcrumb_header.clone().unwrap_or_else(|| {
buffer
.read(cx)
.snapshot()
.resolve_file_path(
self.project
@@ -10961,27 +10958,30 @@ impl Editor {
.unwrap_or_default(),
cx,
)
.unwrap_or_else(|| {
if multibuffer.is_singleton() {
multibuffer.title(cx).to_string()
} else {
MultiBuffer::DEFAULT_TITLE.to_string()
}
})
.unwrap_or_else(|| multi_buffer.title(cx).to_string())
});
vec![HighlightedText {
text: text.into(),
highlights: vec![],
}]
} else {
vec![]
Vec::new()
};
breadcrumbs.extend(symbols.iter().map(|symbol| HighlightedText {
text: symbol.text.clone(),
highlights: symbol.highlight_ranges.clone(),
}));
Some(breadcrumbs)
if let Some((buffer_id, symbols)) = self.outline_symbols_at_cursor.as_ref()
&& multi_buffer.buffer(*buffer_id).is_some()
{
breadcrumbs.extend(symbols.iter().map(|symbol| HighlightedText {
text: symbol.text.clone(),
highlights: symbol.highlight_ranges.clone(),
}));
}
if breadcrumbs.is_empty() {
None
} else {
Some(breadcrumbs)
}
}
fn disable_lsp_data(&mut self) {
+1 -1
View File
@@ -1169,7 +1169,7 @@ impl Item for Editor {
f(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::BreadcrumbsChanged => {
EditorEvent::BreadcrumbsChanged | EditorEvent::OutlineSymbolsChanged => {
f(ItemEvent::UpdateBreadcrumbs);
}