diff --git a/Cargo.lock b/Cargo.lock index ba4e019..89a24ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5241,15 +5241,16 @@ dependencies = [ [[package]] name = "mockaco-core" version = "0.1.0" -source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=4625ca65df20d890522dcb2035caeb1870d1c27e#4625ca65df20d890522dcb2035caeb1870d1c27e" +source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=0fa73960f6c140dd35ceff35d0197023c8c795c3#0fa73960f6c140dd35ceff35d0197023c8c795c3" dependencies = [ + "regex", "ropey 1.6.1", ] [[package]] name = "mockaco-diff" version = "0.1.0" -source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=4625ca65df20d890522dcb2035caeb1870d1c27e#4625ca65df20d890522dcb2035caeb1870d1c27e" +source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=0fa73960f6c140dd35ceff35d0197023c8c795c3#0fa73960f6c140dd35ceff35d0197023c8c795c3" dependencies = [ "mockaco-core", "similar 3.2.0", @@ -5258,7 +5259,7 @@ dependencies = [ [[package]] name = "mockaco-gpui" version = "0.1.0" -source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=4625ca65df20d890522dcb2035caeb1870d1c27e#4625ca65df20d890522dcb2035caeb1870d1c27e" +source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=0fa73960f6c140dd35ceff35d0197023c8c795c3#0fa73960f6c140dd35ceff35d0197023c8c795c3" dependencies = [ "gpui-ce", "mockaco-core", @@ -5270,7 +5271,7 @@ dependencies = [ [[package]] name = "mockaco-language" version = "0.1.0" -source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=4625ca65df20d890522dcb2035caeb1870d1c27e#4625ca65df20d890522dcb2035caeb1870d1c27e" +source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=0fa73960f6c140dd35ceff35d0197023c8c795c3#0fa73960f6c140dd35ceff35d0197023c8c795c3" dependencies = [ "mockaco-core", "streaming-iterator", @@ -5281,7 +5282,7 @@ dependencies = [ [[package]] name = "mockaco-renderer" version = "0.1.0" -source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=4625ca65df20d890522dcb2035caeb1870d1c27e#4625ca65df20d890522dcb2035caeb1870d1c27e" +source = "git+https://github.com/Far-Beyond-Pulsar/Mockaco?rev=0fa73960f6c140dd35ceff35d0197023c8c795c3#0fa73960f6c140dd35ceff35d0197023c8c795c3" dependencies = [ "mockaco-core", ] diff --git a/Cargo.toml b/Cargo.toml index 091a11f..77433e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,10 @@ ui_types_common = { path = "../../../crates/editor/ui_types_common" } # Keep Mockaco and WGPUI on the same tree-sitter 0.25-compatible API. # The host adapter bridges clipboard/history/navigation on this API. -mockaco-gpui = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "4625ca65df20d890522dcb2035caeb1870d1c27e", features = ["native-wgpui"] } -mockaco-language = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "4625ca65df20d890522dcb2035caeb1870d1c27e" } -mockaco-core = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "4625ca65df20d890522dcb2035caeb1870d1c27e" } -mockaco-diff = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "4625ca65df20d890522dcb2035caeb1870d1c27e" } +mockaco-gpui = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "0fa73960f6c140dd35ceff35d0197023c8c795c3", features = ["native-wgpui"] } +mockaco-language = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "0fa73960f6c140dd35ceff35d0197023c8c795c3" } +mockaco-core = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "0fa73960f6c140dd35ceff35d0197023c8c795c3" } +mockaco-diff = { git = "https://github.com/Far-Beyond-Pulsar/Mockaco", rev = "0fa73960f6c140dd35ceff35d0197023c8c795c3" } # Engine dependencies engine_state = { path = "../../../crates/core/engine_state" } diff --git a/src/script_editor/mod.rs b/src/script_editor/mod.rs index 5879e22..6e76e2c 100644 --- a/src/script_editor/mod.rs +++ b/src/script_editor/mod.rs @@ -725,20 +725,23 @@ impl ScriptEditor { .action_if("Find Next", EditorFindNext, open) .action_if("Find Previous", EditorFindPrevious, open) .separator() - .action_if("Format Document", EditorFormatDocument, open), + .action_if("Format Document", EditorFormatDocument, open) + .action_if("Format Selection", EditorFormatSelection, open), ) .menu( EditorMenu::new("Selection") .action_if("Select All", EditorSelectAll, open) .separator() .action_if("Add Cursor Above", EditorAddCursorAbove, open) - .action_if("Add Cursor Below", EditorAddCursorBelow, open), + .action_if("Add Cursor Below", EditorAddCursorBelow, open) + .action_if("Add Next Occurrence", EditorAddNextOccurrence, open), ) .menu(EditorMenu::new("View").action_if("Unfold All", EditorUnfoldAll, open)) .menu( EditorMenu::new("Go") .action_if("Go to Line…", EditorGoToLine, open) .action_if("Go to Symbol in File…", EditorGoToSymbol, open) + .action_if("Go to Symbol in Workspace…", EditorGoToWorkspaceSymbol, open) .separator() .action_if("Go to Definition", EditorGoToDefinition, open) .action_if("Go to References", EditorGoToReferences, open) diff --git a/src/script_editor/text_editor.rs b/src/script_editor/text_editor.rs index 96da445..c2ad46c 100644 --- a/src/script_editor/text_editor.rs +++ b/src/script_editor/text_editor.rs @@ -38,12 +38,20 @@ actions!( EditorFindNext, EditorFindPrevious, EditorFormatDocument, + /// Formats the selected lines (the whole document without a + /// selection). + EditorFormatSelection, EditorAddCursorAbove, EditorAddCursorBelow, + /// Ctrl+D: selects the word under the caret, then adds the next + /// occurrence of the selection. + EditorAddNextOccurrence, EditorGoToLine, EditorGoToDefinition, EditorGoToReferences, EditorGoToSymbol, + /// Searches the symbols of the whole workspace by name. + EditorGoToWorkspaceSymbol, EditorGoToParentModule, EditorExpandMacro, EditorOpenCargoToml, @@ -51,6 +59,11 @@ actions!( EditorGenerateStressFile, /// Closes the find, go-to-line or location bar. EditorCloseBar, + /// The find bar's Match Case, Match Whole Word and Use Regular + /// Expression switches. + EditorToggleFindCase, + EditorToggleFindWholeWord, + EditorToggleFindRegex, ] ); @@ -106,6 +119,9 @@ pub struct TextEditor { /// What Go to References or Go to Symbol found, shown by the panel of /// the file it was asked from. locations: Option, + /// Counts location requests, so only the latest one's answer is shown + /// (workspace symbols ask again on every keystroke). + locations_request: u64, } impl TextEditor { @@ -130,6 +146,7 @@ impl TextEditor { generating: false, lsp: None, locations: None, + locations_request: 0, }; // The language server arrives through the plugin API's context. if let Some(analyzer) = settings.services().language_server.clone() { diff --git a/src/script_editor/text_editor/lsp.rs b/src/script_editor/text_editor/lsp.rs index 93a496e..588bbf5 100644 --- a/src/script_editor/text_editor/lsp.rs +++ b/src/script_editor/text_editor/lsp.rs @@ -700,14 +700,17 @@ impl TextEditor { }; self.show_locations(path, title, Ok(None), cx); let origin = path.to_path_buf(); + let request = self.locations_request; cx.spawn(async move |this, cx| { let Ok(response) = response.recv_async().await else { return; }; let _ = this.update(cx, |this, cx| { - let still_asked = this.locations.as_ref().is_some_and(|list| { - list.origin == origin && list.title == title && list.items.is_none() - }); + // Only the latest request's answer, while its list is shown. + let still_asked = this.locations_request == request + && this.locations.as_ref().is_some_and(|list| { + list.origin == origin && list.title == title && list.items.is_none() + }); if !still_asked { return; } @@ -731,6 +734,9 @@ impl TextEditor { Ok(items) => (items, None), Err(reason) => (Some(Vec::new()), Some(reason.into())), }; + if items.is_none() { + self.locations_request += 1; + } self.locations = Some(LocationList { origin: path.to_path_buf(), title: title.into(), @@ -783,6 +789,38 @@ impl TextEditor { /// Formats `path` with the language server, as one edit. An answer for a /// document that changed meanwhile is dropped. pub fn format_document(&mut self, path: &std::path::Path, cx: &mut Context) { + self.format_lines(path, None, cx); + } + + /// Formats the lines the selection of `path` touches (the whole + /// document without a selection). rust-analyzer only does range + /// formatting with a nightly rustfmt, so this asks for the whole + /// document and applies the edits that fall within those lines: the + /// rest of the file is left as it is. + pub fn format_selection(&mut self, path: &std::path::Path, cx: &mut Context) { + let Ok((_, _, view, _)) = self.lsp_target_for(path, cx) else { + return; + }; + let lines = view.read(cx).surface.editor().selections().primary().and_then(|selection| { + let range = selection.ordered_range(); + if range.is_empty() { + return None; + } + let text = view.read(cx).surface.document().text().clone(); + let first = text.byte_to_line(range.start).ok()?; + // A selection ending at a line's start does not take that line. + let last = text.byte_to_line(range.end.saturating_sub(1).max(range.start)).ok()?; + Some(first..=last) + }); + self.format_lines(path, lines, cx); + } + + fn format_lines( + &mut self, + path: &std::path::Path, + lines: Option>, + cx: &mut Context, + ) { let Ok((_, analyzer, view, uri)) = self.lsp_target_for(path, cx) else { return; }; @@ -801,7 +839,10 @@ impl TextEditor { let Ok(response) = response.recv_async().await else { return; }; - let edits = position_edits(response.get("result").unwrap_or(&Value::Null)); + let mut edits = position_edits(response.get("result").unwrap_or(&Value::Null)); + if let Some(lines) = &lines { + edits.retain(|edit| edit_within_lines(edit, lines)); + } let _ = view.update(cx, |view, cx| { if view.document_version() == version { view.apply_position_edits(edits, cx); @@ -811,6 +852,39 @@ impl TextEditor { .detach(); } + /// Lists the symbols of the whole workspace whose names match `query` + /// (the server's own matching, which is fuzzy). Each call replaces the + /// previous one's answer; an empty query lists nothing. + pub fn workspace_symbols( + &mut self, + path: &std::path::Path, + query: &str, + cx: &mut Context, + ) { + let title = "Workspace Symbols"; + let analyzer = match self.lsp_target_for(path, cx) { + Ok((_, analyzer, _, _)) => analyzer, + Err(reason) => return self.show_locations(path, title, Err(reason), cx), + }; + if query.trim().is_empty() { + return self.show_locations( + path, + title, + Err("Type a name to search the workspace"), + cx, + ); + } + self.request_locations( + path, + title, + analyzer, + "workspace/symbol", + json!({ "query": query.trim() }), + cx, + |result, _| workspace_symbols(result), + ); + } + /// Opens the module that declares the one `path`'s caret is in. pub fn go_to_parent_module(&mut self, path: &std::path::Path, cx: &mut Context) { let Ok((_, analyzer, view, uri)) = self.lsp_target_for(path, cx) else { @@ -1140,6 +1214,53 @@ fn document_symbols(result: &Value, path: &std::path::Path) -> Vec out } +/// Whether `edit` lies within `lines` (an edit ending at the start of the +/// next line, as one replacing a line's end does, counts as within). +fn edit_within_lines(edit: &PositionEdit, lines: &std::ops::RangeInclusive) -> bool { + let last = *lines.end(); + let end_inside = edit.end.line <= last || (edit.end.line == last + 1 && edit.end.column == 0); + edit.start.line >= *lines.start() && end_inside +} + +/// The symbols of a `workspace/symbol` answer (`SymbolInformation[]` or +/// `WorkspaceSymbol[]`, whose location may name only the file), labelled by +/// name, with kind, container and file as detail. +fn workspace_symbols(result: &Value) -> Vec { + result + .as_array() + .into_iter() + .flatten() + .filter_map(|symbol| { + let name = symbol.get("name")?.as_str()?; + let location = symbol.get("location")?; + let path = uri_to_path(location.get("uri")?.as_str()?); + let start = location + .get("range") + .and_then(|range| range.get("start")) + .and_then(position_from) + .unwrap_or(TextPosition { line: 0, column: 0 }); + let kind = symbol_kind_name(symbol.get("kind").and_then(Value::as_u64).unwrap_or(0)); + let file = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let detail = match symbol.get("containerName").and_then(Value::as_str) { + Some(container) if !container.is_empty() => { + format!("{kind} · {container} · {file}:{}", start.line + 1) + } + _ => format!("{kind} · {file}:{}", start.line + 1), + }; + Some(LocationItem { + path, + line: start.line as u32, + character: start.column as u32, + label: name.to_owned(), + detail, + }) + }) + .collect() +} + /// LSP's `SymbolKind` by name. fn symbol_kind_name(kind: u64) -> &'static str { const NAMES: [&str; 26] = [ @@ -1351,6 +1472,42 @@ mod tests { assert!(parse_completions(json!({ "result": null })).is_none()); } + #[test] + fn workspace_symbols_take_both_answer_shapes() { + let result = json!([ + { + "name": "Player", "kind": 23, "containerName": "game", + "location": { "uri": "file:///p/src/player.rs", + "range": { "start": { "line": 4, "character": 11 }, "end": { "line": 4, "character": 17 } } } + }, + { "name": "spawn", "kind": 12, "location": { "uri": "file:///p/src/lib.rs" } }, + { "kind": 12, "location": { "uri": "file:///p/src/lib.rs" } } + ]); + let items = workspace_symbols(&result); + assert_eq!(items.len(), 2, "a symbol without a name is skipped"); + assert_eq!(items[0].label, "Player"); + assert_eq!((items[0].line, items[0].character), (4, 11)); + assert_eq!(items[0].detail, "struct · game · player.rs:5"); + assert_eq!(items[1].label, "spawn"); + assert_eq!(items[1].line, 0, "a WorkspaceSymbol may name only its file"); + assert_eq!(items[1].detail, "function · lib.rs:1"); + } + + #[test] + fn format_selection_keeps_the_edits_within_its_lines() { + let edit = |start: (usize, usize), end: (usize, usize)| PositionEdit { + start: TextPosition { line: start.0, column: start.1 }, + end: TextPosition { line: end.0, column: end.1 }, + new_text: String::new(), + }; + let lines = 3..=5; + assert!(edit_within_lines(&edit((3, 0), (3, 4)), &lines)); + assert!(edit_within_lines(&edit((5, 2), (6, 0)), &lines), "the last line's end"); + assert!(!edit_within_lines(&edit((2, 9), (3, 0)), &lines), "starts above"); + assert!(!edit_within_lines(&edit((5, 0), (6, 1)), &lines), "reaches below"); + assert!(!edit_within_lines(&edit((7, 0), (7, 1)), &lines)); + } + #[test] fn reference_results_list_every_location() { let result = json!([ diff --git a/src/script_editor/workspace_panels.rs b/src/script_editor/workspace_panels.rs index 4997b86..8f8cdb9 100644 --- a/src/script_editor/workspace_panels.rs +++ b/src/script_editor/workspace_panels.rs @@ -2,15 +2,18 @@ use super::file_explorer::FileExplorer; use super::text_editor::{ - EditorAddCursorAbove, EditorAddCursorBelow, EditorCloseBar, EditorCopy, EditorCopyPath, - EditorCut, EditorExpandMacro, EditorFind, EditorFindNext, EditorFindPrevious, - EditorFormatDocument, EditorGenerateStressFile, EditorGoToDefinition, EditorGoToLine, - EditorGoToParentModule, EditorGoToReferences, EditorGoToSymbol, EditorOpenCargoToml, - EditorPaste, EditorRedo, EditorReloadWorkspace, EditorReplace, EditorSave, EditorSelectAll, - EditorUndo, EditorUnfoldAll, LocationItem, TextEditor, + EditorAddCursorAbove, EditorAddCursorBelow, EditorAddNextOccurrence, EditorCloseBar, + EditorCopy, EditorCopyPath, EditorCut, EditorExpandMacro, EditorFind, EditorFindNext, + EditorFindPrevious, EditorFormatDocument, EditorFormatSelection, EditorGenerateStressFile, + EditorGoToDefinition, EditorGoToLine, EditorGoToParentModule, EditorGoToReferences, + EditorGoToSymbol, EditorGoToWorkspaceSymbol, EditorOpenCargoToml, EditorPaste, EditorRedo, + EditorReloadWorkspace, EditorReplace, EditorSave, EditorSelectAll, EditorToggleFindCase, + EditorToggleFindRegex, EditorToggleFindWholeWord, EditorUndo, EditorUnfoldAll, LocationItem, + TextEditor, }; use gpui::prelude::FluentBuilder as _; use gpui::*; +use mockaco_core::{CaseSensitivity, SearchQuery}; use mockaco_gpui::native::WgpuiEditorView; use rust_i18n::t; use std::path::PathBuf; @@ -40,7 +43,13 @@ pub(crate) fn bind_editor_keys(cx: &mut App) { KeyBinding::new("shift-f12", EditorGoToReferences, context), KeyBinding::new("ctrl-shift-o", EditorGoToSymbol, context), KeyBinding::new("shift-alt-f", EditorFormatDocument, context), + KeyBinding::new("ctrl-k ctrl-f", EditorFormatSelection, context), + KeyBinding::new("ctrl-d", EditorAddNextOccurrence, context), + KeyBinding::new("ctrl-t", EditorGoToWorkspaceSymbol, context), KeyBinding::new("escape", EditorCloseBar, Some(BAR_CONTEXT)), + KeyBinding::new("alt-c", EditorToggleFindCase, Some(BAR_CONTEXT)), + KeyBinding::new("alt-w", EditorToggleFindWholeWord, Some(BAR_CONTEXT)), + KeyBinding::new("alt-r", EditorToggleFindRegex, Some(BAR_CONTEXT)), ]); } @@ -52,9 +61,11 @@ enum Bar { _subscription: Subscription, }, /// Go to References / Symbol: the list lives in [`TextEditor`], this - /// filters it. + /// filters it. For workspace symbols the server does the matching, so + /// the field's text is sent as the query instead. Locations { filter: Entity, + workspace: bool, _subscription: Subscription, }, } @@ -64,9 +75,24 @@ struct FindBar { /// Shown for Replace. replacement: Option>, case_sensitive: bool, + whole_word: bool, + regex: bool, _subscriptions: Vec, } +impl FindBar { + fn search_query(&self, cx: &App) -> SearchQuery { + SearchQuery::new(self.query.read(cx).value().to_string()) + .case_sensitivity(if self.case_sensitive { + CaseSensitivity::Sensitive + } else { + CaseSensitivity::Insensitive + }) + .whole_word(self.whole_word) + .regex(self.regex) + } +} + /// One open document as a dock tab. The document itself (text, language /// server sync, dirty state) stays in [`TextEditor`]; this is the part the dock /// can place, split and drag. @@ -199,6 +225,8 @@ impl FilePanel { query, replacement, case_sensitive: false, + whole_word: false, + regex: false, _subscriptions: subscriptions, })); self.search(cx); @@ -243,19 +271,16 @@ impl FilePanel { (!text.contains('\n')).then_some(text) } - fn find_query(&self, cx: &App) -> Option<(String, bool)> { + fn find_query(&self, cx: &App) -> Option { match &self.bar { - Some(Bar::Find(find)) => { - Some((find.query.read(cx).value().to_string(), find.case_sensitive)) - } + Some(Bar::Find(find)) => Some(find.search_query(cx)), _ => None, } } fn search(&mut self, cx: &mut Context) { - if let Some((pattern, case_sensitive)) = self.find_query(cx) { - self.view - .update(cx, |view, cx| view.find(&pattern, case_sensitive, cx)); + if let Some(query) = self.find_query(cx) { + self.view.update(cx, |view, cx| view.find_query(query, cx)); cx.notify(); } } @@ -297,11 +322,13 @@ impl FilePanel { } } - fn toggle_case(&mut self, cx: &mut Context) { + /// Flips one of the find bar's switches and searches again. + fn toggle_find(&mut self, switch: fn(&mut FindBar) -> &mut bool, cx: &mut Context) { if let Some(Bar::Find(find)) = &mut self.bar { - find.case_sensitive = !find.case_sensitive; + let value = switch(find); + *value = !*value; + self.search(cx); } - self.search(cx); } /// Closes whichever bar is open and returns to the text. @@ -355,17 +382,48 @@ impl FilePanel { request: fn(&mut TextEditor, &std::path::Path, &mut Context), window: &mut Window, cx: &mut Context, + ) { + self.open_location_bar(false, None, window, cx); + self.with_editor(cx, |editor, path, cx| request(editor, path, cx)); + cx.notify(); + } + + /// Searches the workspace's symbols, starting from the selected text. + fn open_workspace_symbols(&mut self, window: &mut Window, cx: &mut Context) { + let seed = self.selected_text(cx).unwrap_or_default(); + self.open_location_bar(true, Some(seed.clone()), window, cx); + self.with_editor(cx, |editor, path, cx| editor.workspace_symbols(path, &seed, cx)); + cx.notify(); + } + + fn open_location_bar( + &mut self, + workspace: bool, + seed: Option, + window: &mut Window, + cx: &mut Context, ) { self.close_other_bar(cx); let filter = cx.new(|cx| { - let input = InputState::new(window, cx).placeholder("Filter"); + let placeholder = if workspace { "Symbol name" } else { "Filter" }; + let mut input = InputState::new(window, cx).placeholder(placeholder); + if let Some(seed) = seed { + input.set_value(seed, window, cx); + } input.focus(window, cx); input }); let subscription = cx.subscribe_in( &filter, window, - |this: &mut Self, _, event: &InputEvent, window, cx| match event { + move |this: &mut Self, input, event: &InputEvent, window, cx| match event { + InputEvent::Change if workspace => { + let query = input.read(cx).value().to_string(); + this.with_editor(cx, |editor, path, cx| { + editor.workspace_symbols(path, &query, cx) + }); + cx.notify(); + } InputEvent::Change => cx.notify(), InputEvent::PressEnter { .. } => { if let Some(item) = this.filtered_locations(cx).into_iter().next() { @@ -377,10 +435,9 @@ impl FilePanel { ); self.bar = Some(Bar::Locations { filter, + workspace, _subscription: subscription, }); - self.with_editor(cx, |editor, path, cx| request(editor, path, cx)); - cx.notify(); } /// Clears what the bar being replaced left behind. @@ -396,10 +453,18 @@ impl FilePanel { /// The listed places matching the filter, when this panel asked for them. fn filtered_locations(&self, cx: &App) -> Vec { - let Some(Bar::Locations { filter, .. }) = &self.bar else { + let Some(Bar::Locations { + filter, workspace, .. + }) = &self.bar + else { return Vec::new(); }; - let filter = filter.read(cx).value().to_lowercase(); + // The server already matched workspace symbols, fuzzily. + let filter = if *workspace { + String::new() + } else { + filter.read(cx).value().to_lowercase() + }; let Some(editor) = self.text_editor.upgrade() else { return Vec::new(); }; @@ -441,7 +506,9 @@ impl FilePanel { let muted = theme.muted_foreground; let element = match bar { Bar::Find(find) => { + let invalid = find.search_query(cx).error().is_some(); let status = match self.view.read(cx).find_status() { + _ if invalid => "Invalid expression".to_owned(), Some((_, 0)) => "No results".to_owned(), Some((Some(current), total)) => format!("{} of {total}", current + 1), Some((None, total)) => format!("{total} found"), @@ -457,8 +524,32 @@ impl FilePanel { .ghost() .xsmall() .selected(find.case_sensitive) - .tooltip("Match Case") - .on_click(cx.listener(|this, _, _, cx| this.toggle_case(cx))), + .tooltip("Match Case (Alt+C)") + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_find(|find| &mut find.case_sensitive, cx) + })), + ) + .child( + Button::new("find-whole-word") + .label("ab") + .ghost() + .xsmall() + .selected(find.whole_word) + .tooltip("Match Whole Word (Alt+W)") + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_find(|find| &mut find.whole_word, cx) + })), + ) + .child( + Button::new("find-regex") + .label(".*") + .ghost() + .xsmall() + .selected(find.regex) + .tooltip("Use Regular Expression (Alt+R)") + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_find(|find| &mut find.regex, cx) + })), ) .child( div() @@ -684,6 +775,21 @@ impl Render for FilePanel { .on_action(cx.listener(|this, _: &EditorFormatDocument, _, cx| { this.with_editor(cx, |editor, path, cx| editor.format_document(path, cx)) })) + .on_action(cx.listener(|this, _: &EditorFormatSelection, _, cx| { + this.with_editor(cx, |editor, path, cx| editor.format_selection(path, cx)) + })) + .on_action(cx.listener(|this, _: &EditorAddNextOccurrence, _, cx| { + this.view.update(cx, |view, cx| view.add_next_occurrence(cx)); + })) + .on_action(cx.listener(|this, _: &EditorToggleFindCase, _, cx| { + this.toggle_find(|find| &mut find.case_sensitive, cx) + })) + .on_action(cx.listener(|this, _: &EditorToggleFindWholeWord, _, cx| { + this.toggle_find(|find| &mut find.whole_word, cx) + })) + .on_action(cx.listener(|this, _: &EditorToggleFindRegex, _, cx| { + this.toggle_find(|find| &mut find.regex, cx) + })) .on_action(cx.listener(|this, _: &EditorAddCursorAbove, _, cx| { this.view.update(cx, |view, cx| view.add_cursor(true, cx)) })) @@ -704,6 +810,9 @@ impl Render for FilePanel { .on_action(cx.listener(|this, _: &EditorGoToSymbol, window, cx| { this.open_locations(TextEditor::document_symbols, window, cx) })) + .on_action(cx.listener(|this, _: &EditorGoToWorkspaceSymbol, window, cx| { + this.open_workspace_symbols(window, cx) + })) .on_action(cx.listener(|this, _: &EditorGoToParentModule, _, cx| { this.with_editor(cx, |editor, path, cx| editor.go_to_parent_module(path, cx)) })) @@ -736,6 +845,7 @@ impl Render for FilePanel { .menu("Go to Definition", Box::new(EditorGoToDefinition)) .menu("Go to References", Box::new(EditorGoToReferences)) .menu("Format Document", Box::new(EditorFormatDocument)) + .menu("Format Selection", Box::new(EditorFormatSelection)) .separator() .menu("Select All", Box::new(EditorSelectAll)) .menu("Unfold All", Box::new(EditorUnfoldAll)) @@ -911,8 +1021,10 @@ mod tests { } /// The menus' commands as the keys reach them: Ctrl+F finds and Enter - /// steps, Escape closes the bar, Ctrl+G moves the caret, and Add Cursor - /// adds one. + /// steps, Alt+W and Alt+R switch to whole words and regular + /// expressions, Escape closes the bar, Ctrl+D selects occurrences, + /// Ctrl+G moves the caret, Add Cursor adds one, and the symbol lists + /// open. #[gpui::test] fn find_go_to_line_and_add_cursor_through_the_panel(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1003,11 +1115,51 @@ mod tests { cx.simulate_keystrokes("shift-f3"); assert_eq!(status(cx), Some((Some(0), 3))); + // The switches, by their keys while the field has focus: whole + // word, then a regular expression, and an invalid one. + let switches = |cx: &mut VisualTestContext| { + panel.read_with(cx, |panel, _| match &panel.bar { + Some(Bar::Find(find)) => (find.case_sensitive, find.whole_word, find.regex), + _ => panic!("the find bar is open"), + }) + }; + type_into_bar(cx, "a"); + assert_eq!(status(cx).map(|(_, total)| total), Some(3)); + cx.simulate_keystrokes("alt-w"); + assert_eq!(switches(cx), (false, true, false)); + assert_eq!(status(cx).map(|(_, total)| total), Some(3), "a is a word each time"); + cx.simulate_keystrokes("alt-w alt-r"); + assert_eq!(switches(cx), (false, false, true)); + type_into_bar(cx, r"\w\(\)"); + assert_eq!(status(cx).map(|(_, total)| total), Some(4), "a(), b(), a(), a()"); + type_into_bar(cx, "("); + assert_eq!(status(cx).map(|(_, total)| total), Some(0)); + let invalid = panel.read_with(cx, |panel, cx| match &panel.bar { + Some(Bar::Find(find)) => find.search_query(cx).error().is_some(), + _ => false, + }); + assert!(invalid, "the bar says the expression is invalid"); + cx.simulate_keystrokes("escape"); cx.run_until_parked(); assert!(panel.read_with(cx, |panel, _| panel.bar.is_none())); assert_eq!(status(cx), None, "closing the bar clears the highlights"); + // Ctrl+D: the word under the caret, then its next occurrence. + view.update(cx, |view, cx| view.go_to(1, 9, cx)); + cx.simulate_keystrokes("ctrl-d ctrl-d"); + cx.run_until_parked(); + let selected = view.read_with(cx, |view, _| { + view.surface + .editor() + .selections() + .selections() + .iter() + .map(|selection| selection.ordered_range()) + .collect::>() + }); + assert_eq!(selected, vec![19..20, 24..25], "both calls to a"); + cx.simulate_keystrokes("ctrl-g"); type_into_bar(cx, "2:4"); press_enter(cx); @@ -1035,6 +1187,28 @@ mod tests { .and_then(|list| list.message) }); assert!(note.is_some()); + + // Go to Symbol in Workspace opens a list the server fills as you + // type; without a server it says so. + cx.simulate_keystrokes("escape ctrl-t"); + cx.run_until_parked(); + assert!(panel.read_with(cx, |panel, _| matches!( + panel.bar, + Some(Bar::Locations { + workspace: true, + .. + }) + ))); + type_into_bar(cx, "fn"); + let list = panel.read_with(cx, |panel, cx| { + panel + .text_editor + .upgrade() + .and_then(|editor| editor.read(cx).locations().cloned()) + }); + let list = list.expect("a list for the panel"); + assert_eq!(list.title.as_ref(), "Workspace Symbols"); + assert!(list.message.is_some()); let _ = std::fs::remove_file(&path); } }