diff --git a/src/core/actions.rs b/src/core/actions.rs index 78bacfd2..94a2582a 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -127,6 +127,9 @@ actions!( EditorSave, EditorSaveAs, EditorGoToLine, + EditorGoToSymbol, + EditorNavigateBack, + EditorNavigateForward, EditorNewFile, EditorGoToDefinition, EditorQuickFix, diff --git a/src/ui/app.rs b/src/ui/app.rs index 84799e3f..f0017ebd 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6131,6 +6131,10 @@ impl Tty7App { if matches!(kind, CommandKind::SetTheme(_)) { self.theme_preview_restore = None; } + // Likewise the symbol Go to Symbol has the caret on. + if matches!(kind, CommandKind::GoToSymbol { .. }) { + self.editor_symbol_preview_commit(); + } self.close_search(window, cx); self.run_command(kind, window, cx); } @@ -6143,6 +6147,9 @@ impl Tty7App { } } SearchEvent::CancelThemePreview => self.cancel_preset_preview(window, cx), + SearchEvent::PreviewSymbol { line, column } => { + self.editor_symbol_preview(*line, *column, cx) + } } } @@ -6153,6 +6160,9 @@ impl Tty7App { // other than confirming the pick puts the old one back. self.cancel_preset_preview(window, cx); self.focus_active(window, cx); + // Nor was a symbol Go to Symbol previewed: the caret goes back, and + // the keyboard with it to the editor it came from. + self.editor_symbol_preview_cancel(window, cx); cx.notify(); } @@ -6345,6 +6355,7 @@ impl Tty7App { OpenFile { path, line, column } => { self.open_indexed_file(&path, line, column, window, cx) } + GoToSymbol { line, column } => self.editor_go_to_position(line, column, window, cx), GoToTab { workspace, tab } => self.go_to_tab(workspace, tab, false, window, cx), ResumeSession { agent, diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 294b8a8f..56937bf1 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -23,6 +23,10 @@ use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub}; use crate::ui::i18n::{L10nKey, t, t_fmt}; mod gutter; +mod nav; +pub(crate) mod outline; + +pub(crate) use nav::KEY_CONTEXT as NAV_KEY_CONTEXT; const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; @@ -312,6 +316,8 @@ pub(crate) struct EditorPanelState { watched_dirs: HashSet, watched_files: HashSet, events_tx: smol::channel::Sender>, + /// Back/forward history, outlines and Go to Symbol's preview. + nav: nav::EditorNav, } impl EditorPanelState { @@ -354,6 +360,7 @@ impl EditorPanelState { watched_dirs: HashSet::new(), watched_files: HashSet::new(), events_tx: tx, + nav: nav::EditorNav::default(), } } } @@ -2355,6 +2362,7 @@ impl Tty7App { code.forget(id); } self.editor.buffers.retain(|b| b.id() != id); + self.editor.nav.forget_buffer(id); if matches!( self.editor.bar.as_ref().map(|b| &b.kind), Some(BarKind::SaveAs { id: bar_id, .. }) if *bar_id == id @@ -2888,6 +2896,7 @@ impl Tty7App { /// A tab was closed for good: forget what it had open. pub(crate) fn editor_forget_tab(&mut self, tab: TabId, cx: &mut Context) { self.editor.recorded.remove(&tab); + self.editor.nav.forget_tab(tab); editor_session::remove(cx, tab); } } @@ -2910,6 +2919,8 @@ impl Tty7App { return None; } self.editor_gutter_sync(cx); + self.editor_nav_tick(cx); + let breadcrumbs = self.render_editor_breadcrumbs(window, cx); let body = match self.active_buffer() { None => self.render_editor_empty(cx).into_any_element(), Some(f) if f.preview => { @@ -2977,6 +2988,7 @@ impl Tty7App { .min_w_0() .h_full() .children(header) + .children(breadcrumbs) .when_some(conflict_banner, |this, b| this.child(b)) .children(bar) .child(div().flex_1().min_h_0().child(body)); @@ -3003,6 +3015,7 @@ impl Tty7App { .children(crate::ui::app::overlay_surface_layers(cx)), DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(), }; + let shell = self.editor_nav_actions(shell, cx); Some( shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs new file mode 100644 index 00000000..1d0e1e56 --- /dev/null +++ b/src/ui/code_editor/nav.rs @@ -0,0 +1,1092 @@ +//! Getting around the editor: back and forward through where the caret has +//! been, Go to Symbol, and the breadcrumbs over the text. +//! +//! **History.** Every jump — Go to File, Go to Line, Go to Symbol, a file link +//! clicked in the grid, a definition a language server found, a click far down +//! the file — is noticed the same way: [`Tty7App::editor_nav_tick`] samples the +//! caret each time the editor draws, and a caret that changed file, or moved +//! [`JUMP_LINES`] or more without the text changing under it, has jumped. The +//! place it left goes on the back stack. Nothing has to remember to report a +//! jump, so a new way of jumping cannot forget to. +//! +//! One history per tab, like a tab's strip of files: each tab is its own +//! editor group. A place in a file that has since been closed opens the file +//! again; one in an untitled buffer that is gone is skipped. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::prelude::*; +use gpui::{AnyElement, Context, Window, div, px}; +use gpui_component::input::Position; +use gpui_component::{ActiveTheme as _, h_flex}; +use tty7_core::core::machine::TabId; + +use super::outline::{self, Outline, SymbolKind}; +use super::{BufferId, CURSOR_SCROLL_ATTEMPTS, place_cursor}; +use crate::ui::app::Tty7App; +use crate::ui::host_ops::HostId; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::search::{CommandKind, Item, SearchTab}; + +/// A caret that moves this many lines or more at once has jumped. Arrowing +/// and typing never do; a click, a search hit or Page Down do. +pub(crate) const JUMP_LINES: u32 = 10; + +/// How many places the back stack keeps. +pub(crate) const MAX_HISTORY: usize = 50; + +/// How long a Back or Forward waits for the file it asked for to open before +/// the caret is watched for jumps again. +const PENDING_TIMEOUT: Duration = Duration::from_secs(3); + +/// How often an outline is rebuilt while the text keeps changing. Between +/// rebuilds the breadcrumbs show the last one, a keystroke or two stale. +const OUTLINE_THROTTLE: Duration = Duration::from_millis(250); + +/// The key context the editor's chords are bound in: the text field's own. +/// +/// Not a context of the editor panel's: gpui ranks a context-free binding as +/// deep as whatever has focus, so a panel-level binding would lose ⌘⇧O to the +/// workspace switcher. At the text field's depth, and listed after the +/// window's table, it wins. Only the editor panel handles these actions, so in +/// any other text field — or the terminal — the chord falls through to the +/// binding next in line. +pub(crate) const KEY_CONTEXT: &str = "Input"; + +/// One place the caret has been. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct NavLocation { + pub host: HostId, + /// Empty for an untitled buffer. + pub path: PathBuf, + /// Set only for an untitled buffer, which has no path to be found by and + /// can be returned to only while it is still open. + pub buffer: Option, + pub pos: Position, +} + +impl NavLocation { + fn same_file(&self, other: &NavLocation) -> bool { + self.host == other.host && self.path == other.path && self.buffer == other.buffer + } + + /// In the same file and within a jump of each other: one place, as far + /// as history is concerned. + fn near(&self, other: &NavLocation) -> bool { + self.same_file(other) && self.pos.line.abs_diff(other.pos.line) < JUMP_LINES + } +} + +/// Back and forward stacks, as a browser keeps them. +#[derive(Clone, Debug, Default)] +pub(crate) struct NavHistory { + back: Vec, + forward: Vec, +} + +impl NavHistory { + /// The caret jumped away from `from`. A new jump forgets the way forward, + /// and a place near the last one recorded replaces it rather than + /// stacking up beside it. + pub(crate) fn push(&mut self, from: NavLocation) { + self.forward.clear(); + match self.back.last_mut() { + Some(last) if last.near(&from) => *last = from, + _ => self.back.push(from), + } + if self.back.len() > MAX_HISTORY { + self.back.remove(0); + } + } + + /// Where Back goes from `current`, which becomes the way forward. + /// Places near `current` are passed over: going back to where you already + /// are would look like the key did nothing. + pub(crate) fn go_back(&mut self, current: Option) -> Option { + let target = pop_past(&mut self.back, current.as_ref())?; + if let Some(current) = current { + self.forward.push(current); + } + Some(target) + } + + /// Where Forward goes from `current`, which goes back on the back stack. + pub(crate) fn go_forward(&mut self, current: Option) -> Option { + let target = pop_past(&mut self.forward, current.as_ref())?; + if let Some(current) = current { + match self.back.last_mut() { + Some(last) if last.near(¤t) => *last = current, + _ => self.back.push(current), + } + } + Some(target) + } + + /// Drops the places `keep` turns down, from both stacks. + pub(crate) fn retain(&mut self, keep: impl Fn(&NavLocation) -> bool) { + self.back.retain(&keep); + self.forward.retain(&keep); + } + + #[cfg(test)] + fn lens(&self) -> (usize, usize) { + (self.back.len(), self.forward.len()) + } +} + +fn pop_past(stack: &mut Vec, current: Option<&NavLocation>) -> Option { + loop { + let top = stack.pop()?; + if !current.is_some_and(|c| c.near(&top)) { + return Some(top); + } + } +} + +/// Where the caret is, and how long the text is — a text that changed length +/// since the last sample was being edited, and an edit is not a jump. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Sample { + pub at: NavLocation, + pub text_len: usize, +} + +/// One tab's history, and the caret sample the next one is compared against. +#[derive(Default)] +pub(crate) struct TabNav { + pub(crate) history: NavHistory, + last: Option, + /// A Back or Forward on its way to a file that is still opening. Until it + /// lands, the caret that has not got there yet is not a jump. + pending: Option<(NavLocation, Instant)>, +} + +impl TabNav { + /// Compares a fresh sample against the last one and records a jump. + pub(crate) fn observe(&mut self, now: Option, at: Instant) { + if let Some((target, since)) = &self.pending { + let arrived = now.as_ref().is_some_and(|s| s.at.same_file(target)); + if arrived || at.duration_since(*since) > PENDING_TIMEOUT { + self.pending = None; + if arrived { + self.last = now; + return; + } + } else { + return; + } + } + let Some(now) = now else { + // No buffer in front: nothing to compare the next one against but + // the last place the caret was, which is kept. + return; + }; + if let Some(last) = &self.last { + let jumped = !last.at.same_file(&now.at) + || (last.text_len == now.text_len + && last.at.pos.line.abs_diff(now.at.pos.line) >= JUMP_LINES); + if jumped { + self.history.push(last.at.clone()); + } + } + self.last = Some(now); + } + + fn expect(&mut self, target: NavLocation, at: Instant) { + self.pending = Some((target, at)); + } +} + +struct OutlineCache { + text: gpui_component::Rope, + outline: Arc, + at: Instant, +} + +/// Go to Symbol's preview: where the caret was before the picker started +/// moving it, put back if the picker is dismissed. +struct SymbolPreview { + buffer: BufferId, + origin: Position, + scroll: gpui::Point, +} + +/// The window's navigation state, held by `EditorPanelState`. +#[derive(Default)] +pub(crate) struct EditorNav { + tabs: HashMap, + outlines: HashMap, + /// Symbols handed in from elsewhere — a language server's + /// `documentSymbol` — which win over the tree's. + provided: HashMap>, + preview: Option, + refresh_scheduled: bool, +} + +impl EditorNav { + pub(crate) fn forget_tab(&mut self, tab: TabId) { + self.tabs.remove(&tab); + } + + pub(crate) fn forget_buffer(&mut self, id: BufferId) { + self.outlines.remove(&id); + self.provided.remove(&id); + for nav in self.tabs.values_mut() { + nav.history.retain(|l| l.buffer != Some(id)); + } + } +} + +impl Tty7App { + fn nav_tab(&self) -> Option { + self.tabs.get(self.active).map(|t| t.tree_id.get()) + } + + /// Where the caret is in the buffer in front, if there is one. + fn nav_here(&self, cx: &gpui::App) -> Option { + let f = self.active_buffer()?; + let state = f.input.read(cx); + Some(Sample { + at: NavLocation { + host: f.host.id(), + path: f.path.clone(), + buffer: f.untitled.map(|_| f.id()), + pos: state.cursor_position(), + }, + text_len: state.text().len(), + }) + } + + /// Samples the caret for jumps. Called whenever the editor draws, which + /// every caret move and every edit makes it do. + pub(crate) fn editor_nav_tick(&mut self, cx: &mut Context) { + // The picker moving the caret around is looking, not jumping; the + // jump it ends in is seen on the first draw after it closes. + if self.editor.nav.preview.is_some() { + return; + } + let Some(tab) = self.nav_tab() else { return }; + let now = self.nav_here(cx); + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .observe(now, Instant::now()); + } + + /// Back (or, with `forward`, Forward) through the active tab's history. + pub(crate) fn editor_navigate( + &mut self, + forward: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.nav_tab() else { return }; + // Settle the caret's latest move first, or a jump made since the + // last draw would be lost. + self.editor_nav_tick(cx); + loop { + let current = self.nav_here(cx).map(|s| s.at); + let nav = self.editor.nav.tabs.entry(tab).or_default(); + let target = match forward { + true => nav.history.go_forward(current), + false => nav.history.go_back(current), + }; + let Some(target) = target else { return }; + if self.nav_open(tab, target, window, cx) { + return; + } + } + } + + /// Shows `target`. `false` when it is gone for good — an untitled buffer + /// that was closed, a machine no longer connected. + fn nav_open( + &mut self, + tab: TabId, + target: NavLocation, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let at = Instant::now(); + if let Some(id) = target.buffer { + if self.buffer(id).is_none() { + return false; + } + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .expect(target.clone(), at); + self.editor_show_in_tab(tab, id, true, window, cx); + if let Some(f) = self.buffer(id) { + place_cursor( + f.input.clone(), + target.pos, + CURSOR_SCROLL_ATTEMPTS, + window, + cx, + ); + } + return true; + } + let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, target.host) else { + return false; + }; + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .expect(target.clone(), at); + // The same road a file link takes, so a file closed since opens again. + self.editor.pending_cursor = Some(( + target.path.clone(), + target.pos.line + 1, + target.pos.character + 1, + )); + self.editor_open_on_host(host, &target.path, window, cx); + true + } + + // ---- Outline ---- + + /// Replaces the tree's symbols for `buffer` with ones found elsewhere — a + /// language server's `textDocument/documentSymbol`, flattened with + /// [`Outline::from_nodes`]. `None` goes back to the tree's. The caller + /// keeps them current: they are shown as given until replaced. + #[allow(dead_code)] + pub(crate) fn editor_set_document_symbols( + &mut self, + buffer: BufferId, + symbols: Option, + cx: &mut Context, + ) { + match symbols { + Some(outline) => self.editor.nav.provided.insert(buffer, Arc::new(outline)), + None => self.editor.nav.provided.remove(&buffer), + }; + cx.notify(); + } + + /// A buffer's outline. `fresh` rebuilds a stale one now; otherwise a text + /// that keeps changing is re-outlined at most every [`OUTLINE_THROTTLE`], + /// with a redraw scheduled for when it may be. + fn editor_outline( + &mut self, + id: BufferId, + fresh: bool, + cx: &mut Context, + ) -> Option> { + if let Some(provided) = self.editor.nav.provided.get(&id) { + return Some(provided.clone()); + } + let f = self.buffer(id)?; + let (language, tree, text) = match f.input.read(cx).syntax_tree() { + Some((language, tree, text)) => (language.to_string(), Some(tree), text), + // Not parsed yet — a buffer that has never been drawn. Only worth + // a parse of our own when someone is waiting on the answer. + None if fresh => ( + f.language().to_string(), + None, + f.input.read(cx).text().clone(), + ), + None => return None, + }; + if !outline::supports(&language) { + return None; + } + if let Some(cache) = self.editor.nav.outlines.get(&id) { + let current = cache.text.len() == text.len() && cache.text == text; + if current { + return Some(cache.outline.clone()); + } + if !fresh && cache.at.elapsed() < OUTLINE_THROTTLE { + let stale = cache.outline.clone(); + self.nav_schedule_refresh(cx); + return Some(stale); + } + } + let outline = Arc::new(match tree { + Some(tree) => outline::outline_from_tree(&language, &tree, &text), + None => outline::outline_of_text(&language, &text), + }); + self.editor.nav.outlines.insert( + id, + OutlineCache { + text, + outline: outline.clone(), + at: Instant::now(), + }, + ); + Some(outline) + } + + fn nav_schedule_refresh(&mut self, cx: &mut Context) { + if self.editor.nav.refresh_scheduled { + return; + } + self.editor.nav.refresh_scheduled = true; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(OUTLINE_THROTTLE).await; + let _ = this.update(cx, |this, cx| { + this.editor.nav.refresh_scheduled = false; + cx.notify(); + }); + }) + .detach(); + } + + // ---- Go to Symbol ---- + + /// Go to Symbol in the file in front: the search, on its Symbols tab. + /// The same chord puts it away again. + pub(crate) fn editor_go_to_symbol(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(view) = self.search.clone() { + if view.read(cx).tab() == SearchTab::Symbols { + self.close_search(window, cx); + return; + } + self.close_search(window, cx); + } + let Some(f) = self.active_buffer() else { + return; + }; + let id = f.id(); + let input = f.input.clone(); + // A symbol is a place in the source; a rendered Markdown file shows + // the source to be taken to it. + if let Some(f) = self.buffer_mut(id) { + f.preview = false; + } + let (origin, scroll) = { + let state = input.read(cx); + (state.cursor_position(), state.scroll_offset()) + }; + let outline = self.editor_outline(id, true, cx).unwrap_or_default(); + let rows = symbol_rows(&outline); + let here = outline + .chain_at(origin) + .last() + .map(|&ix| symbol_command(&outline.symbols[ix])); + self.open_search(SearchTab::Symbols, "", window, cx); + self.editor.nav.preview = Some(SymbolPreview { + buffer: id, + origin, + scroll, + }); + if let Some(view) = self.search.clone() { + view.update(cx, |view, cx| view.set_symbols(rows, here, window, cx)); + } + } + + /// Shows a symbol the picker is on, leaving the keyboard in the picker. + pub(crate) fn editor_symbol_preview(&mut self, line: u32, column: u32, cx: &mut Context) { + let Some(buffer) = self.editor.nav.preview.as_ref().map(|p| p.buffer) else { + return; + }; + let Some(f) = self.buffer(buffer) else { return }; + f.input.update(cx, |state, cx| { + state.preview_cursor_position(Position::new(line, column), cx) + }); + } + + /// The picker's choice is final: the caret stays where it took it. + pub(crate) fn editor_symbol_preview_commit(&mut self) { + self.editor.nav.preview = None; + } + + /// The picker went away without a choice: the caret and the scroll go + /// back to where they were, and so does the keyboard. + pub(crate) fn editor_symbol_preview_cancel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(preview) = self.editor.nav.preview.take() else { + return; + }; + let Some(f) = self.buffer(preview.buffer) else { + return; + }; + f.input.update(cx, |state, cx| { + state.set_cursor_position(preview.origin, window, cx); + state.set_scroll_offset(preview.scroll, cx); + }); + } + + /// Puts the caret on a 0-based position in the buffer in front. + pub(crate) fn editor_go_to_position( + &mut self, + line: u32, + column: u32, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.nav.preview = None; + let Some(f) = self.active_buffer() else { + return; + }; + place_cursor( + f.input.clone(), + Position::new(line, column), + CURSOR_SCROLL_ATTEMPTS, + window, + cx, + ); + } + + // ---- Chrome ---- + + /// The editor's own chords, handled by the element that holds it. + pub(crate) fn editor_nav_actions( + &self, + element: E, + cx: &mut Context, + ) -> E { + use crate::core::actions::{EditorGoToSymbol, EditorNavigateBack, EditorNavigateForward}; + element + .on_action(cx.listener(|this, _: &EditorGoToSymbol, window, cx| { + this.editor_go_to_symbol(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorNavigateBack, window, cx| { + this.editor_navigate(false, window, cx) + })) + .on_action(cx.listener(|this, _: &EditorNavigateForward, window, cx| { + this.editor_navigate(true, window, cx) + })) + } + + /// The row over the text: where the file is, then which symbols the caret + /// is inside. The symbols open Go to Symbol. + pub(crate) fn render_editor_breadcrumbs( + &mut self, + window: &Window, + cx: &mut Context, + ) -> Option { + let f = self.active_buffer()?; + if f.preview { + return None; + } + let id = f.id(); + let caret = f.input.read(cx).cursor_position(); + let segments: Vec = if f.untitled.is_some() { + vec![f.label().to_string()] + } else { + let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); + let local = f.host.id() == self.spawn_host(cx); + path_segments(&f.path, if local { roots } else { &[] }) + }; + let file = (f.untitled.is_none()).then(|| f.path.clone()); + let supported = f.untitled.is_none() && outline::supports(f.language()); + let chain: Vec<(SymbolKind, String)> = match supported { + true => self + .editor_outline(id, false, cx) + .map(|o| { + o.chain_at(caret) + .into_iter() + .map(|ix| (o.symbols[ix].kind, o.symbols[ix].name.clone())) + .collect() + }) + .unwrap_or_default(), + false => Vec::new(), + }; + + let theme = cx.theme(); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let sep = || div().flex_none().px(px(4.)).text_color(muted).child("›"); + let last = segments.len().saturating_sub(1); + let mut path = h_flex() + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .items_center(); + for (i, seg) in segments.into_iter().enumerate() { + if i > 0 { + path = path.child(sep()); + } + path = path.child( + div() + .flex_none() + .whitespace_nowrap() + .text_color(if i == last { fg } else { muted }) + .child(seg), + ); + } + let path = div() + .id("editor-breadcrumb-path") + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .cursor_pointer() + .child(path) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(file) = &file { + this.file_tree_reveal_path(file, cx); + } + })); + + let symbols = supported.then(|| { + let mut row = h_flex().min_w_0().overflow_hidden().items_center(); + if chain.is_empty() { + row = row.child(sep()).child(div().text_color(muted).child("…")); + } + for (kind, name) in chain { + row = row.child(sep()).child( + h_flex() + .flex_none() + .gap(px(4.)) + .whitespace_nowrap() + // An impl is named by its header, `impl` and all. + .when(kind != SymbolKind::Impl, |d| { + d.child(div().text_color(muted).child(kind.tag())) + }) + .child(div().text_color(fg).child(name)), + ); + } + div() + .id("editor-breadcrumb-symbols") + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child(row) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::EditorGoToSymbolAction)) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, window, cx| this.editor_go_to_symbol(window, cx))) + }); + + Some( + h_flex() + .id("editor-breadcrumbs") + .flex_none() + .w_full() + .h(px(24.)) + .items_center() + .overflow_hidden() + .px(px(crate::ui::app::CONTENT_INSET)) + .border_b(crate::ui::theme::hairline(window)) + .border_color(cx.theme().sidebar_border) + .text_size(gpui::rems(crate::ui::right_panel::META)) + .child(path) + .children(symbols) + .into_any_element(), + ) + } +} + +/// A path as the breadcrumbs spell it: from inside the project root it is in, +/// with the root's own name first, or whole when it is in none. +pub(crate) fn path_segments(path: &Path, roots: &[PathBuf]) -> Vec { + let (lead, rel) = match roots + .iter() + .find_map(|r| Some((r, path.strip_prefix(r).ok()?))) + { + Some((root, rel)) => ( + root.file_name().map(|n| n.to_string_lossy().to_string()), + rel.to_path_buf(), + ), + None => (None, path.to_path_buf()), + }; + lead.into_iter() + .chain(rel.components().filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()), + std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + _ => None, + })) + .collect() +} + +fn symbol_command(symbol: &outline::Symbol) -> CommandKind { + CommandKind::GoToSymbol { + line: symbol.selection.line, + column: symbol.selection.character, + } +} + +/// The picker's rows, in document order, each with how deep it sits. +pub(crate) fn symbol_rows(outline: &Outline) -> Vec<(usize, Item)> { + outline + .symbols + .iter() + .enumerate() + .map(|(ix, s)| { + let mut item = Item::new(s.name.clone(), symbol_command(s)).with_note(s.kind.tag()); + let path = outline.container_path(ix); + if !path.is_empty() { + item = item.with_subtitle(path.join(" › ")); + } + (s.depth, item) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(path: &str, line: u32) -> NavLocation { + NavLocation { + host: HostId::LOCAL, + path: PathBuf::from(path), + buffer: None, + pos: Position::new(line, 0), + } + } + + fn sample(path: &str, line: u32, len: usize) -> Option { + Some(Sample { + at: at(path, line), + text_len: len, + }) + } + + #[test] + fn back_and_forward_walk_the_places_jumped_from() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("b.rs", 40)); + // Now at c.rs:5. + assert_eq!(h.go_back(Some(at("c.rs", 5))), Some(at("b.rs", 40))); + assert_eq!(h.go_back(Some(at("b.rs", 40))), Some(at("a.rs", 10))); + assert_eq!(h.go_back(Some(at("a.rs", 10))), None); + assert_eq!(h.go_forward(Some(at("a.rs", 10))), Some(at("b.rs", 40))); + assert_eq!(h.go_forward(Some(at("b.rs", 40))), Some(at("c.rs", 5))); + assert_eq!(h.go_forward(Some(at("c.rs", 5))), None); + } + + #[test] + fn a_new_jump_forgets_the_way_forward() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("b.rs", 40)); + h.go_back(Some(at("c.rs", 5))); + assert_eq!(h.lens(), (1, 1)); + h.push(at("b.rs", 40)); + assert_eq!(h.lens(), (2, 0)); + assert_eq!(h.go_forward(Some(at("d.rs", 0))), None); + } + + #[test] + fn places_near_each_other_are_one_entry() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("a.rs", 14)); + assert_eq!(h.lens(), (1, 0)); + h.push(at("a.rs", 30)); + h.push(at("b.rs", 14)); + assert_eq!(h.lens(), (3, 0)); + // Back from right beside the last entry passes over it. + assert_eq!(h.go_back(Some(at("b.rs", 16))), Some(at("a.rs", 30))); + } + + #[test] + fn the_back_stack_is_capped() { + let mut h = NavHistory::default(); + for i in 0..(MAX_HISTORY as u32 + 5) { + h.push(at("a.rs", i * JUMP_LINES)); + } + assert_eq!(h.lens().0, MAX_HISTORY); + let mut oldest = None; + let mut cur = at("z.rs", 0); + while let Some(l) = h.go_back(Some(cur.clone())) { + cur = l.clone(); + oldest = Some(l); + } + assert_eq!(oldest, Some(at("a.rs", 5 * JUMP_LINES))); + } + + #[test] + fn closed_untitled_buffers_can_be_dropped() { + let mut h = NavHistory::default(); + let mut untitled = at("", 3); + untitled.buffer = Some(gpui::EntityId::from(7u64)); + h.push(untitled); + h.push(at("a.rs", 0)); + h.retain(|l| l.buffer.is_none()); + assert_eq!(h.lens(), (1, 0)); + } + + #[test] + fn the_tracker_records_jumps_but_not_walks_or_edits() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 100), t0); + // Arrowing down a line at a time is not a jump. + for line in 1..=30 { + nav.observe(sample("a.rs", line, 100), t0); + } + assert_eq!(nav.history.lens(), (0, 0)); + // Pasting forty lines moves the caret forty lines, and is an edit. + nav.observe(sample("a.rs", 70, 900), t0); + assert_eq!(nav.history.lens(), (0, 0)); + // A click far away is. + nav.observe(sample("a.rs", 300, 900), t0); + assert_eq!(nav.history.back, [at("a.rs", 70)]); + // So is another file coming forward. + nav.observe(sample("b.rs", 0, 10), t0); + assert_eq!(nav.history.back, [at("a.rs", 70), at("a.rs", 300)]); + // No buffer in front keeps the last place for the next one. + nav.observe(None, t0); + nav.observe(sample("c.rs", 0, 10), t0); + assert_eq!(nav.history.back.last(), Some(&at("b.rs", 0))); + } + + #[test] + fn a_pending_back_is_not_mistaken_for_a_jump() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 10), t0); + nav.observe(sample("b.rs", 50, 10), t0); + let target = nav.history.go_back(Some(at("b.rs", 50))).unwrap(); + nav.expect(target, t0); + // The file is still opening: b.rs is on screen, and that is fine. + nav.observe(sample("b.rs", 50, 10), t0); + nav.observe(sample("a.rs", 0, 10), t0); + assert_eq!(nav.history.lens(), (0, 1)); + // A target that never arrives stops being waited on. + nav.expect(at("gone.rs", 0), t0); + nav.observe( + sample("a.rs", 0, 10), + t0 + PENDING_TIMEOUT + Duration::from_secs(1), + ); + nav.observe( + sample("b.rs", 0, 10), + t0 + PENDING_TIMEOUT + Duration::from_secs(1), + ); + assert_eq!(nav.history.back, [at("a.rs", 0)]); + } + + #[test] + fn breadcrumb_paths_start_at_their_project() { + let roots = [PathBuf::from("/work/tty7")]; + assert_eq!( + path_segments(Path::new("/work/tty7/src/ui/code_editor.rs"), &roots), + ["tty7", "src", "ui", "code_editor.rs"] + ); + assert_eq!( + path_segments(Path::new("/etc/hosts"), &roots), + ["etc", "hosts"] + ); + } + + #[test] + fn symbol_rows_carry_their_containers_and_the_breadcrumb_chain_names_them() { + let src = "impl Tty7App {\n fn open_file(&self) {\n let x = 1;\n }\n}\n"; + let outline = outline::outline_of("rust", src); + let rows = symbol_rows(&outline); + let shown: Vec<_> = rows + .iter() + .map(|(d, i)| { + ( + *d, + i.title.as_str(), + i.subtitle.as_deref(), + i.note.as_deref(), + ) + }) + .collect(); + assert_eq!( + shown, + [ + (0, "impl Tty7App", None, Some("impl")), + (1, "open_file", Some("impl Tty7App"), Some("method")), + ] + ); + assert_eq!( + rows[1].1.kind, + CommandKind::GoToSymbol { line: 1, column: 7 } + ); + let chain: Vec<_> = outline + .chain_at(Position::new(2, 8)) + .into_iter() + .map(|i| { + format!( + "{} {}", + outline.symbols[i].kind.tag(), + outline.symbols[i].name + ) + }) + .collect(); + assert_eq!(chain, ["impl impl Tty7App", "method open_file"]); + } +} + +#[cfg(test)] +mod gpui_tests { + use super::*; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::editor_text::{EditorConfig, TextFormat}; + use gpui::{Entity, TestAppContext, VisualTestContext}; + + /// `alpha` on line 0, `beta` on 30, `gamma` on 50. + fn source() -> String { + let mut lines = vec!["fn alpha() {".to_string()]; + lines.extend((1..=20).map(|_| " let x = 1;".to_string())); + lines.push("}".into()); + lines.extend((22..30).map(|_| String::new())); + lines.push("fn beta() {".into()); + lines.push("}".into()); + lines.extend((32..50).map(|_| String::new())); + lines.push("fn gamma() {".into()); + lines.push("}".into()); + lines.join("\n") + "\n" + } + + fn open_rs(app: &Entity, vcx: &mut VisualTestContext, path: &str) -> BufferId { + let path = PathBuf::from(path); + app.update_in(vcx, |app, window, cx| { + let host = app.active_host(cx).expect("a host"); + let tab = app.nav_tab().expect("a tab"); + let indent = crate::ui::editor_text::detect_indent("", "rust"); + let id = app.editor_new_buffer( + host, + path, + None, + source(), + TextFormat::default(), + EditorConfig::default(), + indent, + None, + window, + cx, + ); + app.editor_show_in_tab(tab, id, true, window, cx); + id + }) + } + + fn caret(app: &Entity, vcx: &mut VisualTestContext) -> Position { + app.read_with(vcx, |app, cx| { + app.active_buffer() + .unwrap() + .input + .read(cx) + .cursor_position() + }) + } + + /// What drawing the editor does: sample the caret. + fn draw(app: &Entity, vcx: &mut VisualTestContext) { + app.update(vcx, |app, cx| app.editor_nav_tick(cx)); + } + + fn put_caret(app: &Entity, vcx: &mut VisualTestContext, line: u32) { + app.update_in(vcx, |app, window, cx| { + let input = app.active_buffer().unwrap().input.clone(); + input.update(cx, |s, cx| { + s.set_cursor_position(Position::new(line, 4), window, cx) + }); + }); + draw(app, vcx); + } + + #[gpui::test] + fn go_to_symbol_previews_and_escape_puts_the_caret_back(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + let symbols = app.read_with(&vcx, |app, cx| { + let view = app.search.clone().expect("the search is open"); + assert_eq!(view.read(cx).tab(), SearchTab::Symbols); + view.read(cx).symbol_count() + }); + assert_eq!(symbols, 3); + // Opening on the symbol around the caret does not move it. + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4)); + + vcx.simulate_keystrokes("down"); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(30, 3), + "beta is previewed" + ); + + vcx.simulate_keystrokes("escape"); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_none())); + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4), "and put back"); + draw(&app, &mut vcx); + app.read_with(&vcx, |app, _| { + let tab = app.nav_tab().unwrap(); + assert!( + app.editor.nav.tabs[&tab].history.back.is_empty(), + "looking is not jumping" + ); + }); + } + + #[gpui::test] + fn a_symbol_jump_can_be_walked_back_and_forward(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + vcx.simulate_keystrokes("g a m m a"); + vcx.run_until_parked(); + vcx.simulate_keystrokes("enter"); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_none())); + assert_eq!(caret(&app, &mut vcx), Position::new(50, 3)); + draw(&app, &mut vcx); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(5, 4), + "back to where it was" + ); + draw(&app, &mut vcx); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(true, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(50, 3), + "and forward again" + ); + draw(&app, &mut vcx); + + // Another file coming forward is a jump too, and Back returns to the + // first one. + open_rs(&app, &mut vcx, "/nav-test/b.rs"); + draw(&app, &mut vcx); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + app.read_with(&vcx, |app, _| { + assert_eq!( + app.active_buffer().unwrap().path, + PathBuf::from("/nav-test/a.rs") + ); + }); + assert_eq!(caret(&app, &mut vcx), Position::new(50, 3)); + } +} diff --git a/src/ui/code_editor/outline.rs b/src/ui/code_editor/outline.rs new file mode 100644 index 00000000..d3d19401 --- /dev/null +++ b/src/ui/code_editor/outline.rs @@ -0,0 +1,778 @@ +//! A file's symbols — what Go to Symbol lists and the breadcrumbs walk. +//! +//! Built from the tree the editor already parsed for highlighting +//! ([`gpui_component::input::InputState::syntax_tree`]), with one small query +//! per language saying which nodes are symbols and where their names are. No +//! second parse, and nothing to install. +//! +//! An [`Outline`] is plain data, so it need not come from tree-sitter at all: +//! [`Outline::from_nodes`] takes the same nested shape a language server's +//! `textDocument/documentSymbol` answers with, and a buffer given one through +//! `Tty7App::editor_set_document_symbols` shows that instead of the tree's. + +use std::collections::HashMap; +use std::ops::Range; +use std::sync::{Arc, LazyLock, Mutex}; + +use gpui_component::Rope; +use gpui_component::highlighter::LanguageRegistry; +use gpui_component::highlighter::tree_sitter::{self, Query, QueryCursor, StreamingIterator as _}; +use gpui_component::input::{Position, RopeExt as _}; + +/// What a symbol is — the shapes the picker and the breadcrumbs tell apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymbolKind { + Module, + Namespace, + Class, + Struct, + Enum, + Interface, + Trait, + Impl, + Function, + Method, + Constructor, + Constant, + Variable, + Field, + Type, + Macro, + Heading, +} + +impl SymbolKind { + /// The capture suffix a query names this kind by (`@item.function`). + fn from_capture(suffix: &str) -> Option { + Some(match suffix { + "module" => Self::Module, + "namespace" => Self::Namespace, + "class" => Self::Class, + "struct" => Self::Struct, + "enum" => Self::Enum, + "interface" => Self::Interface, + "trait" => Self::Trait, + "impl" => Self::Impl, + "function" => Self::Function, + "method" => Self::Method, + "constructor" => Self::Constructor, + "constant" => Self::Constant, + "variable" => Self::Variable, + "field" => Self::Field, + "type" => Self::Type, + "macro" => Self::Macro, + "heading" => Self::Heading, + _ => return None, + }) + } + + /// LSP's `SymbolKind` number, for a language server's answer. Kinds with + /// no counterpart here (a file, a package, an event…) fold into the + /// nearest one that draws the same. + pub(crate) fn from_lsp(kind: u32) -> Self { + match kind { + 1 | 2 | 4 => Self::Module, // File, Module, Package + 3 => Self::Namespace, + 5 => Self::Class, + 6 => Self::Method, + 7 | 8 => Self::Field, // Property, Field + 9 => Self::Constructor, + 10 => Self::Enum, + 11 => Self::Interface, + 12 => Self::Function, + 13 => Self::Variable, + 14 | 22 => Self::Constant, // Constant, EnumMember + 23 => Self::Struct, + 26 => Self::Type, // TypeParameter + _ => Self::Variable, + } + } + + /// Whether a function directly inside this is a method of it. + fn holds_methods(self) -> bool { + matches!( + self, + Self::Class | Self::Struct | Self::Enum | Self::Interface | Self::Trait | Self::Impl + ) + } + + /// The short tag the picker shows beside a name. Code's own words, the + /// same in every locale, like the keywords they stand for. + pub(crate) fn tag(self) -> &'static str { + match self { + Self::Module => "mod", + Self::Namespace => "namespace", + Self::Class => "class", + Self::Struct => "struct", + Self::Enum => "enum", + Self::Interface => "interface", + Self::Trait => "trait", + Self::Impl => "impl", + Self::Function => "fn", + Self::Method => "method", + Self::Constructor => "ctor", + Self::Constant => "const", + Self::Variable => "var", + Self::Field => "field", + Self::Type => "type", + Self::Macro => "macro", + Self::Heading => "#", + } + } +} + +/// One symbol, flattened into its outline. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Symbol { + pub name: String, + pub kind: SymbolKind, + /// The whole item, 0-based, in the editor's own [`Position`]s. + pub range: Range, + /// Where a jump to it lands: its name. + pub selection: Position, + /// How many symbols it sits inside. + pub depth: usize, + /// The symbol it sits directly inside, as an index into the outline. + pub parent: Option, +} + +/// A nested symbol, the shape an LSP `DocumentSymbol` has — what an outline +/// from anywhere other than the tree is built out of. +#[derive(Clone, Debug)] +pub(crate) struct SymbolNode { + pub name: String, + pub kind: SymbolKind, + pub range: Range, + pub selection: Position, + pub children: Vec, +} + +/// A file's symbols in document order, every container before what it holds. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Outline { + pub symbols: Vec, +} + +fn key(p: Position) -> (u32, u32) { + (p.line, p.character) +} + +impl Outline { + /// Flattens a nested answer (a language server's `documentSymbol`). + pub(crate) fn from_nodes(nodes: Vec) -> Self { + fn walk( + nodes: Vec, + parent: Option, + depth: usize, + out: &mut Vec, + ) { + let mut nodes = nodes; + nodes.sort_by_key(|n| key(n.range.start)); + for node in nodes { + let ix = out.len(); + out.push(Symbol { + name: node.name, + kind: node.kind, + range: node.range, + selection: node.selection, + depth, + parent, + }); + walk(node.children, Some(ix), depth + 1, out); + } + } + let mut symbols = Vec::new(); + walk(nodes, None, 0, &mut symbols); + Self { symbols } + } + + pub(crate) fn is_empty(&self) -> bool { + self.symbols.is_empty() + } + + /// The symbols `pos` is inside, outermost first — the breadcrumbs' trail. + pub(crate) fn chain_at(&self, pos: Position) -> Vec { + let at = key(pos); + // Children follow their parent and siblings never overlap, so the + // last symbol that holds the position is the innermost one. + let Some(innermost) = self + .symbols + .iter() + .rposition(|s| key(s.range.start) <= at && at <= key(s.range.end)) + else { + return Vec::new(); + }; + let mut chain = vec![innermost]; + let mut cur = innermost; + while let Some(parent) = self.symbols[cur].parent { + chain.push(parent); + cur = parent; + } + chain.reverse(); + chain + } + + /// The names of what symbol `ix` sits inside, outermost first. + pub(crate) fn container_path(&self, ix: usize) -> Vec<&str> { + let mut names = Vec::new(); + let mut cur = self.symbols.get(ix).and_then(|s| s.parent); + while let Some(p) = cur { + names.push(self.symbols[p].name.as_str()); + cur = self.symbols[p].parent; + } + names.reverse(); + names + } +} + +// ---- tree-sitter ---- +// +// Captures: `@item.` on the node that is the symbol, `@name` on its name +// (several are joined with a space), `@body` on an impl's block — which names +// the symbol by everything before it, `impl Display for Wrapper` — and +// `@receiver` on a Go method's receiver list, which goes in front of the name +// as `(*Server).Serve`. When two patterns claim one node the earlier wins, so +// a catch-all belongs last. + +const RUST: &str = r#" +(mod_item name: (identifier) @name) @item.module +(struct_item name: (type_identifier) @name) @item.struct +(union_item name: (type_identifier) @name) @item.struct +(enum_item name: (type_identifier) @name) @item.enum +(trait_item name: (type_identifier) @name) @item.trait +(impl_item body: (declaration_list) @body) @item.impl +(function_item name: (identifier) @name) @item.function +(function_signature_item name: (identifier) @name) @item.function +(const_item name: (identifier) @name) @item.constant +(static_item name: (identifier) @name) @item.constant +(type_item name: (type_identifier) @name) @item.type +(macro_definition name: (identifier) @name) @item.macro +"#; + +const GO: &str = r#" +(function_declaration name: (identifier) @name) @item.function +(method_declaration receiver: (parameter_list) @receiver name: (field_identifier) @name) @item.method +(type_spec name: (type_identifier) @name type: (struct_type)) @item.struct +(type_spec name: (type_identifier) @name type: (interface_type)) @item.interface +(type_spec name: (type_identifier) @name) @item.type +(const_spec name: (identifier) @name) @item.constant +"#; + +const PYTHON: &str = r#" +(class_definition name: (identifier) @name) @item.class +(function_definition name: (identifier) @name) @item.function +"#; + +const JAVASCRIPT: &str = r#" +(class_declaration name: (_) @name) @item.class +(function_declaration name: (identifier) @name) @item.function +(generator_function_declaration name: (identifier) @name) @item.function +(method_definition name: (_) @name) @item.method +(variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)]) @item.function +"#; + +const TYPESCRIPT_EXTRA: &str = r#" +(abstract_class_declaration name: (_) @name) @item.class +(interface_declaration name: (_) @name) @item.interface +(type_alias_declaration name: (_) @name) @item.type +(enum_declaration name: (_) @name) @item.enum +(internal_module name: (_) @name) @item.module +(function_signature name: (_) @name) @item.function +(method_signature name: (_) @name) @item.method +(abstract_method_signature name: (_) @name) @item.method +"#; + +const C: &str = r#" +(function_definition declarator: (function_declarator declarator: (_) @name)) @item.function +(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (_) @name))) @item.function +(struct_specifier name: (_) @name body: (_)) @item.struct +(union_specifier name: (_) @name body: (_)) @item.struct +(enum_specifier name: (_) @name body: (_)) @item.enum +(type_definition declarator: (type_identifier) @name) @item.type +"#; + +const CPP_EXTRA: &str = r#" +(namespace_definition name: (_) @name) @item.namespace +(class_specifier name: (_) @name body: (_)) @item.class +(function_definition declarator: (reference_declarator (function_declarator declarator: (_) @name))) @item.function +(field_declaration declarator: (function_declarator declarator: (_) @name)) @item.method +(alias_declaration name: (type_identifier) @name) @item.type +"#; + +const JAVA: &str = r#" +(class_declaration name: (identifier) @name) @item.class +(interface_declaration name: (identifier) @name) @item.interface +(enum_declaration name: (identifier) @name) @item.enum +(record_declaration name: (identifier) @name) @item.class +(annotation_type_declaration name: (identifier) @name) @item.interface +(method_declaration name: (identifier) @name) @item.method +(constructor_declaration name: (identifier) @name) @item.constructor +"#; + +const MARKDOWN: &str = r#" +(section (atx_heading (inline) @name)) @item.heading +(setext_heading (paragraph (inline) @name)) @item.heading +"#; + +const RUBY: &str = r#" +(class name: (_) @name) @item.class +(module name: (_) @name) @item.module +(method name: (_) @name) @item.method +(singleton_method name: (_) @name) @item.method +"#; + +const BASH: &str = r#" +(function_definition name: (word) @name) @item.function +"#; + +/// The query for a language the editor highlights, or `None` for one it has +/// no outline for. +fn query_source(language: &str) -> Option { + Some(match language { + "rust" => RUST.into(), + "go" => GO.into(), + "python" => PYTHON.into(), + "javascript" => JAVASCRIPT.into(), + "typescript" | "tsx" => format!("{JAVASCRIPT}{TYPESCRIPT_EXTRA}"), + "c" => C.into(), + "cpp" => format!("{C}{CPP_EXTRA}"), + "java" => JAVA.into(), + "markdown" => MARKDOWN.into(), + "ruby" => RUBY.into(), + "bash" => BASH.into(), + _ => return None, + }) +} + +/// Compiled once per language per process. A query that does not compile +/// against its grammar is a bug here, not in the file: it is logged once and +/// the language goes without an outline. +fn query_for(language: &str) -> Option> { + static QUERIES: LazyLock>>>> = + LazyLock::new(Default::default); + let mut queries = QUERIES.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(q) = queries.get(language) { + return q.clone(); + } + let compiled = query_source(language).and_then(|source| { + let config = LanguageRegistry::singleton().language(language)?; + Query::new(&config.language, &source) + .map_err(|e| log::warn!("outline: the {language} query does not compile: {e}")) + .ok() + .map(Arc::new) + }); + queries.insert(language.to_string(), compiled.clone()); + compiled +} + +/// Whether `language` has an outline at all. +pub(crate) fn supports(language: &str) -> bool { + query_source(language).is_some() +} + +/// The longest name a symbol shows; an impl header with a where clause can +/// otherwise run the width of the screen. +const NAME_MAX_CHARS: usize = 80; + +fn collapse(text: &str) -> String { + let mut out = text.split_whitespace().collect::>().join(" "); + if out.chars().count() > NAME_MAX_CHARS { + out = out.chars().take(NAME_MAX_CHARS - 1).collect::() + "…"; + } + out +} + +fn text_of(text: &Rope, range: Range) -> String { + let end = range.end.min(text.len()); + let start = range.start.min(end); + text.slice(start..end).to_string() +} + +/// The outline of `text`, as parsed into `tree`. +pub(crate) fn outline_from_tree(language: &str, tree: &tree_sitter::Tree, text: &Rope) -> Outline { + let Some(query) = query_for(language) else { + return Outline::default(); + }; + let names = query.capture_names(); + struct Found { + pattern: usize, + kind: SymbolKind, + range: Range, + name: String, + selection: usize, + } + let mut found: HashMap = HashMap::new(); + let mut cursor = QueryCursor::new(); + // No pattern has a predicate, so the text is never consulted. + let mut matches = cursor.matches(&query, tree.root_node(), &[] as &[u8]); + while let Some(m) = matches.next() { + let mut item = None; + let mut name_parts: Vec<(usize, String)> = Vec::new(); + let mut body = None; + let mut receiver = None; + for cap in m.captures { + let capture = names[cap.index as usize]; + let node = cap.node; + if let Some(kind) = capture.strip_prefix("item.") { + item = SymbolKind::from_capture(kind).map(|k| (k, node)); + } else if capture == "name" { + name_parts.push((node.start_byte(), text_of(text, node.byte_range()))); + } else if capture == "body" { + body = Some(node.start_byte()); + } else if capture == "receiver" { + receiver = Some(text_of(text, node.byte_range())); + } + } + let Some((kind, node)) = item else { continue }; + if found + .get(&node.id()) + .is_some_and(|f| f.pattern <= m.pattern_index) + { + continue; + } + name_parts.sort_by_key(|(at, _)| *at); + let (name, selection) = match (name_parts.first(), body) { + (Some((at, _)), _) => ( + name_parts + .iter() + .map(|(_, n)| n.as_str()) + .collect::>() + .join(" "), + *at, + ), + (None, Some(body)) => (text_of(text, node.start_byte()..body), node.start_byte()), + (None, None) => continue, + }; + let mut name = collapse(&name); + if let Some(receiver) = receiver { + let ty = receiver + .trim_matches(|c| c == '(' || c == ')') + .split_whitespace() + .last() + .unwrap_or_default() + .to_string(); + if !ty.is_empty() { + name = format!("({ty}).{name}"); + } + } + if name.is_empty() { + continue; + } + found.insert( + node.id(), + Found { + pattern: m.pattern_index, + kind, + range: node.byte_range(), + name, + selection, + }, + ); + } + + // Document order, a container before what it holds: by start, and the + // longer of two that start together first. + let mut items: Vec = found.into_values().collect(); + items.sort_by(|a, b| { + a.range + .start + .cmp(&b.range.start) + .then(b.range.end.cmp(&a.range.end)) + }); + let mut symbols: Vec = Vec::with_capacity(items.len()); + let mut open: Vec<(usize, usize)> = Vec::new(); // (index, end byte) + for f in items { + while open.last().is_some_and(|(_, end)| *end <= f.range.start) { + open.pop(); + } + let parent = open.last().map(|(ix, _)| *ix); + let kind = match (f.kind, parent) { + (SymbolKind::Function, Some(p)) if symbols[p].kind.holds_methods() => { + SymbolKind::Method + } + (kind, _) => kind, + }; + let ix = symbols.len(); + symbols.push(Symbol { + name: f.name, + kind, + range: text.offset_to_position(f.range.start)..text.offset_to_position(f.range.end), + selection: text.offset_to_position(f.selection), + depth: open.len(), + parent, + }); + open.push((ix, f.range.end)); + } + Outline { symbols } +} + +/// The outline of a text nobody has parsed yet — a buffer that has not been +/// drawn. Parses it once, here. +pub(crate) fn outline_of_text(language: &str, text: &Rope) -> Outline { + let Some(config) = LanguageRegistry::singleton().language(language) else { + return Outline::default(); + }; + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&config.language).is_err() { + return Outline::default(); + } + let source = text.to_string(); + match parser.parse(&source, None) { + Some(tree) => outline_from_tree(language, &tree, text), + None => Outline::default(), + } +} + +#[cfg(test)] +pub(crate) fn outline_of(language: &str, source: &str) -> Outline { + outline_of_text(language, &Rope::from(source)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `name@depth:kind` for every symbol, in order. + fn shape(language: &str, source: &str) -> Vec { + outline_of(language, source) + .symbols + .iter() + .map(|s| format!("{}@{}:{}", s.name, s.depth, s.kind.tag())) + .collect() + } + + #[test] + fn every_outline_query_compiles_against_its_grammar() { + for language in [ + "rust", + "go", + "python", + "javascript", + "typescript", + "tsx", + "c", + "cpp", + "java", + "markdown", + "ruby", + "bash", + ] { + assert!(query_for(language).is_some(), "{language}"); + } + assert!(query_for("json").is_none()); + } + + #[test] + fn rust_nests_methods_under_their_impl() { + let src = "mod net {\n pub struct Server { port: u16 }\n impl Display for Wrapper where T: Debug {\n fn fmt(&self) {}\n }\n impl Server {\n pub fn serve(&self) {}\n const MAX: usize = 3;\n }\n}\ntrait Run { fn run(&self); }\nenum Mode { A, B }\nfn main() {}\n"; + assert_eq!( + shape("rust", src), + [ + "net@0:mod", + "Server@1:struct", + "impl Display for Wrapper where T: Debug@1:impl", + "fmt@2:method", + "impl Server@1:impl", + "serve@2:method", + "MAX@2:const", + "Run@0:trait", + "run@1:method", + "Mode@0:enum", + "main@0:fn", + ] + ); + } + + #[test] + fn go_methods_carry_their_receiver() { + let src = "package main\n\ntype Server struct{}\ntype Handler interface{ Serve() }\ntype ID int\nconst Max = 3\nfunc (s *Server) Serve() {}\nfunc main() {}\n"; + assert_eq!( + shape("go", src), + [ + "Server@0:struct", + "Handler@0:interface", + "ID@0:type", + "Max@0:const", + "(*Server).Serve@0:method", + "main@0:fn", + ] + ); + } + + #[test] + fn python_classes_hold_their_methods() { + let src = "class A:\n def f(self):\n def inner():\n pass\n\n@decorator\ndef g():\n pass\n"; + assert_eq!( + shape("python", src), + ["A@0:class", "f@1:method", "inner@2:fn", "g@0:fn"] + ); + } + + #[test] + fn javascript_finds_classes_methods_and_arrow_functions() { + let src = "class Widget {\n render() {}\n}\nfunction boot() {}\nconst handler = () => {};\nconst n = 3;\n"; + assert_eq!( + shape("javascript", src), + [ + "Widget@0:class", + "render@1:method", + "boot@0:fn", + "handler@0:fn" + ] + ); + } + + #[test] + fn typescript_adds_its_type_declarations() { + let src = "interface Props { name: string }\ntype Id = number;\nenum Color { Red }\nexport class View {\n show(): void {}\n}\nnamespace Util { export function f() {} }\n"; + let got = shape("typescript", src); + assert_eq!( + got, + [ + "Props@0:interface", + "Id@0:type", + "Color@0:enum", + "View@0:class", + "show@1:method", + "Util@0:mod", + "f@1:fn", + ] + ); + let tsx = shape("tsx", "function App() { return
; }\n"); + assert_eq!(tsx, ["App@0:fn"]); + } + + #[test] + fn c_and_cpp_find_functions_through_their_declarators() { + let c = "struct point { int x; };\ntypedef int id_t;\nstatic char *name(void) { return 0; }\nint main(void) { return 0; }\n"; + assert_eq!( + shape("c", c), + ["point@0:struct", "id_t@0:type", "name@0:fn", "main@0:fn"] + ); + let cpp = "namespace app {\nclass Server {\n public:\n void serve();\n};\nvoid Server::serve() {}\n}\n"; + assert_eq!( + shape("cpp", cpp), + [ + "app@0:namespace", + "Server@1:class", + "serve@2:method", + "Server::serve@1:fn", + ] + ); + } + + #[test] + fn java_nests_members_in_their_class() { + let src = "class App {\n App() {}\n void run() {}\n interface Listener { void on(); }\n}\nenum Kind { A }\n"; + assert_eq!( + shape("java", src), + [ + "App@0:class", + "App@1:ctor", + "run@1:method", + "Listener@1:interface", + "on@2:method", + "Kind@0:enum", + ] + ); + } + + #[test] + fn markdown_headings_nest_by_level() { + let src = "# Title\n\nintro\n\n## Install\n\ntext\n\n### From source\n\n## Usage\n"; + assert_eq!( + shape("markdown", src), + ["Title@0:#", "Install@1:#", "From source@2:#", "Usage@1:#",] + ); + // tree-sitter-md opens no section for an underlined heading, so it + // names only its own line. + assert_eq!( + shape("markdown", "Setext\n======\n\ntext\n"), + ["Setext@0:#"] + ); + } + + #[test] + fn ruby_and_bash_have_outlines_too() { + assert_eq!( + shape("ruby", "module M\n class C\n def go; end\n end\nend\n"), + ["M@0:mod", "C@1:class", "go@2:method"] + ); + assert_eq!( + shape("bash", "setup() {\n :\n}\nfunction run { :; }\n"), + ["setup@0:fn", "run@0:fn"] + ); + } + + #[test] + fn a_language_without_a_query_has_an_empty_outline() { + assert!(outline_of("json", "{\"a\": 1}").is_empty()); + } + + #[test] + fn the_chain_at_a_position_runs_outermost_to_innermost() { + let src = "mod net {\n impl Server {\n fn serve(&self) {\n let x = 1;\n }\n }\n}\nfn main() {}\n"; + let outline = outline_of("rust", src); + let names = |pos: Position| -> Vec { + outline + .chain_at(pos) + .into_iter() + .map(|i| outline.symbols[i].name.clone()) + .collect() + }; + assert_eq!(names(Position::new(3, 12)), ["net", "impl Server", "serve"]); + assert_eq!(names(Position::new(1, 0)), ["net"]); + assert_eq!(names(Position::new(7, 3)), ["main"]); + assert_eq!(names(Position::new(6, 0)), ["net"]); + assert!(names(Position::new(6, 2)).is_empty()); + let serve = outline + .symbols + .iter() + .position(|s| s.name == "serve") + .unwrap(); + assert_eq!(outline.container_path(serve), ["net", "impl Server"]); + // Lands on the name, not on the start of the item. + assert_eq!(outline.symbols[serve].selection, Position::new(2, 11)); + } + + #[test] + fn a_language_servers_nested_answer_flattens_the_same_way() { + let p = |l, c| Position::new(l, c); + let outline = Outline::from_nodes(vec![ + SymbolNode { + name: "later".into(), + kind: SymbolKind::from_lsp(12), + range: p(10, 0)..p(12, 1), + selection: p(10, 3), + children: vec![], + }, + SymbolNode { + name: "Server".into(), + kind: SymbolKind::from_lsp(5), + range: p(0, 0)..p(8, 1), + selection: p(0, 6), + children: vec![SymbolNode { + name: "serve".into(), + kind: SymbolKind::from_lsp(6), + range: p(2, 2)..p(4, 3), + selection: p(2, 6), + children: vec![], + }], + }, + ]); + let got: Vec<_> = outline + .symbols + .iter() + .map(|s| (s.name.as_str(), s.depth, s.parent)) + .collect(); + assert_eq!( + got, + [ + ("Server", 0, None), + ("serve", 1, Some(0)), + ("later", 0, None) + ] + ); + assert_eq!(outline.chain_at(p(3, 0)), [0, 1]); + } +} diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c48d7ba1..362c7c07 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -125,10 +125,16 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SearchTabActions => "Commands", L10nKey::SearchTabTerminals => "Terminals", L10nKey::SearchTabHosts => "Hosts", + L10nKey::SearchTabSymbols => "Symbols", L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…", L10nKey::SearchPlaceholderActions => "Search commands…", L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…", L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…", + L10nKey::SearchPlaceholderSymbols => "Go to a symbol in this file…", + L10nKey::SearchSymbolsNone => "No symbols in this file", + L10nKey::SearchSymbolsNoneHint => { + "Symbols are listed for Rust, Go, Python, JavaScript, TypeScript, C, C++, Java, Ruby, shell and Markdown." + } L10nKey::SearchTabFiles => "Files", L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…", L10nKey::SearchFilesNoRoots => "No project to search", @@ -1213,6 +1219,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { "These lines are new: the staged version has nothing here." } L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})", + L10nKey::EditorGoToSymbolAction => "Go to Symbol in Editor…", + L10nKey::EditorNavigateBack => "Go Back", + L10nKey::EditorNavigateForward => "Go Forward", L10nKey::EditorSaveAs => "Save as", L10nKey::EditorSaveAsAction => "Save As…", L10nKey::EditorSaveAsPlaceholder => "Full path to save to", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a1af818f..e90fa5e3 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -126,10 +126,16 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "コマンド", L10nKey::SearchTabTerminals => "ターミナル", L10nKey::SearchTabHosts => "ホスト", + L10nKey::SearchTabSymbols => "シンボル", L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…", L10nKey::SearchPlaceholderActions => "コマンドを検索…", L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…", L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…", + L10nKey::SearchPlaceholderSymbols => "このファイル内のシンボルへ移動…", + L10nKey::SearchSymbolsNone => "このファイルにシンボルはありません", + L10nKey::SearchSymbolsNoneHint => { + "Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、シェル、Markdown のシンボルを一覧できます。" + } L10nKey::SearchTabFiles => "ファイル", L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…", L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません", @@ -1261,6 +1267,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "新しく追加された行です。ステージ済みの版にはここに何もありません。" } L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})", + L10nKey::EditorGoToSymbolAction => "エディタ内のシンボルへ移動…", + L10nKey::EditorNavigateBack => "戻る", + L10nKey::EditorNavigateForward => "進む", L10nKey::EditorSaveAs => "名前を付けて保存", L10nKey::EditorSaveAsAction => "名前を付けて保存…", L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 408feb7d..7e06a0f4 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -174,10 +174,14 @@ l10n_keys! { SearchTabActions, SearchTabTerminals, SearchTabHosts, + SearchTabSymbols, SearchPlaceholderAll, SearchPlaceholderActions, SearchPlaceholderTerminals, SearchPlaceholderHosts, + SearchPlaceholderSymbols, + SearchSymbolsNone, + SearchSymbolsNoneHint, SearchTabFiles, SearchPlaceholderFiles, SearchFilesNoRoots, @@ -945,6 +949,9 @@ l10n_keys! { EditorGitPeekSummary, EditorGitPeekAddedOnly, EditorGoToLinePlaceholder, + EditorGoToSymbolAction, + EditorNavigateBack, + EditorNavigateForward, EditorSaveAs, EditorSaveAsAction, EditorSaveAsPlaceholder, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index b0ab0d7f..b8c3658b 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -110,10 +110,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "命令", L10nKey::SearchTabTerminals => "终端", L10nKey::SearchTabHosts => "主机", + L10nKey::SearchTabSymbols => "符号", L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…", L10nKey::SearchPlaceholderActions => "搜索命令…", L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…", L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…", + L10nKey::SearchPlaceholderSymbols => "跳转到本文件中的符号…", + L10nKey::SearchSymbolsNone => "此文件中没有符号", + L10nKey::SearchSymbolsNoneHint => { + "支持 Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、Shell 和 Markdown 的符号。" + } L10nKey::SearchTabFiles => "文件", L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…", L10nKey::SearchFilesNoRoots => "没有可搜索的项目", @@ -1131,6 +1137,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorGitPeekSummary => "与暂存版本相比 −{removed} +{added} 行", L10nKey::EditorGitPeekAddedOnly => "这些是新增的行:暂存版本中此处没有内容。", L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})", + L10nKey::EditorGoToSymbolAction => "跳转到编辑器中的符号…", + L10nKey::EditorNavigateBack => "后退", + L10nKey::EditorNavigateForward => "前进", L10nKey::EditorSaveAs => "另存为", L10nKey::EditorSaveAsAction => "另存为…", L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e9bbfbc8..2b15fa14 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -332,6 +332,11 @@ fn steals_a_control_code(chord: &str) -> bool { /// simply swallows the byte does not belong on this list. fn control_code_binding_allowed(action: &str, chord: &str) -> bool { matches!(action, "EditorSave" | "EditorGoToLine") + // Bound in the text field's context only, and handled only by the + // code editor: in the terminal the chord is never matched, and in any + // other field it falls through. VS Code's Go Forward, ⌃⇧-, folds to + // Ctrl+_ (US) there. + || action_context(action) == Some(crate::ui::code_editor::NAV_KEY_CONTEXT) || (cfg!(not(target_os = "macos")) && chord == "ctrl-v") } @@ -630,6 +635,16 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { ("EditorSave", "secondary-s"), ("EditorSaveAs", "secondary-shift-s"), ("EditorGoToLine", "ctrl-g"), + // VS Code's chords. ⌘⇧O is the workspace switcher everywhere but the + // editor, which takes it back while it has the focus (see + // `action_context`). Back and Forward are ⌃- and ⌃⇧- on macOS; off + // it ⌃- is Decrease Font Size, so Alt+←/→ as VS Code has them there. + ("EditorGoToSymbol", "secondary-shift-o"), + ("EditorNavigateBack", per_platform("ctrl--", "alt-left")), + ( + "EditorNavigateForward", + per_platform("ctrl-shift--", "alt-right"), + ), // Unbound: ⌘N is New Window, and the editor's header has a + for it. ("EditorNewFile", ""), // The language-server commands. Unbound here — a default on a bare @@ -944,6 +959,18 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Terminal, t(L10nKey::LspFormatDocument).to_string(), ), + "EditorGoToSymbol" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGoToSymbolAction).to_string(), + ), + "EditorNavigateBack" => ( + CommandGroup::Terminal, + t(L10nKey::EditorNavigateBack).to_string(), + ), + "EditorNavigateForward" => ( + CommandGroup::Terminal, + t(L10nKey::EditorNavigateForward).to_string(), + ), "EditorNewFile" => ( CommandGroup::Terminal, t(L10nKey::EditorNewFile).to_string(), @@ -1522,6 +1549,9 @@ fn action_context(action: &str) -> Option<&'static str> { // carries on to the PTY as SYN (#677). "AlternatePaste" => Some("Terminal && !alt_screen"), "ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"), + "EditorGoToSymbol" | "EditorNavigateBack" | "EditorNavigateForward" => { + Some(crate::ui::code_editor::NAV_KEY_CONTEXT) + } _ => None, } } @@ -1654,6 +1684,21 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), "EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None), "EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None), + "EditorGoToSymbol" => KeyBinding::new( + keystroke, + EditorGoToSymbol, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorNavigateBack" => KeyBinding::new( + keystroke, + EditorNavigateBack, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorNavigateForward" => KeyBinding::new( + keystroke, + EditorNavigateForward, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), "EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None), "EditorGoToDefinition" => KeyBinding::new(keystroke, EditorGoToDefinition, None), "EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None), @@ -3004,4 +3049,51 @@ mod gpui_tests { } }); } + + /// Go to Symbol and Back/Forward sit on chords the window already uses + /// (⌘⇧O is the workspace switcher; off macOS Alt+←/→ move between + /// panes). Bound at the text field's depth and after the window's table, + /// they rank first inside the editor, and a terminal never sees them. + #[gpui::test] + fn editor_navigation_chords_outrank_the_window_inside_a_text_field(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json(cx, "{}"); + let in_input = |keys: &str| -> Vec<&'static str> { + let input = [Keystroke::parse(keys).expect("the keystroke parses")]; + let context = [ + gpui::KeyContext::parse("Workspace").expect("the context parses"), + gpui::KeyContext::parse("Input").expect("the context parses"), + ]; + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &context) + .0 + .iter() + .map(|b| b.action().name()) + .collect() + }; + let (back, forward) = match cfg!(target_os = "macos") { + true => ("ctrl--", "ctrl-shift--"), + false => ("alt-left", "alt-right"), + }; + for (keys, action) in [ + ("secondary-shift-o", EditorGoToSymbol::name_for_type()), + (back, EditorNavigateBack::name_for_type()), + (forward, EditorNavigateForward::name_for_type()), + ] { + assert_eq!( + in_input(keys).first(), + Some(&action), + "{keys} in the editor" + ); + assert!(!fired(cx, keys).contains(&action), "{keys} in a terminal"); + } + assert_eq!( + fired(cx, "secondary-shift-o").first(), + Some(&ToggleSwitcher::name_for_type()), + "a terminal still opens the switcher" + ); + }); + } } diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 9214b07b..7dea7c65 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -116,6 +116,11 @@ pub enum CommandKind { line: Option, column: Option, }, + /// A Go to Symbol row: this 0-based place in the file in front. + GoToSymbol { + line: u32, + column: u32, + }, /// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…). OpenSshConnect(String), SetTheme(usize), @@ -270,6 +275,7 @@ impl CommandKind { | SetTheme(_) | GoToTab { .. } | OpenFile { .. } + | GoToSymbol { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) @@ -406,6 +412,7 @@ impl CommandKind { | SetTheme(_) | GoToTab { .. } | OpenFile { .. } + | GoToSymbol { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index 19dbaeb5..dab40d20 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -30,6 +30,9 @@ pub(crate) enum SearchTab { Terminals, Sessions, Hosts, + /// Go to Symbol: the symbols of the file in front of the editor. Like + /// Files, reached by its chord (or the breadcrumbs), never by the row. + Symbols, } impl SearchTab { @@ -57,6 +60,7 @@ impl SearchTab { SearchTab::Terminals => L10nKey::SearchTabTerminals, SearchTab::Sessions => L10nKey::SearchTabSessions, SearchTab::Hosts => L10nKey::SearchTabHosts, + SearchTab::Symbols => L10nKey::SearchTabSymbols, }) } @@ -68,6 +72,7 @@ impl SearchTab { SearchTab::Terminals => L10nKey::SearchPlaceholderTerminals, SearchTab::Sessions => L10nKey::SearchPlaceholderSessions, SearchTab::Hosts => L10nKey::SearchPlaceholderHosts, + SearchTab::Symbols => L10nKey::SearchPlaceholderSymbols, }) } @@ -102,5 +107,6 @@ mod tests { assert!(!SearchTab::ORDER.contains(&SearchTab::Files)); // Tab out of Go to File lands on the row again. assert_eq!(SearchTab::Files.step(true), SearchTab::Terminals); + assert!(!SearchTab::ORDER.contains(&SearchTab::Symbols)); } } diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index 0652cbd2..969e9ea5 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -90,6 +90,10 @@ pub(crate) struct Catalog { /// The project's files, from the window's last walk of it. A fresh walk /// may land after the search opens (`Tty7App::refresh_file_index`). pub files: FileList, + /// The symbols of the file in front of the editor, in document order, + /// each with how deep it sits — filled only for Go to Symbol + /// (`Tty7App::editor_go_to_symbol`). + pub symbols: Vec<(usize, Item)>, } impl Catalog { @@ -109,6 +113,7 @@ impl Catalog { sessions: Vec::new(), sessions_here: 0, files: FileList::default(), + symbols: Vec::new(), } } @@ -123,6 +128,7 @@ impl Catalog { })), SearchTab::Hosts => Some(Box::new(Hosts(&self.hosts))), SearchTab::Files => Some(Box::new(Files(&self.files))), + SearchTab::Symbols => Some(Box::new(Symbols(&self.symbols))), } } @@ -421,6 +427,52 @@ impl Source for Sessions<'_> { /// A typed address outranks any saved host: typing one is saying where to go. const TYPED_ADDRESS_SCORE: i32 = 10_000; +/// Go to Symbol's rows. Browsing shows the file's shape, each symbol +/// indented under what holds it; a search ranks them flat, each naming what +/// holds it instead. +struct Symbols<'a>(&'a [(usize, Item)]); + +/// How far one level of nesting indents a symbol while browsing. +const SYMBOL_INDENT: &str = " "; + +impl Source for Symbols<'_> { + fn tab(&self) -> SearchTab { + SearchTab::Symbols + } + + fn browse(&self, _cx: &App) -> Vec
{ + if self.0.is_empty() { + return Vec::new(); + } + vec![Section { + title: None, + rows: self + .0 + .iter() + .map(|(depth, item)| { + let mut item = item.clone(); + item.title = format!("{}{}", SYMBOL_INDENT.repeat(*depth), item.title); + item.subtitle = None; + Row::Item(item) + }) + .collect(), + }] + } + + fn highlights(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn on_the_empty_all_tab(&self) -> bool { + false + } + + fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> { + let items: Vec = self.0.iter().map(|(_, item)| item.clone()).collect(); + rank(&items, query, |_| 0) + } +} + struct Hosts<'a>(&'a [Item]); impl Source for Hosts<'_> { diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index 178cc2c6..a238440f 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -271,6 +271,10 @@ impl ListDelegate for SearchDelegate { Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => { t(L10nKey::SearchSessionsEmptyHint) } + Scope::Tab(SearchTab::Symbols) if self.catalog.symbols.is_empty() => { + headline = t(L10nKey::SearchSymbolsNone); + t(L10nKey::SearchSymbolsNoneHint) + } _ => t(L10nKey::PaletteTryDifferentSearch), }; // The headline in body ink and the way out under it in caption ink: @@ -373,6 +377,13 @@ pub enum SearchEvent { PreviewTheme(usize), /// Put back the theme that was live before the preview started. CancelThemePreview, + /// Show this 0-based place in the file Go to Symbol is listing, leaving + /// the keyboard in the search. Closing the search without a choice puts + /// the caret back (`Tty7App::close_search`). + PreviewSymbol { + line: u32, + column: u32, + }, } pub struct SearchView { @@ -389,6 +400,11 @@ pub struct SearchView { /// How many session lists have arrived since the search opened. A test /// waits on it: the scan runs on a real thread and lands when it lands. sessions_landed: usize, + /// The symbol Go to Symbol opened on — the one around the caret — which + /// is not previewed until the highlight has moved: opening the picker + /// must not move the caret. + symbol_initial: Option, + symbol_moved: bool, _sub: Subscription, } @@ -420,6 +436,8 @@ impl SearchView { parked_query: None, previewing: None, sessions_landed: 0, + symbol_initial: None, + symbol_moved: false, _sub, } } @@ -526,6 +544,28 @@ impl SearchView { self.update_catalog(|catalog| catalog.files = files, window, cx); } + /// Go to Symbol's rows, with the highlight on `selected` — the symbol the + /// caret is in — when it is one of them. + pub(crate) fn set_symbols( + &mut self, + symbols: Vec<(usize, Item)>, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.symbol_moved = false; + self.symbol_initial = selected.clone(); + self.update_catalog(|catalog| catalog.symbols = symbols, window, cx); + if let Some(kind) = selected { + self.list.update(cx, |state, cx| { + if let Some(ix) = state.delegate().position_of(&kind) { + state.set_selected_index(Some(ix), window, cx); + state.scroll_to_selected_item(window, cx); + } + }); + } + } + /// Changes part of the catalog under an open list. The highlight stays on /// the row it was on when that row is still there, so a list that fills in /// under the cursor does not move what Return runs. @@ -554,6 +594,11 @@ impl SearchView { cx.notify(); } + #[cfg(test)] + pub(crate) fn symbol_count(&self) -> usize { + self.catalog.symbols.len() + } + /// The tab showing — or, in a row's own list, the one Escape returns to. pub(crate) fn tab(&self) -> SearchTab { self.tab @@ -689,6 +734,17 @@ impl SearchView { false => cx.emit(SearchEvent::Dismiss), }, ListEvent::Select(ix) => { + if !self.in_sub_list() + && self.tab == SearchTab::Symbols + && let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix) + && let CommandKind::GoToSymbol { line, column } = item.kind + { + if self.symbol_moved || self.symbol_initial.as_ref() != Some(&item.kind) { + self.symbol_moved = true; + cx.emit(SearchEvent::PreviewSymbol { line, column }); + } + return; + } if self.in_sub_list() && let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix) && let CommandKind::SetTheme(i) = item.kind