mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
Merge branch 'ide/nav' into feat/editor-ide
# Conflicts: # src/ui/code_editor.rs # src/ui/keymap.rs
This commit is contained in:
@@ -127,6 +127,9 @@ actions!(
|
||||
EditorSave,
|
||||
EditorSaveAs,
|
||||
EditorGoToLine,
|
||||
EditorGoToSymbol,
|
||||
EditorNavigateBack,
|
||||
EditorNavigateForward,
|
||||
EditorNewFile,
|
||||
EditorGoToDefinition,
|
||||
EditorQuickFix,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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>) {
|
||||
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| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "保存先のフルパス",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => "要保存到的完整路径",
|
||||
|
||||
@@ -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<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),
|
||||
"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"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user