feat(editor): go to symbol, breadcrumbs, and back/forward navigation

Go to Symbol (Cmd-Shift-O in the editor) lists the file's outline on a new
Symbols tab of Search Everywhere: indented by nesting while browsing, ranked
flat with the containing symbols as a subtitle while searching. Arrowing
through the rows previews each symbol; Escape puts the caret and the scroll
back, Return keeps it.

The outline comes from the tree the highlighter already parsed, with a small
query per language (Rust, Go, Python, JavaScript, TypeScript/TSX, C, C++,
Java, Markdown, Ruby, shell). Tty7App::editor_set_document_symbols lets a
language server's documentSymbol answer replace it per buffer.

A breadcrumb row over the text shows the file's path from its project root
and the symbols around the caret; the symbols open Go to Symbol.

Back and forward (Ctrl-- / Ctrl-Shift-- on macOS, Alt-Left/Right elsewhere)
walk a per-tab history. Jumps are noticed by sampling the caret whenever the
editor draws: a change of file, or a move of ten lines or more without an
edit, records where the caret was. Quick open, go to line, file links and
anything that goes through open_file_in_editor_at are therefore captured
without hooks of their own; a place in a closed file reopens it.
This commit is contained in:
l0ng-ai
2026-09-28 17:15:33 +08:00
parent e06ba47017
commit ec5aee0f0c
14 changed files with 2098 additions and 0 deletions
+3
View File
@@ -127,6 +127,9 @@ actions!(
EditorSave,
EditorSaveAs,
EditorGoToLine,
EditorGoToSymbol,
EditorNavigateBack,
EditorNavigateForward,
EditorNewFile,
OpenSshProfiles,
RestartSshSession,
+11
View File
@@ -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,
+14
View File
@@ -22,6 +22,11 @@ use crate::ui::editor_text::{self, EditorConfig, Indent, LineEnding, TextFormat}
use crate::ui::host_ops::{HostId, HostOps, MTime, SharedHost, WatchSub};
use crate::ui::i18n::{L10nKey, t, t_fmt};
mod nav;
pub(crate) mod outline;
pub(crate) use nav::KEY_CONTEXT as NAV_KEY_CONTEXT;
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
@@ -281,6 +286,8 @@ pub(crate) struct EditorPanelState {
watched_dirs: HashSet<PathBuf>,
watched_files: HashSet<PathBuf>,
events_tx: smol::channel::Sender<Vec<PathBuf>>,
/// Back/forward history, outlines and Go to Symbol's preview.
nav: nav::EditorNav,
}
impl EditorPanelState {
@@ -323,6 +330,7 @@ impl EditorPanelState {
watched_dirs: HashSet::new(),
watched_files: HashSet::new(),
events_tx: tx,
nav: nav::EditorNav::default(),
}
}
}
@@ -2269,6 +2277,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
@@ -2802,6 +2811,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>) {
self.editor.recorded.remove(&tab);
self.editor.nav.forget_tab(tab);
editor_session::remove(cx, tab);
}
}
@@ -2823,6 +2833,8 @@ impl Tty7App {
if !self.code_panel_visible() {
return None;
}
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 => {
@@ -2889,6 +2901,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));
@@ -2915,6 +2928,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| {
File diff suppressed because it is too large Load Diff
+778
View File
@@ -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<Self> {
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<Position>,
/// 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<usize>,
}
/// 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<Position>,
pub selection: Position,
pub children: Vec<SymbolNode>,
}
/// 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<Symbol>,
}
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<SymbolNode>) -> Self {
fn walk(
nodes: Vec<SymbolNode>,
parent: Option<usize>,
depth: usize,
out: &mut Vec<Symbol>,
) {
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<usize> {
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.<kind>` 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<T> Display for Wrapper<T>` — 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<String> {
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<Arc<Query>> {
static QUERIES: LazyLock<Mutex<HashMap<String, Option<Arc<Query>>>>> =
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::<Vec<_>>().join(" ");
if out.chars().count() > NAME_MAX_CHARS {
out = out.chars().take(NAME_MAX_CHARS - 1).collect::<String>() + "…";
}
out
}
fn text_of(text: &Rope, range: Range<usize>) -> 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<usize>,
name: String,
selection: usize,
}
let mut found: HashMap<usize, Found> = 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::<Vec<_>>()
.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> = 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<Symbol> = 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<String> {
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<T> Display for Wrapper<T> 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<T> Display for Wrapper<T> 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 <div/>; }\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<String> {
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]);
}
}
+9
View File
@@ -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",
@@ -1203,6 +1209,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorGoToLineAction => "Go to Line…",
L10nKey::EditorCopyRelativePath => "Copy Relative Path",
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",
+9
View File
@@ -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 => "検索するプロジェクトがありません",
@@ -1251,6 +1257,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorGoToLineAction => "行へ移動…",
L10nKey::EditorCopyRelativePath => "相対パスをコピー",
L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})",
L10nKey::EditorGoToSymbolAction => "エディタ内のシンボルへ移動…",
L10nKey::EditorNavigateBack => "戻る",
L10nKey::EditorNavigateForward => "進む",
L10nKey::EditorSaveAs => "名前を付けて保存",
L10nKey::EditorSaveAsAction => "名前を付けて保存…",
L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス",
+7
View File
@@ -174,10 +174,14 @@ l10n_keys! {
SearchTabActions,
SearchTabTerminals,
SearchTabHosts,
SearchTabSymbols,
SearchPlaceholderAll,
SearchPlaceholderActions,
SearchPlaceholderTerminals,
SearchPlaceholderHosts,
SearchPlaceholderSymbols,
SearchSymbolsNone,
SearchSymbolsNoneHint,
SearchTabFiles,
SearchPlaceholderFiles,
SearchFilesNoRoots,
@@ -937,6 +941,9 @@ l10n_keys! {
EditorGoToLineAction,
EditorCopyRelativePath,
EditorGoToLinePlaceholder,
EditorGoToSymbolAction,
EditorNavigateBack,
EditorNavigateForward,
EditorSaveAs,
EditorSaveAsAction,
EditorSaveAsPlaceholder,
+9
View File
@@ -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 => "没有可搜索的项目",
@@ -1123,6 +1129,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorGoToLineAction => "跳转到行…",
L10nKey::EditorCopyRelativePath => "复制相对路径",
L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})",
L10nKey::EditorGoToSymbolAction => "跳转到编辑器中的符号…",
L10nKey::EditorNavigateBack => "后退",
L10nKey::EditorNavigateForward => "前进",
L10nKey::EditorSaveAs => "另存为",
L10nKey::EditorSaveAsAction => "另存为…",
L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径",
+45
View File
@@ -292,6 +292,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")
}
@@ -590,6 +595,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", ""),
("OpenSshProfiles", ""),
@@ -878,6 +893,18 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Terminal,
t(L10nKey::EditorGoToLineAction).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(),
@@ -1440,6 +1467,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,
}
}
@@ -1572,6 +1602,21 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"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),
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
+7
View File
@@ -116,6 +116,11 @@ pub enum CommandKind {
line: Option<u32>,
column: Option<u32>,
},
/// 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(_)
+6
View File
@@ -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));
}
}
+52
View File
@@ -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<Section> {
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<Item> {
Vec::new()
}
fn on_the_empty_all_tab(&self) -> bool {
false
}
fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
let items: Vec<Item> = self.0.iter().map(|(_, item)| item.clone()).collect();
rank(&items, query, |_| 0)
}
}
struct Hosts<'a>(&'a [Item]);
impl Source for Hosts<'_> {
+56
View File
@@ -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<CommandKind>,
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<CommandKind>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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