diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh index f7adaf4f..b68bdfab 100755 --- a/.github/scripts/check-host-boundary.sh +++ b/.github/scripts/check-host-boundary.sh @@ -104,6 +104,23 @@ src/ui/code_editor.rs|std::fs::metadata # that copy goes through `Host`, and the one `std::fs::copy` that touches a # destination sits inside a branch already gated on `host.id().is_local()`. src/ui/file_copy.rs|std::fs:: + +# The code editor's language servers run on this machine and only ever take a +# local buffer: `OpenFile::local` refuses anything whose host is not local, so +# every path `ui::lsp` holds is on this disk. It reads files a server points +# into that are not open (to measure a column, to apply a rename to disk), +# looks for project markers above a file (`Cargo.toml`, `package.json`, …), +# and checks a server binary is executable. A remote workspace gets language +# servers by running them over there, not by routing these reads. +src/ui/lsp/editor.rs|std::fs::read_to_string +src/ui/lsp/locations.rs|std::fs::read_to_string +src/ui/lsp/mod.rs|std::fs::read_to_string +src/ui/lsp/servers.rs|std::fs::metadata +src/ui/lsp/servers.rs|std::fs::read_to_string +# Its tests live in their own file (not a trailing `mod tests`), writing +# fixtures into a local temp dir. +src/ui/lsp/tests.rs|std::fs::write +src/ui/lsp/tests.rs|.canonicalize() EOF ) diff --git a/Cargo.lock b/Cargo.lock index 9c6ef10e..16d5c05b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#0e7541fbcee5a6d681c0238d5d7950061da4f147" dependencies = [ "aho-corasick", "anyhow", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#0e7541fbcee5a6d681c0238d5d7950061da4f147" dependencies = [ "anyhow", "gpui", @@ -3473,7 +3473,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#379c56a2ffbcb9771fb12e9e2fd6cc68a725c154" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#0e7541fbcee5a6d681c0238d5d7950061da4f147" dependencies = [ "proc-macro2", "quote", @@ -9947,6 +9947,7 @@ dependencies = [ "ksni", "libc", "log", + "lsp-types", "memchr", "notify 8.2.0", "notify-rust", diff --git a/Cargo.toml b/Cargo.toml index ea5e7962..af44cf79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,9 @@ tempfile = "3" # userinfo and man-page names back out. url = "2" percent-encoding = "2" +# The code editor's language-server client (`ui::lsp`). The same version +# gpui-component's LSP hooks take their types from. +lsp-types = { version = "0.97.0", features = ["proposed"] } # The code editor's file loading and saving (`ui::editor_text`). `encoding_rs` # decodes legacy-encoded text (GB18030, Windows-1252) and writes it back in the diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index a2e5444b..25faad4c 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -263,6 +263,18 @@ pub struct Config { /// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it. #[serde(default)] pub editor_markdown_preview: bool, + /// Whether the code editor starts language servers (rust-analyzer, + /// typescript-language-server, …) for the files it opens, for + /// diagnostics, completion, hover and go to definition. On by default; + /// a language whose server is not installed simply goes without. + #[serde(default = "default_true")] + pub editor_lsp: bool, + /// Whether the code editor marks lines that differ from the file's + /// staged (index) version in its gutter. On by default; flipped by the + /// `ToggleEditorGitGutter` command and remembered like + /// [`Self::editor_soft_wrap`]. + #[serde(default = "default_true")] + pub editor_git_gutter: bool, /// Whether the sidebar files tabs nobody pinned into groups of its own — /// by repository, and by host for an SSH pane. Off, those tabs sit in one /// flat list below the pinned groups, which are the user's and show @@ -759,6 +771,8 @@ impl Default for Config { scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, + editor_lsp: true, + editor_git_gutter: true, sidebar_auto_grouping: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, diff --git a/src/core/actions.rs b/src/core/actions.rs index 1fb5be95..417f2d00 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -127,7 +127,30 @@ actions!( EditorSave, EditorSaveAs, EditorGoToLine, + EditorGoToSymbol, + EditorNavigateBack, + EditorNavigateForward, + EditorSplitRight, + EditorFocusLeftGroup, + EditorFocusRightGroup, EditorNewFile, + EditorGoToDefinition, + EditorQuickFix, + EditorRenameSymbol, + EditorFormatDocument, + EditorTransformUppercase, + EditorTransformLowercase, + EditorTransformTitleCase, + EditorTrimTrailingWhitespace, + EditorJoinLines, + EditorRemoveSurroundingBrackets, + EditorFindReferences, + EditorNextChange, + EditorPrevChange, + EditorRevertChange, + ToggleEditorGitGutter, + EditorPeekChange, + ToggleEditorProblems, OpenSshProfiles, RestartSshSession, SendTab, diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index b1461a58..07085e64 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -890,8 +890,12 @@ impl Tty7App { // it against a local repository that merely shares the path. let (host, path) = self.editor_active_location()?; let root = cx - .try_global::()? - .repo_root_for(host, path.parent()?)?; + .try_global::() + .and_then(|cache| cache.repo_root_for(host, path.parent()?)) + // The gutter's own base read finds the repository of a file no + // terminal has been in, and its markers need this watch to + // notice a stage or a checkout. + .or_else(|| self.editor_gutter_root())?; Some((host, root.to_path_buf())) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 49db39e6..d634ad20 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1605,6 +1605,11 @@ impl Tty7App { crate::ui::tray::init(cx); } app.refresh_shells(cx); + // Backstop for every other way a window goes away (a workspace + // closed from elsewhere): its language-server documents go with it. + let app_id = cx.entity_id(); + cx.on_release(move |_, cx| crate::ui::lsp::LspStore::sync_window(app_id, Vec::new(), cx)) + .detach(); cx.on_app_quit(|app, cx| { app.save_session(cx); crate::core::window_state::WindowState::from_bounds(app.window_bounds).save(); @@ -1670,7 +1675,10 @@ impl Tty7App { if asked { return false; } - app.update(cx, |app, cx| app.prepare_window_close(cx)); + app.update(cx, |app, cx| { + app.prepare_window_close(cx); + app.lsp_window_closed(cx); + }); true }); @@ -1772,6 +1780,7 @@ impl Tty7App { return; } self.prepare_window_close(cx); + self.lsp_window_closed(cx); window.remove_window(); } @@ -4591,7 +4600,7 @@ impl Tty7App { (None, code) => host.code = code, // Both had an editor: the files come along into this tab's strip // rather than being dropped with the tab that brought them. - (Some(code), Some(theirs)) => code.adopt(&theirs.files), + (Some(code), Some(theirs)) => code.adopt(&theirs.all_files()), (Some(_), None) => {} } if host.diff_overlay.is_none() { @@ -5888,6 +5897,9 @@ impl Tty7App { }, ); + // The editor's navigation, while there is a file in front of it. + actions.extend(self.editor_palette_items()); + // Groups are something the sidebar draws, so they are offered only // while the tabs are in it. Opening a folder as a group asks the // system picker, which browses this computer: a path picked there @@ -5914,6 +5926,37 @@ impl Tty7App { } } + // The editor's text commands, while there is a buffer to run them on. + if self.editor_can_dispatch() { + actions.extend( + [ + Item::localized( + L10nKey::CmdEditorTransformUppercase, + CommandKind::EditorTransformUppercase, + ), + Item::localized( + L10nKey::CmdEditorTransformLowercase, + CommandKind::EditorTransformLowercase, + ), + Item::localized( + L10nKey::CmdEditorTransformTitleCase, + CommandKind::EditorTransformTitleCase, + ), + Item::localized( + L10nKey::CmdEditorTrimTrailingWhitespace, + CommandKind::EditorTrimTrailingWhitespace, + ), + Item::localized(L10nKey::CmdEditorJoinLines, CommandKind::EditorJoinLines), + Item::localized( + L10nKey::CmdEditorRemoveSurroundingBrackets, + CommandKind::EditorRemoveSurroundingBrackets, + ), + ] + .into_iter() + .map(|item| item.in_group(CommandGroup::View)), + ); + } + // Offered only where it would do something. A connection opened from a // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing @@ -6056,6 +6099,10 @@ impl Tty7App { ) { let catalog = self.search_catalog(window, cx); let view = cx.new(|cx| SearchView::new(catalog, tab, query, window, cx)); + if tab.in_editor_row() { + let tabs = self.editor_search_tabs(); + view.update(cx, |view, cx| view.set_editor_tabs(tabs, cx)); + } self.search_sub = Some(cx.subscribe_in(&view, window, Self::on_search_event)); self.search = Some(view.clone()); self.refresh_search_sessions(view, window, cx); @@ -6131,6 +6178,13 @@ 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 { .. } | CommandKind::GoToLocation { .. } + ) { + self.editor_symbol_preview_commit(); + } self.close_search(window, cx); self.run_command(kind, window, cx); } @@ -6143,6 +6197,22 @@ impl Tty7App { } } SearchEvent::CancelThemePreview => self.cancel_preset_preview(window, cx), + SearchEvent::PreviewSymbol { line, column } => { + self.editor_symbol_preview(*line, *column, cx) + } + SearchEvent::PreviewLocation { path, line, column } => { + self.lsp_preview_location(path, *line, *column, cx) + } + // Each editor tab is opened the way its own chord opens it, so + // it arrives set up: the outline around the caret, the server + // to ask. Closing first puts back a caret Go to Symbol moved. + SearchEvent::SwitchEditorTab(tab) => { + self.close_search(window, cx); + match tab { + SearchTab::Symbols => self.editor_go_to_symbol(window, cx), + _ => self.open_search(SearchTab::Files, "", window, cx), + } + } } } @@ -6153,6 +6223,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(); } @@ -6303,6 +6376,34 @@ impl Tty7App { } ToggleDocumentPreview => self.toggle_document_preview(cx), ToggleDocumentWrap => self.toggle_document_wrap(window, cx), + EditorTransformUppercase => { + self.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx); + } + EditorTransformLowercase => { + self.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx); + } + EditorTransformTitleCase => { + self.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx); + } + EditorTrimTrailingWhitespace => { + self.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx); + } + EditorJoinLines => { + self.editor_dispatch(&gpui_component::input::JoinLines, window, cx); + } + EditorRemoveSurroundingBrackets => { + self.editor_dispatch( + &gpui_component::input::RemoveSurroundingBrackets, + window, + cx, + ); + } + EditorGoToSymbol => self.editor_go_to_symbol(window, cx), + EditorNavigateBack => self.editor_navigate(false, window, cx), + EditorNavigateForward => self.editor_navigate(true, window, cx), + EditorSplitRight => { + self.editor_split(window, cx); + } RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { @@ -6345,6 +6446,10 @@ 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), + GoToLocation { path, line, column } => { + self.open_file_in_editor_at(&path, Some(line + 1), Some(column + 1), window, cx) + } GoToTab { workspace, tab } => self.go_to_tab(workspace, tab, false, window, cx), ResumeSession { agent, @@ -6481,6 +6586,16 @@ impl Tty7App { L10nKey::SettingsSidebarGrouping => { self.set_sidebar_auto_grouping(defaults.sidebar_auto_grouping, cx) } + L10nKey::SettingsEditorGitGutter => { + self.set_editor_git_gutter(defaults.editor_git_gutter, cx) + } + L10nKey::SettingsEditorLsp => self.set_editor_lsp(defaults.editor_lsp, cx), + L10nKey::SettingsEditorSoftWrap => { + self.set_editor_soft_wrap(defaults.editor_soft_wrap, cx) + } + L10nKey::SettingsEditorMarkdownPreview => { + self.set_editor_markdown_preview(defaults.editor_markdown_preview, cx) + } L10nKey::SettingsNotifyOnCommandFinish => { self.set_notify_mode(defaults.notify_on_command_finish, cx) } @@ -9359,9 +9474,79 @@ impl Render for Tty7App { } this.editor_go_to_line(window, cx) })) + // The language-server commands (`ui::lsp`) belong to the + // editor only while it has the focus; F2 and F12 mean + // something to programs in the terminal too. + .on_action(cx.listener(|this, _: &EditorGoToDefinition, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_go_to_definition(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorQuickFix, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_code_actions(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorRenameSymbol, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_rename_start(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorFormatDocument, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_format_document(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.lsp_find_references(window, cx) + })) .on_action(cx.listener(|this, _: &EditorNewFile, window, cx| { this.editor_new_file(window, cx) })) + // The gutter's change markers. Each one lets the chord through + // when the editor is not focused or has nothing to do. + .on_action(cx.listener(|this, _: &EditorNextChange, window, cx| { + if !this.editor_gutter_step(true, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorPrevChange, window, cx| { + if !this.editor_gutter_step(false, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorRevertChange, window, cx| { + if !this.editor_gutter_revert_at_cursor(window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &ToggleEditorGitGutter, _, cx| { + this.toggle_editor_git_gutter(cx) + })) + .on_action(cx.listener(|this, _: &EditorPeekChange, window, cx| { + if !this.editor_gutter_peek_at_cursor(window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &ToggleEditorProblems, _, cx| { + if !this.code_panel_visible() { + cx.propagate(); + return; + } + this.toggle_editor_problems(cx) + })) .on_action( cx.listener(|this, _: &Quit, window, cx| this.quit_stop_sessions(window, cx)), ) @@ -9466,6 +9651,7 @@ impl Render for Tty7App { .children(self.render_switcher(window, cx)) .when_some(self.search.clone(), |this, search| this.child(search)) .children(gpui_component::Root::render_notification_layer(window, cx)); + let root = Self::with_editor_text_commands(root, cx); if let Some(start) = prof { crate::ui::perf::record("window", start.elapsed()); diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index b03f169e..e6eed147 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -22,6 +22,14 @@ 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 gutter; +mod nav; +pub(crate) mod outline; +mod problems; +mod split; + +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); @@ -117,6 +125,8 @@ pub(crate) struct OpenFile { /// scrollbar every other scrolling surface in tty7 has. The editor itself /// gets one from `Input`. pub(crate) preview_scroll: gpui::ScrollHandle, + /// Git change markers — see `gutter`. + gutter: gutter::BufferGutter, _sub: Subscription, _observe: Subscription, } @@ -147,6 +157,25 @@ impl OpenFile { } language_for_path(&self.path) } + + fn local(&self) -> Option { + (self.untitled.is_none() && self.host.id().is_local()).then(|| LocalFile { + id: self.id(), + input: self.input.clone(), + path: self.path.clone(), + language: self.language(), + indent: self.indent, + }) + } +} + +/// A saved file on this machine, as the language servers see it. +pub(crate) struct LocalFile { + pub(crate) id: BufferId, + pub(crate) input: Entity, + pub(crate) path: PathBuf, + pub(crate) language: &'static str, + pub(crate) indent: Indent, } /// One tab's view of the editor: which buffers it shows, in the order its @@ -158,6 +187,10 @@ pub(crate) struct TabCode { pub(crate) roots: Vec, pub(crate) expanded: std::collections::HashSet, pub(crate) selected: Option, + /// The second editor group, when the editor is split — the one without + /// the focus. `files` and `active` above are always the focused group's + /// (see `split`). + pub(crate) split: Option, } impl TabCode { @@ -169,6 +202,7 @@ impl TabCode { roots: Vec::new(), expanded: std::collections::HashSet::new(), selected: None, + split: None, } } @@ -245,7 +279,15 @@ struct SaveWaiter { enum BarKind { GoToLine, - SaveAs { id: BufferId, then_close: bool }, + SaveAs { + id: BufferId, + then_close: bool, + }, + /// A new name for the symbol at `offset` (`ui::lsp`). + Rename { + id: BufferId, + offset: usize, + }, } /// The one-line prompt that sits above the text: go to line, or name a file @@ -281,6 +323,10 @@ pub(crate) struct EditorPanelState { watched_dirs: HashSet, watched_files: HashSet, events_tx: smol::channel::Sender>, + /// Back/forward history, outlines and Go to Symbol's preview. + nav: nav::EditorNav, + /// The Problems list at the foot of the panel — see `problems`. + problems: problems::ProblemsPane, } impl EditorPanelState { @@ -323,6 +369,8 @@ impl EditorPanelState { watched_dirs: HashSet::new(), watched_files: HashSet::new(), events_tx: tx, + nav: nav::EditorNav::new(cx), + problems: Default::default(), } } } @@ -796,12 +844,22 @@ impl Tty7App { self.tabs.iter().position(|t| t.tree_id.get() == tab) } - /// How many tabs show this buffer. + /// How many places show this buffer: every tab showing it, twice for a + /// tab whose split shows it in both groups. fn buffer_refs(&self, id: BufferId) -> usize { self.tabs .iter() .filter_map(|t| t.code.as_deref()) - .filter(|c| c.files.contains(&id)) + .map(|c| c.views_of(id)) + .sum() + } + + /// How many tabs show this buffer, in either group. + fn buffer_tabs(&self, id: BufferId) -> usize { + self.tabs + .iter() + .filter_map(|t| t.code.as_deref()) + .filter(|c| c.shows(id)) .count() } @@ -819,10 +877,9 @@ impl Tty7App { let Some(code) = self.tabs.get(tab_ix).and_then(|t| t.code.as_deref()) else { return Vec::new(); }; - code.files - .iter() - .copied() - .filter(|id| self.buffer(*id).is_some_and(|b| b.dirty) && self.buffer_refs(*id) == 1) + code.all_files() + .into_iter() + .filter(|id| self.buffer(*id).is_some_and(|b| b.dirty) && self.buffer_tabs(*id) == 1) .collect() } @@ -855,6 +912,9 @@ impl Tty7App { .filter_map(|p| p.parent().map(Path::to_path_buf)) .collect(); self.editor.watched_files = files; + // Every change to which files are open, or where they live, passes + // through here — which is exactly what the language servers follow. + self.lsp_sync_buffers(cx); if dirs == self.editor.watched_dirs { return; } @@ -1247,6 +1307,7 @@ impl Tty7App { .default_value(text) }); let id = input.entity_id(); + let git_gutter = gutter::BufferGutter::attach(&input, id, cx); let sub = cx.subscribe_in( &input, window, @@ -1277,6 +1338,7 @@ impl Tty7App { preview, wrap, preview_scroll: gpui::ScrollHandle::new(), + gutter: git_gutter, _sub: sub, _observe: observe, }); @@ -1297,9 +1359,43 @@ impl Tty7App { if let Some(f) = self.buffer_mut(id) { f.dirty = dirty; } + self.lsp_buffer_edited(id, cx); + self.editor_gutter_note_edit(id, cx); + self.editor_nav_note_edit(id, cx); cx.notify(); } + /// Saved files on this machine — what a language server can read + /// (`ui::lsp`). Untitled buffers and files on other hosts are not. + pub(crate) fn editor_local_files(&self) -> Vec { + self.editor + .buffers + .iter() + .filter_map(OpenFile::local) + .collect() + } + + pub(crate) fn editor_local_file(&self, id: BufferId) -> Option { + self.buffer(id).and_then(OpenFile::local) + } + + pub(crate) fn editor_active_local_file(&self) -> Option { + self.active_buffer().and_then(OpenFile::local) + } + + /// Asks for a new name for the symbol at `offset`, in the bar above the + /// text; `ui::lsp` does the renaming once it is answered. + pub(crate) fn editor_open_rename_bar( + &mut self, + id: BufferId, + offset: usize, + current: String, + window: &mut Window, + cx: &mut Context, + ) { + self.editor_open_bar(BarKind::Rename { id, offset }, current, window, cx); + } + /// A new, empty, never-saved buffer, on the window's own machine. pub(crate) fn editor_new_file(&mut self, window: &mut Window, cx: &mut Context) { let Some(host) = self.active_host(cx) else { @@ -1464,6 +1560,101 @@ impl Tty7App { self.editor_note_edit(id, cx); } + /// Run one of gpui-component's editing actions on the active buffer, as + /// if it had been pressed there. Returns false with no buffer to run it + /// on. The palette closes before its row runs, so this focuses the + /// buffer first rather than relying on where the focus happens to be. + pub(crate) fn editor_dispatch( + &mut self, + action: &dyn gpui::Action, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.code_panel_visible() { + return false; + } + let Some(input) = self.active_buffer().map(|f| f.input.clone()) else { + return false; + }; + let handle = input.read(cx).focus_handle(cx); + window.focus(&handle, cx); + handle.dispatch_action(action, window, cx); + true + } + + /// The window's listeners for the editor's text commands, when the user + /// has bound one: each runs on the editor only while it has the focus. + pub(crate) fn with_editor_text_commands( + root: gpui::Stateful, + cx: &mut Context, + ) -> gpui::Stateful { + use crate::core::actions::{ + EditorJoinLines, EditorRemoveSurroundingBrackets, EditorTransformLowercase, + EditorTransformTitleCase, EditorTransformUppercase, EditorTrimTrailingWhitespace, + }; + root.on_action( + cx.listener(|this, _: &EditorTransformUppercase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTransformLowercase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTransformTitleCase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTrimTrailingWhitespace, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx); + }), + ) + .on_action(cx.listener(|this, _: &EditorJoinLines, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::JoinLines, window, cx); + })) + .on_action( + cx.listener(|this, _: &EditorRemoveSurroundingBrackets, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch( + &gpui_component::input::RemoveSurroundingBrackets, + window, + cx, + ); + }), + ) + } + + /// Whether an editor exists to run [`Self::editor_dispatch`] on. + pub(crate) fn editor_can_dispatch(&self) -> bool { + self.code_panel_visible() && self.active_buffer().is_some() + } + pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { self.code_panel_visible() && self.active_buffer().is_some_and(|f| { @@ -1587,6 +1778,8 @@ impl Tty7App { f.saved_format = format; f.conflict = None; app.editor_note_edit(id, cx); + app.lsp_buffer_saved(id, cx); + app.editor_gutter_refetch(id); // A save is a working-tree edit the `.git` watch cannot // see, and the file tree only sees it while it happens // to be showing that directory. @@ -1816,6 +2009,7 @@ impl Tty7App { ) } BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAsPlaceholder).to_string(), + BarKind::Rename { .. } => t_fmt(L10nKey::LspRenamePlaceholder, &[("name", &initial)]), }; let input = cx.new(|cx| { InputState::new(window, cx) @@ -1833,7 +2027,7 @@ impl Tty7App { InputEvent::Blur => { if matches!( this.editor.bar.as_ref().map(|b| &b.kind), - Some(BarKind::GoToLine) + Some(BarKind::GoToLine | BarKind::Rename { .. }) ) { this.editor.bar = None; cx.notify(); @@ -1897,6 +2091,14 @@ impl Tty7App { }; place_cursor(input, position, CURSOR_SCROLL_ATTEMPTS, window, cx); } + BarKind::Rename { id, offset } => { + let name = text.trim().to_string(); + if name.is_empty() { + return; + } + self.editor_close_bar(window, cx); + self.lsp_rename(id, offset, name, window, cx); + } BarKind::SaveAs { id, then_close } => { let path = PathBuf::from(text.trim()); if path.as_os_str().is_empty() { @@ -2100,11 +2302,37 @@ impl Tty7App { ) .menu(t(L10nKey::AppMenuSelectAll), Box::new(edit::SelectAll)) .separator() + // The line commands act on every line a cursor or selection + // touches. Their chords live on gpui-component's `CodeEditor` key + // context and are re-added in `keymap::fixed_bindings`, so they + // beat the app's own keys while the editor has focus. + .menu( + t(L10nKey::EditorToggleComment), + Box::new(edit::ToggleLineComment), + ) + .menu(t(L10nKey::EditorMoveLineUp), Box::new(edit::MoveLineUp)) + .menu(t(L10nKey::EditorMoveLineDown), Box::new(edit::MoveLineDown)) + .menu( + t(L10nKey::EditorDuplicateLine), + Box::new(edit::CopyLineDown), + ) + .menu(t(L10nKey::EditorDeleteLine), Box::new(edit::DeleteLine)) + .separator() .menu(t(L10nKey::AppMenuFind), Box::new(edit::Search)) .menu( t(L10nKey::EditorGoToLineAction), Box::new(crate::core::actions::EditorGoToLine), + ) + .menu_with_disabled( + t(L10nKey::EditorGitRevertChange), + Box::new(crate::core::actions::EditorRevertChange), + !this.editor_gutter_can_revert(id, cx), + ) + .menu( + t(L10nKey::EditorGoToMatchingBracket), + Box::new(edit::MoveToMatchingBracket), ); + let menu = this.lsp_menu_items(menu, id, cx); this.editor_file_menu_items(menu, id, app, cx) } @@ -2252,7 +2480,7 @@ impl Tty7App { .get_mut(tab_ix) .and_then(|t| t.code.as_deref_mut()) { - code.forget(id); + code.close_in_focused(id); } if self.buffer_refs(id) == 0 { self.editor_drop_buffer(id, cx); @@ -2266,9 +2494,10 @@ impl Tty7App { // see that happen now. self.editor_saves_failed(id); for code in self.tabs.iter_mut().filter_map(|t| t.code.as_deref_mut()) { - code.forget(id); + code.forget_everywhere(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 @@ -2337,10 +2566,15 @@ impl Tty7App { if let Some(tab_ix) = ids.first().and_then(|id| { self.tabs .iter() - .position(|t| t.code.as_deref().is_some_and(|c| c.files.contains(id))) + .position(|t| t.code.as_deref().is_some_and(|c| c.shows(*id))) }) && tab_ix == self.active && let Some(code) = self.tab_code_mut() - && let Some(pos) = code.files.iter().position(|f| *f == ids[0]) + && let Some(pos) = { + if !code.files.contains(&ids[0]) { + code.swap_focus(); + } + code.files.iter().position(|f| *f == ids[0]) + } { code.active = pos; code.visible = true; @@ -2634,7 +2868,7 @@ impl Tty7App { .tabs .iter() .filter_map(|t| t.code.as_deref()) - .flat_map(|c| c.files.iter().copied()) + .flat_map(TabCode::all_files) .collect(); let orphans: Vec<(BufferId, bool)> = self .editor @@ -2650,6 +2884,7 @@ impl Tty7App { for (id, dirty) in orphans { if !dirty { self.editor.buffers.retain(|b| b.id() != id); + self.editor.nav.forget_buffer(id); changed = true; continue; } @@ -2683,7 +2918,9 @@ impl Tty7App { let Some(state) = editor_session::get(cx, tab_id) else { return; }; - if state.files.is_empty() { + // The left group can be empty while the right is not: it held only + // files that are not recorded (untitled, remote). + if state.files.is_empty() && state.split.as_ref().is_none_or(|s| s.files.is_empty()) { return; } let Some(host) = self.active_host(cx) else { @@ -2692,7 +2929,14 @@ impl Tty7App { // What was recorded is what is being put back; recording it again // before the files have loaded would write down an empty tab. self.editor.recorded.insert(tab_id, state.clone()); - let files = state.files.clone(); + // Both groups' files in one trip, the left group's first. + let files: Vec = state + .files + .iter() + .chain(state.split.iter().flat_map(|s| s.files.iter())) + .cloned() + .collect(); + let requested = files.clone(); HostOps::run_in( host.clone(), window, @@ -2704,32 +2948,55 @@ impl Tty7App { .collect::>() }, move |app, loaded: Vec>, window, cx| { - let front = loaded - .get(state.active) - .and_then(Option::as_ref) - .map(|l| l.path.clone()); - let mut ids = Vec::new(); - for l in loaded.into_iter().flatten() { - ids.push(app.editor_install(host.clone(), l, tab_id, false, window, cx)); + let mut opened: Vec<(PathBuf, BufferId)> = Vec::new(); + for (path, l) in requested.into_iter().zip(loaded) { + if let Some(l) = l { + let id = app.editor_install(host.clone(), l, tab_id, false, window, cx); + opened.push((path, id)); + } } + let group = |paths: &[PathBuf], active: usize| -> (Vec, usize) { + let front = paths.get(active); + let mut ids: Vec = Vec::new(); + let mut at = 0; + for path in paths { + let Some(&(_, id)) = opened.iter().find(|(p, _)| p == path) else { + continue; + }; + if ids.contains(&id) { + continue; + } + if Some(path) == front { + at = ids.len(); + } + ids.push(id); + } + (ids, at) + }; + let (left, left_active) = group(&state.files, state.active); + let right = state + .split + .as_ref() + .map(|s| (group(&s.files, s.active), s.focused)); let Some(tab_ix) = app.tab_index_of(tab_id) else { return; }; let Some(code) = app.tabs[tab_ix].code.as_deref_mut() else { return; }; - if let Some(front) = front - && let Some(pos) = code.files.iter().position(|id| { - app.editor - .buffers - .iter() - .any(|b| b.id() == *id && b.path == front) - }) - { - code.active = pos; - } - code.visible = state.visible && !code.files.is_empty(); - if tab_ix == app.active && code.visible { + let restored: Vec = opened.iter().map(|(_, id)| *id).collect(); + // Merged into what the tab has now, not written over it: a + // file opened, or a split made, while these were loading stays. + let untouched = code.restore_groups( + &restored, + (left, left_active), + right.map(|((files, active), focused)| (files, active, focused)), + ); + let visible = state.visible && !code.files.is_empty(); + code.visible |= visible; + // Only a restore nobody has touched takes the keyboard; one the + // reader has moved on from leaves it where they put it. + if untouched && tab_ix == app.active && visible { app.focus_editor(window, cx); } cx.notify(); @@ -2750,24 +3017,38 @@ impl Tty7App { if !self.editor.restored.contains(&tab_id) { continue; } - let mut files = Vec::new(); - let mut active = 0; - for (pos, id) in code.files.iter().enumerate() { - // Only files on the window's own machine: an SFTP buffer's - // host is a connection that will not exist next launch. - let Some(f) = self.buffer(*id) else { continue }; - if f.untitled.is_some() || f.host.id() != spawn_host { - continue; + // Only files on the window's own machine: an SFTP buffer's host + // is a connection that will not exist next launch. + let group = |ids: &[BufferId], front: usize| -> (Vec, usize) { + let mut files = Vec::new(); + let mut active = 0; + for (pos, id) in ids.iter().enumerate() { + let Some(f) = self.buffer(*id) else { continue }; + if f.untitled.is_some() || f.host.id() != spawn_host { + continue; + } + if pos == front { + active = files.len(); + } + files.push(f.path.clone()); } - if pos == code.active { - active = files.len(); - } - files.push(f.path.clone()); - } + (files, active) + }; + let sides = code.sides(); + let (files, active) = group(&sides[0].files, sides[0].active); + let split = sides.get(1).and_then(|right| { + let (files, active) = group(&right.files, right.active); + (!files.is_empty()).then_some(editor_session::SplitEditor { + files, + active, + focused: right.focused, + }) + }); let state = TabEditor { files, active, visible: code.visible, + split, }; if self.editor.recorded.get(&tab_id) != Some(&state) { changed.push((tab_id, state)); @@ -2788,12 +3069,12 @@ impl Tty7App { .tabs .get(tab_ix) .and_then(|t| t.code.as_deref()) - .map(|c| c.files.clone()) + .map(TabCode::all_files) else { return; }; for id in ids { - if self.buffer_refs(id) == 1 && self.buffer(id).is_some_and(|b| !b.dirty) { + if self.buffer_tabs(id) == 1 && self.buffer(id).is_some_and(|b| !b.dirty) { self.editor_drop_buffer(id, cx); } } @@ -2802,6 +3083,7 @@ impl Tty7App { /// A tab was closed for good: forget what it had open. pub(crate) fn editor_forget_tab(&mut self, tab: TabId, cx: &mut Context) { self.editor.recorded.remove(&tab); + self.editor.nav.forget_tab(tab); editor_session::remove(cx, tab); } } @@ -2823,58 +3105,15 @@ impl Tty7App { if !self.code_panel_visible() { return None; } - let body = match self.active_buffer() { - None => self.render_editor_empty(cx).into_any_element(), - Some(f) if f.preview => { - let markdown = f.input.read(cx).text().to_string(); - let scroll = f.preview_scroll.clone(); - // The bar's wrapper takes its height from `flex_1`, so it needs - // a column with a definite height to grow inside — hand it one - // rather than dropping it straight into the overlay, or the - // pane sizes to its content and there is nothing left to - // scroll. - v_flex() - .size_full() - .child(crate::ui::scrollbar::with_vertical_scrollbar( - "editor-md-preview-scrollbar", - div() - .id("editor-md-preview") - .size_full() - .overflow_y_scroll() - .track_scroll(&scroll) - .px_4() - .py_3() - .child( - gpui_component::text::TextView::markdown( - "editor-md-preview-body", - markdown, - ) - .style(crate::ui::theme::markdown_style(cx)), - ), - &scroll, - )) - .into_any_element() - } - Some(f) => { - let input = f.input.clone(); - let id = f.id(); - let app = cx.entity().downgrade(); - div() - .id("editor-body") - .size_full() - .child( - Input::new(&input) - .appearance(false) - .font_family(cx.theme().mono_font_family.clone()) - .text_size(cx.theme().mono_font_size) - .size_full(), - ) - .context_menu(move |menu, _window, cx| { - Self::editor_body_menu(menu, &app, id, cx) - }) - .into_any_element() - } - }; + self.editor_split_follow_focus(window, cx); + self.editor_gutter_sync(cx); + self.editor_nav_tick(cx); + let breadcrumbs = self.render_editor_breadcrumbs(window, cx); + let body = self.render_editor_body( + self.tab_code().and_then(TabCode::active_id), + self.tab_code().map_or(0, TabCode::focused_slot), + cx, + ); let conflict_banner = self .active_buffer() .and_then(|f| f.conflict.map(|c| (f.id(), c))) @@ -2891,7 +3130,8 @@ impl Tty7App { .children(header) .when_some(conflict_banner, |this, b| this.child(b)) .children(bar) - .child(div().flex_1().min_h_0().child(body)); + .child(self.render_editor_groups(breadcrumbs, body, window, cx)) + .children(self.render_editor_problems(window, cx)); // The panel's own paint is the same either way; only the box is not. // Filling the workspace means stopping the window's translucency and @@ -2915,12 +3155,18 @@ 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); + let shell = self.editor_split_command_sync(shell, cx); Some( shell .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key != "escape" { return; } + if this.editor_gutter_close_peek(cx) { + cx.stop_propagation(); + return; + } // Escape in the go-to-line or Save As box dismisses the // box, not the whole editor. if this.editor.bar.is_some() { @@ -2936,6 +3182,69 @@ impl Tty7App { ) } + /// One group's text: the file `id`, rendered or as source, or the empty + /// panel. `slot` tells the two groups' elements apart. + pub(crate) fn render_editor_body( + &self, + id: Option, + slot: usize, + cx: &mut Context, + ) -> AnyElement { + match id.and_then(|id| self.buffer(id)) { + None => self.render_editor_empty(cx).into_any_element(), + Some(f) if f.preview => { + let markdown = f.input.read(cx).text().to_string(); + let scroll = f.preview_scroll.clone(); + // The bar's wrapper takes its height from `flex_1`, so it needs + // a column with a definite height to grow inside — hand it one + // rather than dropping it straight into the overlay, or the + // pane sizes to its content and there is nothing left to + // scroll. + v_flex() + .size_full() + .child(crate::ui::scrollbar::with_vertical_scrollbar( + ("editor-md-preview-scrollbar", slot), + div() + .id(("editor-md-preview", slot)) + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .px_4() + .py_3() + .child( + gpui_component::text::TextView::markdown( + ("editor-md-preview-body", slot), + markdown, + ) + .style(crate::ui::theme::markdown_style(cx)), + ), + &scroll, + )) + .into_any_element() + } + Some(f) => { + let input = f.input.clone(); + let id = f.id(); + let app = cx.entity().downgrade(); + div() + .id(("editor-body", slot)) + .size_full() + .child( + Input::new(&input) + .appearance(false) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .size_full(), + ) + .children(self.render_editor_gutter_peek(id, cx)) + .context_menu(move |menu, _window, cx| { + Self::editor_body_menu(menu, &app, id, cx) + }) + .into_any_element() + } + } + } + /// The editor header alone, for the strip above a docked column. pub(crate) fn render_editor_header_only( &self, @@ -3179,6 +3488,7 @@ impl Tty7App { let label = match bar.kind { BarKind::GoToLine => t(L10nKey::EditorGoToLine), BarKind::SaveAs { .. } => t(L10nKey::EditorSaveAs), + BarKind::Rename { .. } => t(L10nKey::LspRenameSymbol), }; Some( h_flex() @@ -3208,45 +3518,32 @@ impl Tty7App { } fn render_code_status_bar(&self, window: &Window, cx: &mut Context) -> gpui::Div { - // The roots below belong to this window's own machine. A file read - // over SFTP is on another one, where they mean nothing, so it shows - // its own full path rather than borrowing the local repo's name. - let tree_host = self.spawn_host(cx); - let code = self.tab_code(); + // No path here: the breadcrumbs over the text show where the file is. let muted = cx.theme().muted_foreground; let active = self.active_buffer(); - let path_text: Option = code.map(|c| { - let repo = c - .roots - .first() - .and_then(|r| r.file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - match active { - Some(f) if f.untitled.is_some() => f.label(), - Some(f) if f.host.id() != tree_host => f.path.display().to_string().into(), - Some(f) => { - let rel = c - .roots - .iter() - .find_map(|r| f.path.strip_prefix(r).ok()) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| f.label().to_string()); - format!("{repo} › {rel}").into() - } - None => repo.into(), - } - }); let cursor: Option = active.map(|f| { - let pos = f.input.read(cx).cursor_position(); - t_fmt( + let input = f.input.read(cx); + let pos = input.cursor_position(); + let ln_col = t_fmt( L10nKey::EditorLnCol, &[ ("line", &(pos.line + 1).to_string()), ("column", &(pos.character + 1).to_string()), ], - ) - .into() + ); + // Multi-cursor: the position is the primary caret's, the count + // says there are more. + match input.selection_count() { + 1 => ln_col.into(), + n => { + let n = n.to_string(); + format!( + "{ln_col} {}", + t_fmt(L10nKey::EditorSelections, &[("n", &n)]) + ) + .into() + } + } }); let wrap: Option = active.map(|f| f.wrap); let is_markdown = active.is_some_and(|f| f.language() == "markdown"); @@ -3275,10 +3572,16 @@ impl Tty7App { .border_color(cx.theme().sidebar_border) .text_size(gpui::rems(crate::ui::right_panel::META)) .text_color(muted) - .when_some(path_text, |this, t| { - this.child(div().min_w_0().text_ellipsis().child(t)) - }) .child(div().flex_1()) + // The counts open the Problems list. + .children(self.render_lsp_status(cx).map(|status| { + div() + .id("status-problems") + .flex_none() + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| this.toggle_editor_problems(cx))) + .child(status) + })) .when(is_markdown, |this| { this.child( Button::new("status-md-preview") @@ -3454,6 +3757,16 @@ impl Tty7App { mod tests { use super::*; + /// A temp dir as the editor will see paths under it: through the local + /// host's `canonicalize`, the call `load_file` makes. That resolves + /// macOS's `/var` → `/private/var` and Windows' 8.3 short names + /// (`RUNNER~1`), and never answers in Windows' `\\?\` form. + pub(crate) fn test_real_dir(dir: &Path) -> PathBuf { + tty7_core::host::local::LocalHost::new() + .canonicalize(dir) + .unwrap() + } + /// Handing a file the editor cannot read to the desktop is how a click /// opens a PNG. It must not be how a click runs a build's output. #[cfg(unix)] diff --git a/src/ui/code_editor/gutter.rs b/src/ui/code_editor/gutter.rs new file mode 100644 index 00000000..623c665a --- /dev/null +++ b/src/ui/code_editor/gutter.rs @@ -0,0 +1,1337 @@ +//! Git change markers in the code editor's gutter. +//! +//! Each buffer is diffed against its file's **index** version (`git show +//! :path`) — what VS Code's quick diff compares with, and what the source +//! control panel lists as "Changes": staging a hunk makes its marker go away. +//! Untracked files, files outside a repository and hosts without git (SFTP) +//! get no markers at all, again as VS Code does. +//! +//! The base is fetched through the buffer's own host, so a file on a remote +//! workspace is compared with *that* machine's repository. It is re-read when +//! the repository's SCM epoch moves (a `.git` watch event, a stage, a commit, +//! a checkout — see `ScmData::bump`), when the file is saved, and when the +//! buffer's path changes. The diff itself is recomputed off the UI thread a +//! moment after each edit; in between, the editor shifts the markers along +//! with the text (see `gpui_component::input::GutterMarker`). +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use gpui::prelude::*; +use gpui::{ + AnyElement, Context, EntityInputHandler as _, Pixels, Point, Task, Window, anchored, deferred, + div, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{GutterMarker, InputState, Position, RopeExt as _}; +use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; + +use super::{BufferId, TabCode}; +use crate::ui::app::Tty7App; +use crate::ui::editor_text::TextFormat; +use crate::ui::host_ops::HostOps; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +/// How long typing has to pause before the buffer is diffed again. +const DIFF_DEBOUNCE: Duration = Duration::from_millis(150); + +/// Past this many differing lines (after trimming what the two sides share at +/// either end) the exact diff is not worth its cost: the region between the +/// first and last difference is marked as one modified block instead. +const MAX_EDIT_DISTANCE: usize = 1000; + +/// One contiguous difference: base lines `old` became buffer lines `new`. +/// Lines are counted the way the editor counts them — a text ending in `\n` +/// has an empty last line, and line `i` of the diff is line `i` on screen. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Hunk { + pub(crate) old: Range, + pub(crate) new: Range, +} + +impl Hunk { + fn marker(&self) -> GutterMarker { + if self.old.is_empty() { + GutterMarker::added(self.new.clone()) + } else if self.new.is_empty() { + GutterMarker::deleted(self.new.start) + } else { + GutterMarker::modified(self.new.clone()) + } + } +} + +/// The file's lines, each with its terminator. `split_inclusive` gives no +/// entry for the empty line after a final `\n`, which is exactly right: that +/// line exists on both sides or on neither, and its presence shows up as the +/// previous line gaining or losing its `\n`. +fn lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +/// A line as compared: `\r\n` and `\n` are the same line. Save writes one +/// line ending for the whole file, so a pasted `\r\n` is not a change. +fn line_key(line: &str) -> &str { + match line.strip_suffix("\r\n") { + Some(body) => &line[..body.len()], + None => line.strip_suffix('\n').unwrap_or(line), + } +} + +/// Whether the line had a terminator — kept apart from [`line_key`] so a +/// last line that lost its `\n` still counts as changed. +fn line_id(line: &str) -> (&str, bool) { + (line_key(line), line.ends_with('\n')) +} + +/// The differences between `base` and `current`, in order. +pub(crate) fn diff_hunks(base: &str, current: &str) -> Vec { + let a = lines(base); + let b = lines(current); + // Intern the lines so the diff compares integers. + fn intern<'a>(ids: &mut std::collections::HashMap<(&'a str, bool), u32>, l: &'a str) -> u32 { + let next = ids.len() as u32; + *ids.entry(line_id(l)).or_insert(next) + } + let mut ids = std::collections::HashMap::new(); + let a: Vec = a.iter().map(|l| intern(&mut ids, l)).collect(); + let b: Vec = b.iter().map(|l| intern(&mut ids, l)).collect(); + + let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); + let suffix = a[prefix..] + .iter() + .rev() + .zip(b[prefix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + let a_mid = &a[prefix..a.len() - suffix]; + let b_mid = &b[prefix..b.len() - suffix]; + if a_mid.is_empty() && b_mid.is_empty() { + return Vec::new(); + } + let matches = myers_matches(a_mid, b_mid, MAX_EDIT_DISTANCE).unwrap_or_default(); + + let mut hunks = Vec::new(); + let (mut ai, mut bi) = (0, 0); + for (x, y) in matches.into_iter().chain([(a_mid.len(), b_mid.len())]) { + if x > ai || y > bi { + hunks.push(Hunk { + old: prefix + ai..prefix + x, + new: prefix + bi..prefix + y, + }); + } + ai = x + 1; + bi = y + 1; + } + hunks +} + +/// The pairs `(i, j)` with `a[i] == b[j]` on a shortest edit script, in +/// order — Myers' O(ND) algorithm. `None` when the edit distance exceeds +/// `max_d`. +fn myers_matches(a: &[u32], b: &[u32], max_d: usize) -> Option> { + let n = a.len() as isize; + let m = b.len() as isize; + let max = ((n + m) as usize).min(max_d) as isize; + let offset = max + 1; + let mut v = vec![0isize; 2 * offset as usize + 1]; + // `trace[d]` is `v` as it stood before round `d`, only the part round `d` + // reads: diagonals `-d - 1 ..= d + 1`. + let mut trace: Vec> = Vec::new(); + let mut found = None; + 'outer: for d in 0..=max { + let lo = (offset - d - 1).max(0) as usize; + let hi = (offset + d + 1) as usize; + trace.push(v[lo..=hi].to_vec()); + let mut k = -d; + while k <= d { + let ix = (offset + k) as usize; + let mut x = if k == -d || (k != d && v[ix - 1] < v[ix + 1]) { + v[ix + 1] + } else { + v[ix - 1] + 1 + }; + let mut y = x - k; + while x < n && y < m && a[x as usize] == b[y as usize] { + x += 1; + y += 1; + } + v[ix] = x; + if x >= n && y >= m { + found = Some(d); + break 'outer; + } + k += 2; + } + } + let d_final = found?; + + let mut matches = Vec::new(); + let (mut x, mut y) = (n, m); + for d in (0..=d_final).rev() { + // The snapshot starts at diagonal `-d - 1`. + let snap = &trace[d as usize]; + let at = |k: isize| snap[(k + d + 1) as usize]; + let k = x - y; + let prev_k = if k == -d || (k != d && at(k - 1) < at(k + 1)) { + k + 1 + } else { + k - 1 + }; + let prev_x = at(prev_k); + let prev_y = prev_x - prev_k; + // The snake: equal lines back to where this round's one edit landed. + while x > prev_x && y > prev_y { + x -= 1; + y -= 1; + matches.push((x as usize, y as usize)); + } + x = prev_x; + y = prev_y; + } + matches.reverse(); + Some(matches) +} + +/// The edit that puts `hunk`'s base lines back: a byte range of `current` and +/// the text to put there. +pub(crate) fn revert_edit(current: &str, base: &str, hunk: &Hunk) -> (Range, String) { + let offset = |text: &str, line: usize| -> usize { + text.split_inclusive('\n') + .take(line) + .map(str::len) + .sum::() + }; + let start = offset(current, hunk.new.start); + let end = start + + current + .split_inclusive('\n') + .skip(hunk.new.start) + .take(hunk.new.len()) + .map(str::len) + .sum::(); + let replacement: String = base + .split_inclusive('\n') + .skip(hunk.old.start) + .take(hunk.old.len()) + .collect(); + (start..end, replacement) +} + +/// The hunk the cursor on `line` is in. A deletion belongs to the lines on +/// both sides of the boundary it sits on. +pub(crate) fn hunk_at_line(hunks: &[Hunk], line: usize) -> Option { + hunks + .iter() + .position(|h| h.new.contains(&line)) + .or_else(|| { + hunks + .iter() + .position(|h| h.new.is_empty() && (h.new.start == line || h.new.start == line + 1)) + }) +} + +/// The hunk to jump to from the cursor on `line`, wrapping around the file. +pub(crate) fn step_hunk(hunks: &[Hunk], line: usize, forward: bool) -> Option { + if hunks.is_empty() { + return None; + } + if forward { + hunks.iter().position(|h| h.new.start > line).or(Some(0)) + } else { + // "Previous" from inside a hunk is the one before it, not its start. + let here = hunk_at_line(hunks, line).filter(|&i| hunks[i].new.start <= line); + let before = match here { + Some(i) => i.checked_sub(1), + None => hunks.iter().rposition(|h| h.new.start < line), + }; + before.or(Some(hunks.len() - 1)) + } +} + +/// The base as the editor would show it: decoded the way the buffer was, and +/// with `\r\n` turned into `\n` like every buffer. +/// +/// The encoding is the buffer's rather than a fresh guess: a short index +/// version could sniff differently from the file it is an older copy of. +pub(crate) fn decode_base(bytes: &[u8], format: &TextFormat) -> String { + let encoding = format.encoding; + let text = if encoding == encoding_rs::UTF_8 { + let body = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + String::from_utf8_lossy(body).into_owned() + } else { + let body = if encoding == encoding_rs::UTF_16LE { + bytes.strip_prefix(b"\xFF\xFE").unwrap_or(bytes) + } else if encoding == encoding_rs::UTF_16BE { + bytes.strip_prefix(b"\xFE\xFF").unwrap_or(bytes) + } else { + bytes + }; + encoding.decode_without_bom_handling(body).0.into_owned() + }; + if text.contains("\r\n") { + text.replace("\r\n", "\n") + } else { + text + } +} + +/// What the host thread brings back for a buffer's base. +struct Fetched { + root: Option, + bytes: Option>, +} + +fn fetch_base(h: &dyn tty7_core::host::Host, path: &Path) -> Fetched { + let none = Fetched { + root: None, + bytes: None, + }; + let (Some(dir), Some(name)) = (path.parent(), path.file_name().and_then(|n| n.to_str())) else { + return none; + }; + let Some(root) = h.repo_root(dir).ok().flatten() else { + return none; + }; + let root = tty7_core::core::path_spelling::spelling_on_buf(h.id(), root); + // `:./name` is the index entry of `name` relative to the cwd. It fails for + // an untracked file and for one mid-merge (no stage 0), and both mean "no + // markers". + let spec = format!(":./{name}"); + let bytes = h + .git(dir, &["show", &spec]) + .ok() + .filter(|out| out.success()) + .map(|out| out.stdout); + Fetched { + root: Some(root), + bytes, + } +} + +/// The inline peek a click on a marker, or the peek command, opens. +struct Peek { + hunk: Hunk, + /// The base lines the hunk replaced. + original: String, + anchor: PeekAnchor, + /// Held while the peek is open, so Enter and Escape reach it. + focus: gpui::FocusHandle, +} + +/// Where the peek hangs. +enum PeekAnchor { + /// Where the marker was clicked, in window coordinates. + Point(Point), + /// Under the caret, wherever the last paint put it. + Caret, +} + +/// Which read of the base a buffer holds, or wants. +#[derive(Clone, Debug, PartialEq, Eq)] +struct BaseKey { + path: PathBuf, + epoch: u64, +} + +/// One buffer's change markers. +#[derive(Default)] +pub(crate) struct BufferGutter { + /// The index version, decoded. `None` for no markers. + base: Option>, + /// The repository the file is in, once a fetch has found out. + root: Option, + /// What `base` was read for; a different wanted key means read again. + fetched: Option, + fetching: bool, + hunks: Arc>, + /// Bumped on every edit; `diffed` is the value `hunks` were computed at. + edits: u64, + diffed: u64, + diff_task: Option>, + peek: Option, +} + +impl BufferGutter { + /// Hooks a new buffer's input up to the gutter: clicking a marker opens + /// the peek. + pub(crate) fn attach( + input: &gpui::Entity, + id: BufferId, + cx: &mut Context, + ) -> Self { + let app = cx.entity().downgrade(); + input.update(cx, |state, _| { + state.on_gutter_marker_click(move |click, window, cx| { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_open_peek( + id, + click.marker.lines.start, + PeekAnchor::Point(click.position), + window, + cx, + ) + }); + }); + }); + Self::default() + } + + /// The repository the buffer's file is in, if a base read found one. + pub(crate) fn root(&self) -> Option<&Path> { + self.root.as_deref() + } + + #[cfg(test)] + pub(crate) fn set_base_for_test(&mut self, base: &str) { + self.base = Some(base.into()); + self.edits += 1; + } +} + +impl Tty7App { + /// The repository of the file in front, as its gutter's base read found + /// it — for the SCM watch, which otherwise only knows the repositories + /// terminals have been in. + pub(crate) fn editor_gutter_root(&self) -> Option<&Path> { + self.active_buffer()?.gutter.root() + } +} + +fn enabled(cx: &gpui::App) -> bool { + cx.global::().editor_git_gutter +} + +fn scm_epoch(cx: &gpui::App, host: crate::ui::host_ops::HostId, root: &Path) -> u64 { + cx.try_global::() + .map(|data| data.epoch(host, root)) + .unwrap_or(0) +} + +impl Tty7App { + /// Once a frame while the editor is on screen: make sure the file in + /// front has a base that is current for its repository. + pub(crate) fn editor_gutter_sync(&mut self, cx: &mut Context) { + if !enabled(cx) { + return; + } + // Both groups' files when the editor is split: the one without the + // focus is on screen too. + let shown = self + .tab_code() + .map(|c| [c.active_id(), c.other_active_id()]) + .unwrap_or_default(); + for id in shown.into_iter().flatten() { + self.editor_gutter_sync_buffer(id, cx); + } + } + + fn editor_gutter_sync_buffer(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer(id) else { + return; + }; + if f.untitled.is_some() || f.gutter.fetching { + return; + } + let host = f.host.clone(); + let wanted = BaseKey { + path: f.path.clone(), + epoch: f + .gutter + .root + .as_deref() + .map_or(0, |root| scm_epoch(cx, host.id(), root)), + }; + if f.gutter.fetched.as_ref() == Some(&wanted) { + return; + } + let format = f.format; + if let Some(f) = self.buffer_mut(id) { + f.gutter.fetching = true; + } + let path = wanted.path.clone(); + HostOps::run( + host, + cx, + // A panic would skip the landing and leave `fetching` set for + // good; no base is the right answer to a read that blew up. + move |h| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch_base(h, &path))) + .unwrap_or(Fetched { + root: None, + bytes: None, + }) + }, + move |app, fetched: Fetched, cx| { + let Some(f) = app.buffer_mut(id) else { + return; + }; + f.gutter.fetching = false; + f.gutter.fetched = Some(wanted); + f.gutter.root = fetched.root; + let base: Option> = fetched + .bytes + .map(|bytes| decode_base(&bytes, &format).into()); + if f.gutter.base != base { + f.gutter.base = base; + // Not an edit, but the hunks are just as stale. + f.gutter.edits += 1; + if f.gutter.base.is_some() { + app.editor_gutter_schedule(id, Duration::ZERO, cx); + } else { + // Untracked now, or no longer in a repository. + let at = f.gutter.edits; + f.gutter.diff_task = None; + app.editor_gutter_install(id, at, Vec::new(), cx); + } + } + cx.notify(); + }, + ); + } + + /// Read the base again on the next frame — after a save, or when the + /// setting comes back on. + pub(crate) fn editor_gutter_refetch(&mut self, id: BufferId) { + if let Some(f) = self.buffer_mut(id) { + f.gutter.fetched = None; + } + } + + /// The buffer's text changed. + pub(crate) fn editor_gutter_note_edit(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer_mut(id) else { + return; + }; + f.gutter.edits += 1; + f.gutter.peek = None; + if f.gutter.base.is_some() { + self.editor_gutter_schedule(id, DIFF_DEBOUNCE, cx); + } + } + + /// Diff the buffer against its base after `delay`, off the UI thread. + fn editor_gutter_schedule(&mut self, id: BufferId, delay: Duration, cx: &mut Context) { + let Some(f) = self.buffer_mut(id) else { + return; + }; + let task = cx.spawn(async move |this, cx| { + if !delay.is_zero() { + cx.background_executor().timer(delay).await; + } + let Ok(Some((base, text, at))) = this.update(cx, |app, cx| { + let f = app.buffer(id)?; + let base = f.gutter.base.clone()?; + Some((base, f.input.read(cx).text().clone(), f.gutter.edits)) + }) else { + return; + }; + let hunks = cx + .background_executor() + .spawn(async move { diff_hunks(&base, &text.to_string()) }) + .await; + let _ = this.update(cx, |app, cx| app.editor_gutter_install(id, at, hunks, cx)); + }); + f.gutter.diff_task = Some(task); + } + + fn editor_gutter_install( + &mut self, + id: BufferId, + at: u64, + hunks: Vec, + cx: &mut Context, + ) { + let on = enabled(cx); + let Some(f) = self.buffer_mut(id) else { + return; + }; + // An edit landed while this was computing; its own diff is on the way. + if f.gutter.edits != at { + return; + } + f.gutter.diffed = at; + f.gutter.hunks = Arc::new(hunks); + let markers = if on { + f.gutter.hunks.iter().map(Hunk::marker).collect() + } else { + Vec::new() + }; + f.input + .clone() + .update(cx, |state, cx| state.set_gutter_markers(markers, cx)); + } + + /// The hunks for the text as it is right now, diffing on the spot if an + /// edit is still waiting out its debounce. + fn editor_gutter_hunks_now(&mut self, id: BufferId, cx: &mut Context) -> Arc> { + let Some(f) = self.buffer(id) else { + return Arc::default(); + }; + let Some(base) = f.gutter.base.clone().filter(|_| enabled(cx)) else { + return Arc::default(); + }; + if f.gutter.diffed != f.gutter.edits { + let at = f.gutter.edits; + let text = f.input.read(cx).text().to_string(); + let hunks = diff_hunks(&base, &text); + self.editor_gutter_install(id, at, hunks, cx); + } + self.buffer(id) + .map(|f| f.gutter.hunks.clone()) + .unwrap_or_default() + } + + fn editor_gutter_active(&self, window: &Window, cx: &Context) -> Option { + if !self.editor_panel_has_focus(window, cx) { + return None; + } + self.tab_code().and_then(TabCode::active_id) + } + + /// Next / previous change: put the cursor on the first line of the next + /// hunk, wrapping around the file. `false` when there is nowhere to go, so + /// the caller can let the key through. + pub(crate) fn editor_gutter_step( + &mut self, + forward: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(id) = self.editor_gutter_active(window, cx) else { + return false; + }; + let hunks = self.editor_gutter_hunks_now(id, cx); + let Some(f) = self.buffer(id) else { + return false; + }; + let input = f.input.clone(); + let line = input.read(cx).cursor_position().line as usize; + let Some(ix) = step_hunk(&hunks, line, forward) else { + return false; + }; + let last = input.read(cx).text().lines_len().saturating_sub(1); + let target = hunks[ix].new.start.min(last); + input.update(cx, |state, cx| { + state.set_cursor_position(Position::new(target as u32, 0), window, cx); + }); + true + } + + /// Whether the cursor sits in a change that could be reverted — for the + /// right-click menu, which reads the hunks as they were last drawn. + pub(crate) fn editor_gutter_can_revert(&self, id: BufferId, cx: &gpui::App) -> bool { + let Some(f) = self.buffer(id) else { + return false; + }; + if f.gutter.base.is_none() || !enabled(cx) { + return false; + } + let line = f.input.read(cx).cursor_position().line as usize; + hunk_at_line(&f.gutter.hunks, line).is_some() + } + + /// Put back the base lines of the change under the cursor, as one edit. + pub(crate) fn editor_gutter_revert_at_cursor( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(id) = self.editor_gutter_active(window, cx) else { + return false; + }; + let hunks = self.editor_gutter_hunks_now(id, cx); + let Some(f) = self.buffer(id) else { + return false; + }; + let line = f.input.read(cx).cursor_position().line as usize; + let Some(ix) = hunk_at_line(&hunks, line) else { + return false; + }; + self.editor_gutter_revert(id, &hunks[ix], window, cx) + } + + fn editor_gutter_revert( + &mut self, + id: BufferId, + hunk: &Hunk, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(f) = self.buffer(id) else { + return false; + }; + let Some(base) = f.gutter.base.clone() else { + return false; + }; + let input = f.input.clone(); + let text = input.read(cx).text().to_string(); + let (range, replacement) = revert_edit(&text, &base, hunk); + let start16 = text[..range.start].encode_utf16().count(); + let end16 = start16 + text[range.clone()].encode_utf16().count(); + let line = hunk.new.start; + let from_peek = f.gutter.peek.is_some(); + input.update(cx, |state, cx| { + state.replace_text_in_range(Some(start16..end16), &replacement, window, cx); + let last = state.text().lines_len().saturating_sub(1); + state.set_cursor_position(Position::new(line.min(last) as u32, 0), window, cx); + if from_peek { + state.focus(window, cx); + } + }); + if let Some(f) = self.buffer_mut(id) { + f.gutter.peek = None; + // The input's Change event lands after this returns; until then + // the hunks must not look current. + f.gutter.edits += 1; + } + cx.notify(); + true + } + + /// The command: flip the setting. + pub(crate) fn toggle_editor_git_gutter(&mut self, cx: &mut Context) { + self.set_editor_git_gutter(!enabled(cx), cx); + } + + /// Turn the markers on or off, dropping or bringing back every buffer's. + pub(crate) fn set_editor_git_gutter(&mut self, on: bool, cx: &mut Context) { + if on == enabled(cx) { + return; + } + self.update_config(cx, |cfg| cfg.editor_git_gutter = on); + let ids: Vec = self.editor.buffers.iter().map(|f| f.id()).collect(); + for id in ids { + let Some(f) = self.buffer_mut(id) else { + continue; + }; + f.gutter.peek = None; + if on { + f.gutter.fetched = None; + } else { + f.gutter = BufferGutter::default(); + f.input + .clone() + .update(cx, |state, cx| state.set_gutter_markers(Vec::new(), cx)); + } + } + cx.notify(); + } + + /// Open the peek on the hunk starting at buffer line `start`. + fn editor_gutter_open_peek( + &mut self, + id: BufferId, + start: usize, + anchor: PeekAnchor, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(f) = self.buffer(id) else { + return false; + }; + let Some(base) = f.gutter.base.clone() else { + return false; + }; + let Some(hunk) = f + .gutter + .hunks + .iter() + .find(|h| h.new.start == start) + .cloned() + else { + return false; + }; + let original = base + .split_inclusive('\n') + .skip(hunk.old.start) + .take(hunk.old.len()) + .collect(); + let focus = cx.focus_handle(); + window.focus(&focus, cx); + if let Some(f) = self.buffer_mut(id) { + f.gutter.peek = Some(Peek { + hunk, + original, + anchor, + focus, + }); + } + cx.notify(); + true + } + + /// The peek command: show the change under the caret — or, with the + /// caret on an unchanged line, go to the next change and show that. + pub(crate) fn editor_gutter_peek_at_cursor( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(id) = self.editor_gutter_active(window, cx) else { + return false; + }; + let hunks = self.editor_gutter_hunks_now(id, cx); + let Some(f) = self.buffer(id) else { + return false; + }; + let input = f.input.clone(); + let line = input.read(cx).cursor_position().line as usize; + let ix = match hunk_at_line(&hunks, line) { + Some(ix) => ix, + None => { + let Some(ix) = step_hunk(&hunks, line, true) else { + return false; + }; + let last = input.read(cx).text().lines_len().saturating_sub(1); + let target = hunks[ix].new.start.min(last); + input.update(cx, |state, cx| { + state.set_cursor_position(Position::new(target as u32, 0), window, cx); + }); + ix + } + }; + self.editor_gutter_open_peek(id, hunks[ix].new.start, PeekAnchor::Caret, window, cx) + } + + /// Close the peek from inside it, handing the keyboard back to the text. + fn editor_gutter_dismiss_peek( + &mut self, + id: BufferId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.buffer_mut(id) else { + return; + }; + if f.gutter.peek.take().is_some() { + let input = f.input.clone(); + input.update(cx, |state, cx| state.focus(window, cx)); + cx.notify(); + } + } + + /// Escape closes the peek before it closes anything else. + pub(crate) fn editor_gutter_close_peek(&mut self, cx: &mut Context) -> bool { + let Some(id) = self.tab_code().and_then(TabCode::active_id) else { + return false; + }; + let Some(f) = self.buffer_mut(id) else { + return false; + }; + if f.gutter.peek.take().is_none() { + return false; + } + cx.notify(); + true + } + + /// The peek for buffer `id`, when one is open: the lines the change + /// replaced and a Revert button. + pub(crate) fn render_editor_gutter_peek( + &self, + id: BufferId, + cx: &mut Context, + ) -> Option { + let f = self.buffer(id)?; + let peek = f.gutter.peek.as_ref()?; + let position = match peek.anchor { + PeekAnchor::Point(p) => p + gpui::point(px(8.), px(4.)), + PeekAnchor::Caret => { + let input = f.input.read(cx); + let caret = input.cursor(); + let bounds = input.range_to_bounds(&(caret..caret))?; + bounds.bottom_left() + gpui::point(px(0.), px(2.)) + } + }; + let theme = cx.theme(); + let removed = peek.hunk.old.len(); + let added = peek.hunk.new.len(); + let summary = t_fmt( + L10nKey::EditorGitPeekSummary, + &[ + ("removed", &removed.to_string()), + ("added", &added.to_string()), + ], + ); + let body: AnyElement = if peek.original.is_empty() { + div() + .px_3() + .py_2() + .text_sm() + .text_color(theme.muted_foreground) + .child(t(L10nKey::EditorGitPeekAddedOnly)) + .into_any_element() + } else { + let text = peek.original.strip_suffix('\n').unwrap_or(&peek.original); + div() + .id("editor-gutter-peek-body") + .max_h(px(240.)) + .overflow_y_scroll() + .bg(theme.danger.opacity(0.08)) + .px_3() + .py_1() + .font_family(theme.mono_font_family.clone()) + .text_size(theme.mono_font_size) + .children( + text.split('\n') + .map(|l| div().whitespace_nowrap().child(l.to_string())), + ) + .into_any_element() + }; + let app = cx.entity().downgrade(); + let hunk = peek.hunk.clone(); + let header = h_flex() + .gap_2() + .px_3() + .py_1p5() + .border_b_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .text_sm() + .text_color(theme.muted_foreground) + .child(summary), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(t(L10nKey::EditorGitPeekKeys)), + ) + .child( + Button::new("editor-gutter-peek-revert") + .small() + .label(t(L10nKey::EditorGitPeekRevert)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_revert(id, &hunk, window, cx); + }); + } + }), + ) + .child( + Button::new("editor-gutter-peek-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_dismiss_peek(id, window, cx) + }); + } + }), + ); + let keys_hunk = peek.hunk.clone(); + let card = v_flex() + .id("editor-gutter-peek") + .track_focus(&peek.focus) + .on_key_down({ + let app = app.clone(); + move |ev: &gpui::KeyDownEvent, window, cx| { + let ks = &ev.keystroke; + if ks.modifiers.modified() { + return; + } + match ks.key.as_str() { + "escape" => { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_dismiss_peek(id, window, cx) + }); + } + "enter" => { + let _ = app.update(cx, |app, cx| { + app.editor_gutter_revert(id, &keys_hunk, window, cx) + }); + } + _ => return, + } + cx.stop_propagation(); + } + }) + .occlude() + .min_w(px(280.)) + .max_w(px(640.)) + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.border) + .rounded(theme.radius) + .shadow_lg() + .overflow_hidden() + .child(header) + .child(body) + .on_mouse_down_out(move |_, _, cx| { + let _ = app.update(cx, |app, cx| app.editor_gutter_close_peek(cx)); + }); + Some( + deferred( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .child(card), + ) + .with_priority(1) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_component::input::GutterMarkerKind; + + fn h(old: Range, new: Range) -> Hunk { + Hunk { old, new } + } + + #[test] + fn identical_texts_have_no_hunks() { + assert!(diff_hunks("", "").is_empty()); + assert!(diff_hunks("a\nb\n", "a\nb\n").is_empty()); + } + + #[test] + fn added_lines_at_the_start_middle_and_end() { + assert_eq!(diff_hunks("b\nc\n", "a\nb\nc\n"), vec![h(0..0, 0..1)]); + assert_eq!(diff_hunks("a\nc\n", "a\nb\nb2\nc\n"), vec![h(1..1, 1..3)]); + assert_eq!(diff_hunks("a\n", "a\nb\n"), vec![h(1..1, 1..2)]); + // Typing on the empty last line of a file that ends in a newline. + assert_eq!(diff_hunks("a\n", "a\nb"), vec![h(1..1, 1..2)]); + assert_eq!(diff_hunks("", "a\n"), vec![h(0..0, 0..1)]); + } + + #[test] + fn deleted_lines_at_the_start_middle_and_end() { + assert_eq!(diff_hunks("a\nb\nc\n", "b\nc\n"), vec![h(0..1, 0..0)]); + assert_eq!(diff_hunks("a\nb\nc\n", "a\nc\n"), vec![h(1..2, 1..1)]); + assert_eq!(diff_hunks("a\nb\nc\n", "a\nb\n"), vec![h(2..3, 2..2)]); + assert_eq!(diff_hunks("a\n", ""), vec![h(0..1, 0..0)]); + } + + #[test] + fn modified_lines_and_separate_hunks() { + assert_eq!(diff_hunks("a\nb\nc\n", "a\nB\nc\n"), vec![h(1..2, 1..2)]); + assert_eq!( + diff_hunks("1\n2\n3\n4\n5\n6\n", "1\nX\n3\n4\n6\n7\n"), + vec![h(1..2, 1..2), h(4..5, 4..4), h(6..6, 5..6)] + ); + // One line replaced by two is one hunk, not a delete and an add. + assert_eq!(diff_hunks("a\nb\nc\n", "a\nx\ny\nc\n"), vec![h(1..2, 1..3)]); + } + + #[test] + fn a_lost_final_newline_is_a_change_to_the_last_line() { + assert_eq!(diff_hunks("a\nb\n", "a\nb"), vec![h(1..2, 1..2)]); + } + + #[test] + fn a_pasted_crlf_is_not_a_change() { + assert!(diff_hunks("a\nb\n", "a\r\nb\n").is_empty()); + } + + #[test] + fn diffs_are_minimal_on_interleaved_edits() { + let base: String = (0..200).map(|i| format!("line {i}\n")).collect(); + let current: String = (0..200) + .map(|i| match i % 7 { + 0 => format!("changed {i}\n"), + 3 => String::new(), + _ => format!("line {i}\n"), + }) + .collect(); + let hunks = diff_hunks(&base, ¤t); + let changed = (0..200).filter(|i| i % 7 == 0 || i % 7 == 3).count(); + assert_eq!(hunks.len(), changed); + // Applying every revert, last first, gives the base back. + let mut text = current.clone(); + for hunk in hunks.iter().rev() { + let (range, replacement) = revert_edit(&text, &base, hunk); + text.replace_range(range, &replacement); + } + assert_eq!(text, base); + } + + #[test] + fn a_huge_rewrite_falls_back_to_one_block() { + let base: String = (0..3000).map(|i| format!("a{i}\n")).collect(); + let current: String = (0..3000).map(|i| format!("b{i}\n")).collect(); + let hunks = diff_hunks(&base, &format!("keep\n{current}")); + assert_eq!(hunks, vec![h(0..3000, 0..3001)]); + } + + #[test] + fn markers_follow_the_hunk_kinds() { + let kinds: Vec<_> = diff_hunks("a\nb\nc\nd\ne\n", "a\nB\nc\ne\nf\n") + .iter() + .map(|h| { + let m = h.marker(); + (m.lines, m.kind) + }) + .collect(); + assert_eq!( + kinds, + vec![ + (1..2, GutterMarkerKind::Modified), + (3..3, GutterMarkerKind::Deleted), + (4..5, GutterMarkerKind::Added), + ] + ); + } + + #[test] + fn reverting_puts_the_base_lines_back() { + let base = "a\nb\nc\n"; + for current in [ + "a\nB\nc\n", + "a\nc\n", + "a\nb\nx\nc\n", + "x\na\nb\nc\n", + "a\nb\nc\nz", + "a\nb\nc", + ] { + let hunks = diff_hunks(base, current); + assert_eq!(hunks.len(), 1, "{current:?}"); + let (range, replacement) = revert_edit(current, base, &hunks[0]); + let mut text = current.to_string(); + text.replace_range(range, &replacement); + assert_eq!(text, base, "reverting {current:?}"); + } + } + + #[test] + fn the_cursor_finds_its_hunk_and_steps_between_them() { + let hunks = vec![h(1..2, 1..2), h(4..5, 4..4), h(6..6, 5..7)]; + assert_eq!(hunk_at_line(&hunks, 1), Some(0)); + assert_eq!(hunk_at_line(&hunks, 2), None); + // A deletion belongs to the lines on either side of it. + assert_eq!(hunk_at_line(&hunks, 3), Some(1)); + assert_eq!(hunk_at_line(&hunks, 4), Some(1)); + assert_eq!(hunk_at_line(&hunks, 6), Some(2)); + + assert_eq!(step_hunk(&hunks, 0, true), Some(0)); + assert_eq!(step_hunk(&hunks, 1, true), Some(1)); + assert_eq!(step_hunk(&hunks, 5, true), Some(0), "wraps"); + assert_eq!(step_hunk(&hunks, 6, false), Some(1)); + assert_eq!(step_hunk(&hunks, 3, false), Some(0)); + assert_eq!(step_hunk(&hunks, 0, false), Some(2), "wraps"); + assert_eq!(step_hunk(&[], 0, true), None); + } + + #[test] + fn the_base_is_decoded_the_way_the_buffer_was() { + use crate::ui::editor_text::{LineEnding, TextFormat}; + + let crlf = TextFormat { + line_ending: LineEnding::CrLf, + ..TextFormat::default() + }; + assert_eq!(decode_base(b"a\r\nb\r\n", &crlf), "a\nb\n"); + // An index that holds LF for a CRLF checkout (autocrlf) compares the same. + assert_eq!(decode_base(b"a\nb\n", &crlf), "a\nb\n"); + assert_eq!( + decode_base(b"\xEF\xBB\xBFhi\n", &TextFormat::default()), + "hi\n" + ); + + let gb = TextFormat { + encoding: encoding_rs::GB18030, + ..TextFormat::default() + }; + let (bytes, _, _) = encoding_rs::GB18030.encode("中文\n"); + assert_eq!(decode_base(&bytes, &gb), "中文\n"); + + let utf16 = TextFormat { + encoding: encoding_rs::UTF_16LE, + bom: true, + ..TextFormat::default() + }; + let mut bytes = vec![0xFF, 0xFE]; + for unit in "é\r\n".encode_utf16() { + bytes.extend(unit.to_le_bytes()); + } + assert_eq!(decode_base(&bytes, &utf16), "é\n"); + } + + #[test] + fn step_wraps_forward_past_the_last_hunk() { + let hunks = vec![h(1..2, 1..2)]; + assert_eq!(step_hunk(&hunks, 1, true), Some(0)); + assert_eq!(step_hunk(&hunks, 9, true), Some(0)); + } + + /// The whole loop in a real window: a base, an edited buffer, markers on + /// the input, stepping between changes, and a revert that is one undo. + #[gpui::test] + fn markers_steps_and_revert_in_an_open_buffer(cx: &mut gpui::TestAppContext) { + use crate::ui::app::test_window; + use gpui_component::input::GutterMarkerKind; + + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + let base = "one\ntwo\nthree\nfour\n"; + let (id, input) = app.update_in(&mut vcx, |app, window, cx| { + app.editor_new_file(window, cx); + let id = app + .tab_code() + .and_then(TabCode::active_id) + .expect("a buffer"); + let input = app.buffer(id).expect("the buffer").input.clone(); + input.update(cx, |state, cx| { + state.set_value("one\nTWO\nthree\nfour\nfive\n", window, cx); + state.focus(window, cx); + }); + app.buffer_mut(id).unwrap().gutter.set_base_for_test(base); + (id, input) + }); + vcx.run_until_parked(); + + app.update_in(&mut vcx, |app, window, cx| { + let hunks = app.editor_gutter_hunks_now(id, cx); + assert_eq!(*hunks, vec![h(1..2, 1..2), h(4..4, 4..5)]); + let kinds: Vec<_> = input + .read(cx) + .gutter_markers() + .iter() + .map(|m| (m.lines.clone(), m.kind)) + .collect(); + assert_eq!( + kinds, + vec![ + (1..2, GutterMarkerKind::Modified), + (4..5, GutterMarkerKind::Added) + ] + ); + + assert!(app.editor_gutter_step(true, window, cx)); + assert_eq!(input.read(cx).cursor_position().line, 1); + assert!(app.editor_gutter_step(true, window, cx)); + assert_eq!(input.read(cx).cursor_position().line, 4); + assert!(app.editor_gutter_step(true, window, cx), "wraps"); + assert_eq!(input.read(cx).cursor_position().line, 1); + + assert!(app.editor_gutter_can_revert(id, cx)); + assert!(app.editor_gutter_revert_at_cursor(window, cx)); + assert_eq!( + input.read(cx).text().to_string(), + "one\ntwo\nthree\nfour\nfive\n" + ); + assert_eq!(*app.editor_gutter_hunks_now(id, cx), vec![h(4..4, 4..5)]); + }); + + // One undo takes the whole revert back. + vcx.update(|window, cx| { + window.dispatch_action(Box::new(gpui_component::input::Undo), cx); + }); + vcx.run_until_parked(); + vcx.update(|_, cx| { + assert_eq!( + input.read(cx).text().to_string(), + "one\nTWO\nthree\nfour\nfive\n" + ); + }); + + // The peek from the keyboard: from an unchanged line it goes to the + // next change and takes the keys; Escape hands them back. + let peek_open = |app: &gpui::Entity, vcx: &mut gpui::VisualTestContext| { + app.update_in(vcx, |app, _, _| { + app.buffer(id).unwrap().gutter.peek.is_some() + }) + }; + app.update_in(&mut vcx, |app, window, cx| { + input.update(cx, |state, cx| { + state.set_cursor_position(Position::new(0, 0), window, cx); + state.focus(window, cx); + }); + assert!(app.editor_gutter_peek_at_cursor(window, cx)); + assert_eq!(input.read(cx).cursor_position().line, 1); + }); + vcx.run_until_parked(); + assert!(peek_open(&app, &mut vcx)); + vcx.simulate_keystrokes("escape"); + vcx.run_until_parked(); + assert!(!peek_open(&app, &mut vcx)); + vcx.update(|window, cx| { + use gpui::Focusable as _; + assert!(input.read(cx).focus_handle(cx).is_focused(window)); + }); + + // Enter in the peek reverts the change it shows. + app.update_in(&mut vcx, |app, window, cx| { + assert!(app.editor_gutter_peek_at_cursor(window, cx)); + }); + vcx.run_until_parked(); + vcx.simulate_keystrokes("enter"); + vcx.run_until_parked(); + assert!(!peek_open(&app, &mut vcx)); + vcx.update(|_, cx| { + assert_eq!( + input.read(cx).text().to_string(), + "one\ntwo\nthree\nfour\nfive\n" + ); + }); + } + + /// The base is the index — staged edits are not changes — read through + /// the host; an untracked file and a file outside any repository have + /// none. + #[test] + fn the_base_is_the_staged_version() { + fn git(root: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?} failed"); + } + let root = std::env::temp_dir().join(format!("tty7-gutter-base-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("sub")).unwrap(); + let root = crate::ui::code_editor::tests::test_real_dir(&root); + git(&root, &["init", "--quiet"]); + let file = root.join("sub").join("a b.txt"); + std::fs::write(&file, "one\n").unwrap(); + git(&root, &["add", "."]); + git( + &root, + &[ + "-c", + "user.email=t@x", + "-c", + "user.name=t", + "commit", + "-qm", + "one", + ], + ); + std::fs::write(&file, "two\n").unwrap(); + git(&root, &["add", "."]); + std::fs::write(&file, "three\n").unwrap(); + std::fs::write(root.join("new.txt"), "new\n").unwrap(); + + let host = tty7_core::host::local::LocalHost::new(); + let fetched = fetch_base(&*host, &file); + assert_eq!(fetched.root.as_deref(), Some(root.as_path())); + assert_eq!(fetched.bytes.as_deref(), Some(&b"two\n"[..])); + + let untracked = fetch_base(&*host, &root.join("new.txt")); + assert!(untracked.root.is_some()); + assert_eq!(untracked.bytes, None); + + let _ = std::fs::remove_dir_all(&root); + let outside = + std::env::temp_dir().join(format!("tty7-gutter-plain-{}", std::process::id())); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("x.txt"), "x\n").unwrap(); + let plain = fetch_base(&*host, &outside.join("x.txt")); + assert!(plain.bytes.is_none()); + let _ = std::fs::remove_dir_all(&outside); + } +} diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs new file mode 100644 index 00000000..61742322 --- /dev/null +++ b/src/ui/code_editor/nav.rs @@ -0,0 +1,1436 @@ +//! Getting around the editor: back and forward through where the caret has +//! been, Go to Symbol, and the breadcrumbs over the text. +//! +//! **History.** Every jump — Go to File, Go to Line, Go to Symbol, a file link +//! clicked in the grid, a definition a language server found, a click far down +//! the file — is noticed the same way: [`Tty7App::editor_nav_tick`] samples the +//! caret each time the editor draws, and a caret that changed file, or moved +//! [`JUMP_LINES`] or more without the text changing under it, has jumped. The +//! place it left goes on the back stack. Nothing has to remember to report a +//! jump, so a new way of jumping cannot forget to. +//! +//! One history per tab, like a tab's strip of files: each tab is its own +//! editor group. A place in a file that has since been closed opens the file +//! again; one in an untitled buffer that is gone is skipped. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::prelude::*; +use gpui::{AnyElement, App, Context, Window, div, px}; +use gpui_component::input::{LineEdit, Position}; +use gpui_component::{ActiveTheme as _, h_flex}; +use tty7_core::core::machine::TabId; + +use super::outline::{self, Outline, SymbolKind}; +use super::{BufferId, CURSOR_SCROLL_ATTEMPTS, TabCode, place_cursor}; +use crate::ui::app::Tty7App; +use crate::ui::host_ops::HostId; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::search::{CommandKind, Item, SearchTab}; + +/// A caret that moves this many lines or more at once has jumped. Arrowing +/// and typing never do; a click, a search hit or Page Down do. +pub(crate) const JUMP_LINES: u32 = 10; + +/// How many places the back stack keeps. +pub(crate) const MAX_HISTORY: usize = 50; + +/// How long a Back or Forward waits for the file it asked for to open before +/// the caret is watched for jumps again. +const PENDING_TIMEOUT: Duration = Duration::from_secs(3); + +/// How often an outline is rebuilt while the text keeps changing. Between +/// rebuilds the breadcrumbs show the last one, a keystroke or two stale. +const OUTLINE_THROTTLE: Duration = Duration::from_millis(250); + +/// The key context the editor's chords are bound in: the text field's own. +/// +/// Not a context of the editor panel's: gpui ranks a context-free binding as +/// deep as whatever has focus, so a panel-level binding would lose ⌘⇧O to the +/// workspace switcher. At the text field's depth, and listed after the +/// window's table, it wins. Only the editor panel handles these actions, so in +/// any other text field — or the terminal — the chord falls through to the +/// binding next in line. +pub(crate) const KEY_CONTEXT: &str = "Input"; + +/// One place the caret has been. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct NavLocation { + pub host: HostId, + /// Empty for an untitled buffer. + pub path: PathBuf, + /// Set only for an untitled buffer, which has no path to be found by and + /// can be returned to only while it is still open. + pub buffer: Option, + pub pos: Position, +} + +impl NavLocation { + fn same_file(&self, other: &NavLocation) -> bool { + self.host == other.host && self.path == other.path && self.buffer == other.buffer + } + + /// In the same file and within a jump of each other: one place, as far + /// as history is concerned. + fn near(&self, other: &NavLocation) -> bool { + self.same_file(other) && self.pos.line.abs_diff(other.pos.line) < JUMP_LINES + } +} + +/// Back and forward stacks, as a browser keeps them. +#[derive(Clone, Debug, Default)] +pub(crate) struct NavHistory { + back: Vec, + forward: Vec, +} + +impl NavHistory { + /// The caret jumped away from `from`. A new jump forgets the way forward, + /// and a place near the last one recorded replaces it rather than + /// stacking up beside it. + pub(crate) fn push(&mut self, from: NavLocation) { + self.forward.clear(); + match self.back.last_mut() { + Some(last) if last.near(&from) => *last = from, + _ => self.back.push(from), + } + if self.back.len() > MAX_HISTORY { + self.back.remove(0); + } + } + + /// Where Back goes from `current`, which becomes the way forward. + /// Places near `current` are passed over: going back to where you already + /// are would look like the key did nothing. + pub(crate) fn go_back(&mut self, current: Option) -> Option { + let target = pop_past(&mut self.back, current.as_ref())?; + if let Some(current) = current { + self.forward.push(current); + } + Some(target) + } + + /// Where Forward goes from `current`, which goes back on the back stack. + pub(crate) fn go_forward(&mut self, current: Option) -> Option { + let target = pop_past(&mut self.forward, current.as_ref())?; + if let Some(current) = current { + match self.back.last_mut() { + Some(last) if last.near(¤t) => *last = current, + _ => self.back.push(current), + } + } + Some(target) + } + + /// Moves the places in one file along with edits made to it, so a place + /// keeps pointing at the same code after lines above it come and go. + pub(crate) fn shift(&mut self, file: &NavLocation, edits: &[LineEdit]) { + for loc in self.back.iter_mut().chain(self.forward.iter_mut()) { + if loc.same_file(file) { + loc.pos.line = shift_line(loc.pos.line, edits); + } + } + } + + /// Drops the places `keep` turns down, from both stacks. + pub(crate) fn retain(&mut self, keep: impl Fn(&NavLocation) -> bool) { + self.back.retain(&keep); + self.forward.retain(&keep); + } + + #[cfg(test)] + fn lens(&self) -> (usize, usize) { + (self.back.len(), self.forward.len()) + } +} + +fn shift_line(line: u32, edits: &[LineEdit]) -> u32 { + edits + .iter() + .fold(line as usize, |line, edit| edit.shift(line)) + .min(u32::MAX as usize) as u32 +} + +fn pop_past(stack: &mut Vec, current: Option<&NavLocation>) -> Option { + loop { + let top = stack.pop()?; + if !current.is_some_and(|c| c.near(&top)) { + return Some(top); + } + } +} + +/// Where the caret is, and how long the text is — a text that changed length +/// since the last sample was being edited, and an edit is not a jump. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Sample { + pub at: NavLocation, + pub text_len: usize, +} + +/// One tab's history, and the caret sample the next one is compared against. +#[derive(Default)] +pub(crate) struct TabNav { + pub(crate) history: NavHistory, + last: Option, + /// A Back or Forward on its way to a file that is still opening. Until it + /// lands, the caret that has not got there yet is not a jump. + pending: Option<(NavLocation, Instant)>, +} + +impl TabNav { + /// Compares a fresh sample against the last one and records a jump. + /// `quiet` says the caret was moved by a key that pages or pastes, which + /// can go a long way without anyone having jumped anywhere. + pub(crate) fn observe(&mut self, now: Option, at: Instant, quiet: bool) { + if let Some((target, since)) = &self.pending { + let arrived = now.as_ref().is_some_and(|s| s.at.same_file(target)); + if arrived || at.duration_since(*since) > PENDING_TIMEOUT { + self.pending = None; + if arrived { + self.last = now; + return; + } + } else { + return; + } + } + let Some(now) = now else { + // No buffer in front: nothing to compare the next one against but + // the last place the caret was, which is kept. + return; + }; + if let Some(last) = &self.last { + let jumped = !last.at.same_file(&now.at) + || (!quiet + && last.text_len == now.text_len + && last.at.pos.line.abs_diff(now.at.pos.line) >= JUMP_LINES); + if jumped { + self.history.push(last.at.clone()); + } + } + self.last = Some(now); + } + + /// Moves the history, and the last sample, along with edits to a file. + fn shift(&mut self, file: &NavLocation, edits: &[LineEdit]) { + self.history.shift(file, edits); + if let Some(last) = self.last.as_mut().filter(|l| l.at.same_file(file)) { + last.at.pos.line = shift_line(last.at.pos.line, edits); + } + } + + fn expect(&mut self, target: NavLocation, at: Instant) { + self.pending = Some((target, at)); + } +} + +struct OutlineCache { + text: gpui_component::Rope, + outline: Arc, + at: Instant, +} + +/// Go to Symbol's preview: where the caret was before the picker started +/// moving it, put back if the picker is dismissed. +struct SymbolPreview { + buffer: BufferId, + origin: Position, + scroll: gpui::Point, +} + +/// The window's navigation state, held by `EditorPanelState`. +#[derive(Default)] +pub(crate) struct EditorNav { + tabs: HashMap, + outlines: HashMap, + /// Symbols handed in from elsewhere — a language server's + /// `documentSymbol` — which win over the tree's. + provided: HashMap>, + preview: Option, + refresh_scheduled: bool, + /// How far into each buffer's edit log the history has been moved. + edits_seen: HashMap, + /// A key that pages or pastes was just pressed in the editor: the caret + /// move it makes is not a jump. + quiet_move: bool, + _keystrokes: Option, +} + +/// Whether an action moves the caret a long way without jumping anywhere: +/// paging through the file, or pasting, undoing and redoing a large edit. +fn is_quiet_move(action: &dyn gpui::Action) -> bool { + use gpui_component::input::{MovePageDown, MovePageUp, Paste, Redo, Undo}; + let any = action.as_any(); + any.is::() + || any.is::() + || any.is::() + || any.is::() + || any.is::() +} + +impl EditorNav { + pub(crate) fn new(cx: &mut Context) -> Self { + let keystrokes = cx.observe_keystrokes(|this, event, window, cx| { + if event.action.as_deref().is_some_and(is_quiet_move) + && this.editor_panel_has_focus(window, cx) + { + this.editor.nav.quiet_move = true; + } + }); + Self { + _keystrokes: Some(keystrokes), + ..Self::default() + } + } + + pub(crate) fn forget_tab(&mut self, tab: TabId) { + self.tabs.remove(&tab); + } + + pub(crate) fn forget_buffer(&mut self, id: BufferId) { + self.outlines.remove(&id); + self.edits_seen.remove(&id); + self.provided.remove(&id); + for nav in self.tabs.values_mut() { + nav.history.retain(|l| l.buffer != Some(id)); + } + } +} + +impl Tty7App { + fn nav_tab(&self) -> Option { + self.tabs.get(self.active).map(|t| t.tree_id.get()) + } + + /// Where the caret is in the buffer in front, if there is one. + fn nav_here(&self, cx: &gpui::App) -> Option { + let f = self.active_buffer()?; + let state = f.input.read(cx); + Some(Sample { + at: NavLocation { + host: f.host.id(), + path: f.path.clone(), + buffer: f.untitled.map(|_| f.id()), + pos: state.cursor_position(), + }, + text_len: state.text().len(), + }) + } + + /// Samples the caret for jumps. Called whenever the editor draws, which + /// every caret move and every edit makes it do. + pub(crate) fn editor_nav_tick(&mut self, cx: &mut Context) { + // The picker moving the caret around is looking, not jumping; the + // jump it ends in is seen on the first draw after it closes. + if self.editor.nav.preview.is_some() { + return; + } + let Some(tab) = self.nav_tab() else { return }; + let now = self.nav_here(cx); + let quiet = std::mem::take(&mut self.editor.nav.quiet_move); + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .observe(now, Instant::now(), quiet); + } + + /// A buffer's text changed: every tab's history of it moves with the + /// lines. A buffer edited so much the log has forgotten some of it keeps + /// its places where they were. + pub(crate) fn editor_nav_note_edit(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer(id) else { return }; + let file = NavLocation { + host: f.host.id(), + path: f.path.clone(), + buffer: f.untitled.map(|_| id), + pos: Position::default(), + }; + let state = f.input.read(cx); + let version = state.edit_version(); + let seen = self.editor.nav.edits_seen.insert(id, version).unwrap_or(0); + let Some(edits) = state.line_edits_since(seen) else { + return; + }; + if edits.is_empty() { + return; + } + for nav in self.editor.nav.tabs.values_mut() { + nav.shift(&file, &edits); + } + } + + /// Back (or, with `forward`, Forward) through the active tab's history. + pub(crate) fn editor_navigate( + &mut self, + forward: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.nav_tab() else { return }; + // Settle the caret's latest move first, or a jump made since the + // last draw would be lost. + self.editor_nav_tick(cx); + loop { + let current = self.nav_here(cx).map(|s| s.at); + let nav = self.editor.nav.tabs.entry(tab).or_default(); + let target = match forward { + true => nav.history.go_forward(current), + false => nav.history.go_back(current), + }; + let Some(target) = target else { return }; + if self.nav_open(tab, target, window, cx) { + return; + } + } + } + + /// Shows `target`. `false` when it is gone for good — an untitled buffer + /// that was closed, a machine no longer connected. + fn nav_open( + &mut self, + tab: TabId, + target: NavLocation, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let at = Instant::now(); + if let Some(id) = target.buffer { + if self.buffer(id).is_none() { + return false; + } + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .expect(target.clone(), at); + self.editor_show_in_tab(tab, id, true, window, cx); + if let Some(f) = self.buffer(id) { + place_cursor( + f.input.clone(), + target.pos, + CURSOR_SCROLL_ATTEMPTS, + window, + cx, + ); + } + return true; + } + let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, target.host) else { + return false; + }; + self.editor + .nav + .tabs + .entry(tab) + .or_default() + .expect(target.clone(), at); + // The same road a file link takes, so a file closed since opens again. + self.editor.pending_cursor = Some(( + target.path.clone(), + target.pos.line + 1, + target.pos.character + 1, + )); + self.editor_open_on_host(host, &target.path, window, cx); + true + } + + // ---- Outline ---- + + /// Replaces the tree's symbols for `buffer` with ones found elsewhere — a + /// language server's `textDocument/documentSymbol`, flattened with + /// [`Outline::from_nodes`]. `None` goes back to the tree's. The caller + /// keeps them current: they are shown as given until replaced. + pub(crate) fn editor_set_document_symbols( + &mut self, + buffer: BufferId, + symbols: Option, + cx: &mut Context, + ) { + match symbols { + Some(outline) => self.editor.nav.provided.insert(buffer, Arc::new(outline)), + None => self.editor.nav.provided.remove(&buffer), + }; + cx.notify(); + } + + /// A buffer's outline. `fresh` rebuilds a stale one now; otherwise a text + /// that keeps changing is re-outlined at most every [`OUTLINE_THROTTLE`], + /// with a redraw scheduled for when it may be. + fn editor_outline( + &mut self, + id: BufferId, + fresh: bool, + cx: &mut Context, + ) -> Option> { + if let Some(provided) = self.editor.nav.provided.get(&id) { + return Some(provided.clone()); + } + let f = self.buffer(id)?; + let (language, tree, text) = match f.input.read(cx).syntax_tree() { + Some((language, tree, text)) => (language.to_string(), Some(tree), text), + // Not parsed yet — a buffer that has never been drawn. Only worth + // a parse of our own when someone is waiting on the answer. + None if fresh => ( + f.language().to_string(), + None, + f.input.read(cx).text().clone(), + ), + None => return None, + }; + if !outline::supports(&language) { + return None; + } + if let Some(cache) = self.editor.nav.outlines.get(&id) { + let current = cache.text.len() == text.len() && cache.text == text; + if current { + return Some(cache.outline.clone()); + } + if !fresh && cache.at.elapsed() < OUTLINE_THROTTLE { + let stale = cache.outline.clone(); + self.nav_schedule_refresh(cx); + return Some(stale); + } + } + let outline = Arc::new(match tree { + Some(tree) => outline::outline_from_tree(&language, &tree, &text), + None => outline::outline_of_text(&language, &text), + }); + self.editor.nav.outlines.insert( + id, + OutlineCache { + text, + outline: outline.clone(), + at: Instant::now(), + }, + ); + Some(outline) + } + + fn nav_schedule_refresh(&mut self, cx: &mut Context) { + if self.editor.nav.refresh_scheduled { + return; + } + self.editor.nav.refresh_scheduled = true; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(OUTLINE_THROTTLE).await; + let _ = this.update(cx, |this, cx| { + this.editor.nav.refresh_scheduled = false; + cx.notify(); + }); + }) + .detach(); + } + + // ---- Go to Symbol ---- + + /// Go to Symbol in the file in front: the search, on its Symbols tab. + /// The same chord puts it away again. + pub(crate) fn editor_go_to_symbol(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(view) = self.search.clone() { + if view.read(cx).tab() == SearchTab::Symbols { + self.close_search(window, cx); + return; + } + self.close_search(window, cx); + } + let Some(f) = self.active_buffer() else { + return; + }; + let id = f.id(); + let input = f.input.clone(); + // A symbol is a place in the source; a rendered Markdown file shows + // the source to be taken to it. + if let Some(f) = self.buffer_mut(id) { + f.preview = false; + } + let (origin, scroll) = { + let state = input.read(cx); + (state.cursor_position(), state.scroll_offset()) + }; + let outline = self.editor_outline(id, true, cx).unwrap_or_default(); + let rows = symbol_rows(&outline); + let here = outline + .chain_at(origin) + .last() + .map(|&ix| symbol_command(&outline.symbols[ix])); + self.open_search(SearchTab::Symbols, "", window, cx); + self.editor.nav.preview = Some(SymbolPreview { + buffer: id, + origin, + scroll, + }); + let live = self.lsp_project_symbol_query(window, cx); + if let Some(view) = self.search.clone() { + view.update(cx, |view, cx| { + view.set_symbols(rows, here, window, cx); + if let Some(live) = live { + view.set_live_query(live, window, cx); + } + }); + } + } + + /// The editor's search row as this window can fill it: Files always, + /// Symbols with a file in front. + pub(crate) fn editor_search_tabs(&self) -> Vec { + let mut tabs = vec![SearchTab::Files]; + if self.active_buffer().is_some() { + tabs.push(SearchTab::Symbols); + } + tabs + } + + /// Shows a symbol the picker is on, leaving the keyboard in the picker. + pub(crate) fn editor_symbol_preview(&mut self, line: u32, column: u32, cx: &mut Context) { + let Some(buffer) = self.editor.nav.preview.as_ref().map(|p| p.buffer) else { + return; + }; + let Some(f) = self.buffer(buffer) else { return }; + f.input.update(cx, |state, cx| { + state.preview_cursor_position(Position::new(line, column), cx) + }); + } + + /// Begins a preview in the file in front for a picker someone else + /// filled — Find References (`ui::lsp`) — so arrowing through it moves + /// the caret and closing it puts the caret back, as Go to Symbol does. + pub(crate) fn editor_begin_preview(&mut self, cx: &App) { + let Some(f) = self.active_buffer() else { + return; + }; + let (origin, scroll) = { + let state = f.input.read(cx); + (state.cursor_position(), state.scroll_offset()) + }; + self.editor.nav.preview = Some(SymbolPreview { + buffer: f.id(), + origin, + scroll, + }); + } + + /// The buffer a preview is running in, if one is. + pub(crate) fn editor_preview_buffer(&self) -> Option { + self.editor.nav.preview.as_ref().map(|p| p.buffer) + } + + /// The picker's choice is final: the caret stays where it took it. + pub(crate) fn editor_symbol_preview_commit(&mut self) { + self.editor.nav.preview = None; + } + + /// The picker went away without a choice: the caret and the scroll go + /// back to where they were, and so does the keyboard. + pub(crate) fn editor_symbol_preview_cancel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let Some(preview) = self.editor.nav.preview.take() else { + return; + }; + let Some(f) = self.buffer(preview.buffer) else { + return; + }; + f.input.update(cx, |state, cx| { + state.set_cursor_position(preview.origin, window, cx); + state.set_scroll_offset(preview.scroll, cx); + }); + } + + /// Puts the caret on a 0-based position in the buffer in front. + pub(crate) fn editor_go_to_position( + &mut self, + line: u32, + column: u32, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.nav.preview = None; + let Some(f) = self.active_buffer() else { + return; + }; + place_cursor( + f.input.clone(), + Position::new(line, column), + CURSOR_SCROLL_ATTEMPTS, + window, + cx, + ); + } + + // ---- Chrome ---- + + /// The palette's editor rows, offered while a file is in front of it. + pub(crate) fn editor_palette_items(&self) -> Vec { + use crate::ui::search::CommandGroup; + if !self.code_panel_visible() || self.active_buffer().is_none() { + return Vec::new(); + } + [ + (L10nKey::CmdEditorGoToSymbol, CommandKind::EditorGoToSymbol), + (L10nKey::CmdEditorGoBack, CommandKind::EditorNavigateBack), + ( + L10nKey::CmdEditorGoForward, + CommandKind::EditorNavigateForward, + ), + (L10nKey::CmdEditorSplitRight, CommandKind::EditorSplitRight), + ] + .into_iter() + .map(|(key, kind)| Item::localized(key, kind).in_group(CommandGroup::View)) + .collect() + } + + /// The editor's own chords, handled by the element that holds it. + pub(crate) fn editor_nav_actions( + &self, + element: E, + cx: &mut Context, + ) -> E { + use crate::core::actions::{ + EditorFocusLeftGroup, EditorFocusRightGroup, EditorGoToSymbol, EditorNavigateBack, + EditorNavigateForward, EditorSplitRight, + }; + element + .on_action(cx.listener(|this, _: &EditorSplitRight, window, cx| { + if !this.editor_split(window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorFocusLeftGroup, window, cx| { + if !this.editor_focus_group(true, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorFocusRightGroup, window, cx| { + if !this.editor_focus_group(false, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorGoToSymbol, window, cx| { + this.editor_go_to_symbol(window, cx) + })) + .on_action(cx.listener(|this, _: &EditorNavigateBack, window, cx| { + this.editor_navigate(false, window, cx) + })) + .on_action(cx.listener(|this, _: &EditorNavigateForward, window, cx| { + this.editor_navigate(true, window, cx) + })) + } + + /// The row over the text: where the file is, then which symbols the caret + /// is inside. The symbols open Go to Symbol. + pub(crate) fn render_editor_breadcrumbs( + &mut self, + window: &Window, + cx: &mut Context, + ) -> Option { + let id = self.tab_code().and_then(TabCode::active_id)?; + let slot = self.tab_code().map_or(0, TabCode::focused_slot); + self.render_editor_breadcrumbs_for(id, slot, true, window, cx) + } + + /// The breadcrumbs of one group's file. `focused` is whether that group + /// has the focus; the other group's symbols first bring the focus over. + pub(crate) fn render_editor_breadcrumbs_for( + &mut self, + id: BufferId, + slot: usize, + focused: bool, + window: &Window, + cx: &mut Context, + ) -> Option { + let f = self.buffer(id)?; + let caret = f.input.read(cx).cursor_position(); + let segments: Vec = if f.untitled.is_some() { + vec![f.label().to_string()] + } else { + let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); + let local = f.host.id() == self.spawn_host(cx); + path_segments(&f.path, if local { roots } else { &[] }) + }; + let file = (f.untitled.is_none()).then(|| f.path.clone()); + // A rendered Markdown file keeps the path; its symbols are the source's. + let supported = !f.preview && f.untitled.is_none() && outline::supports(f.language()); + let chain: Vec<(SymbolKind, String)> = match supported { + true => self + .editor_outline(id, false, cx) + .map(|o| { + o.chain_at(caret) + .into_iter() + .map(|ix| (o.symbols[ix].kind, o.symbols[ix].name.clone())) + .collect() + }) + .unwrap_or_default(), + false => Vec::new(), + }; + + let theme = cx.theme(); + let (fg, muted) = (theme.foreground, theme.muted_foreground); + let sep = || div().flex_none().px(px(4.)).text_color(muted).child("›"); + let last = segments.len().saturating_sub(1); + let mut path = h_flex() + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .items_center(); + for (i, seg) in segments.into_iter().enumerate() { + if i > 0 { + path = path.child(sep()); + } + path = path.child( + div() + .flex_none() + .whitespace_nowrap() + .text_color(if i == last { fg } else { muted }) + .child(seg), + ); + } + let path = div() + .id(("editor-breadcrumb-path", slot)) + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .cursor_pointer() + .child(path) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(file) = &file { + this.file_tree_reveal_path(file, cx); + } + })); + + let symbols = supported.then(|| { + let mut row = h_flex().min_w_0().overflow_hidden().items_center(); + if chain.is_empty() { + row = row.child(sep()).child(div().text_color(muted).child("…")); + } + for (kind, name) in chain { + row = row.child(sep()).child( + h_flex() + .flex_none() + .gap(px(4.)) + .whitespace_nowrap() + // An impl is named by its header, `impl` and all. + .when(kind != SymbolKind::Impl, |d| { + d.child(div().text_color(muted).child(kind.tag())) + }) + .child(div().text_color(fg).child(name)), + ); + } + div() + .id(("editor-breadcrumb-symbols", slot)) + .flex_shrink(1.) + .min_w_0() + .overflow_hidden() + .rounded(px(4.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child(row) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t(L10nKey::EditorGoToSymbolAction)) + .build(window, cx) + }) + .on_click(cx.listener(move |this, _, window, cx| { + if !focused { + let left = slot == 0; + this.editor_focus_group(left, window, cx); + } + this.editor_go_to_symbol(window, cx) + })) + }); + + Some( + h_flex() + .id(("editor-breadcrumbs", slot)) + .flex_none() + .w_full() + .h(px(24.)) + .items_center() + .overflow_hidden() + .px(px(crate::ui::app::CONTENT_INSET)) + .border_b(crate::ui::theme::hairline(window)) + .border_color(cx.theme().sidebar_border) + .text_size(gpui::rems(crate::ui::right_panel::META)) + .child(path) + .children(symbols) + .into_any_element(), + ) + } +} + +/// A path as the breadcrumbs spell it: from inside the project root it is in, +/// with the root's own name first, or whole when it is in none. +pub(crate) fn path_segments(path: &Path, roots: &[PathBuf]) -> Vec { + let (lead, rel) = match roots + .iter() + .find_map(|r| Some((r, path.strip_prefix(r).ok()?))) + { + Some((root, rel)) => ( + root.file_name().map(|n| n.to_string_lossy().to_string()), + rel.to_path_buf(), + ), + None => (None, path.to_path_buf()), + }; + lead.into_iter() + .chain(rel.components().filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()), + std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + _ => None, + })) + .collect() +} + +fn symbol_command(symbol: &outline::Symbol) -> CommandKind { + CommandKind::GoToSymbol { + line: symbol.selection.line, + column: symbol.selection.character, + } +} + +/// The picker's rows, in document order, each with how deep it sits. +pub(crate) fn symbol_rows(outline: &Outline) -> Vec<(usize, Item)> { + outline + .symbols + .iter() + .enumerate() + .map(|(ix, s)| { + let mut item = Item::new(s.name.clone(), symbol_command(s)).with_note(s.kind.tag()); + let path = outline.container_path(ix); + if !path.is_empty() { + item = item.with_subtitle(path.join(" › ")); + } + (s.depth, item) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(path: &str, line: u32) -> NavLocation { + NavLocation { + host: HostId::LOCAL, + path: PathBuf::from(path), + buffer: None, + pos: Position::new(line, 0), + } + } + + fn sample(path: &str, line: u32, len: usize) -> Option { + Some(Sample { + at: at(path, line), + text_len: len, + }) + } + + #[test] + fn back_and_forward_walk_the_places_jumped_from() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("b.rs", 40)); + // Now at c.rs:5. + assert_eq!(h.go_back(Some(at("c.rs", 5))), Some(at("b.rs", 40))); + assert_eq!(h.go_back(Some(at("b.rs", 40))), Some(at("a.rs", 10))); + assert_eq!(h.go_back(Some(at("a.rs", 10))), None); + assert_eq!(h.go_forward(Some(at("a.rs", 10))), Some(at("b.rs", 40))); + assert_eq!(h.go_forward(Some(at("b.rs", 40))), Some(at("c.rs", 5))); + assert_eq!(h.go_forward(Some(at("c.rs", 5))), None); + } + + #[test] + fn a_new_jump_forgets_the_way_forward() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("b.rs", 40)); + h.go_back(Some(at("c.rs", 5))); + assert_eq!(h.lens(), (1, 1)); + h.push(at("b.rs", 40)); + assert_eq!(h.lens(), (2, 0)); + assert_eq!(h.go_forward(Some(at("d.rs", 0))), None); + } + + #[test] + fn places_near_each_other_are_one_entry() { + let mut h = NavHistory::default(); + h.push(at("a.rs", 10)); + h.push(at("a.rs", 14)); + assert_eq!(h.lens(), (1, 0)); + h.push(at("a.rs", 30)); + h.push(at("b.rs", 14)); + assert_eq!(h.lens(), (3, 0)); + // Back from right beside the last entry passes over it. + assert_eq!(h.go_back(Some(at("b.rs", 16))), Some(at("a.rs", 30))); + } + + #[test] + fn the_back_stack_is_capped() { + let mut h = NavHistory::default(); + for i in 0..(MAX_HISTORY as u32 + 5) { + h.push(at("a.rs", i * JUMP_LINES)); + } + assert_eq!(h.lens().0, MAX_HISTORY); + let mut oldest = None; + let mut cur = at("z.rs", 0); + while let Some(l) = h.go_back(Some(cur.clone())) { + cur = l.clone(); + oldest = Some(l); + } + assert_eq!(oldest, Some(at("a.rs", 5 * JUMP_LINES))); + } + + #[test] + fn closed_untitled_buffers_can_be_dropped() { + let mut h = NavHistory::default(); + let mut untitled = at("", 3); + untitled.buffer = Some(gpui::EntityId::from(7u64)); + h.push(untitled); + h.push(at("a.rs", 0)); + h.retain(|l| l.buffer.is_none()); + assert_eq!(h.lens(), (1, 0)); + } + + #[test] + fn the_tracker_records_jumps_but_not_walks_or_edits() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 100), t0, false); + // Arrowing down a line at a time is not a jump. + for line in 1..=30 { + nav.observe(sample("a.rs", line, 100), t0, false); + } + assert_eq!(nav.history.lens(), (0, 0)); + // Pasting forty lines moves the caret forty lines, and is an edit. + nav.observe(sample("a.rs", 70, 900), t0, false); + assert_eq!(nav.history.lens(), (0, 0)); + // A click far away is. + nav.observe(sample("a.rs", 300, 900), t0, false); + assert_eq!(nav.history.back, [at("a.rs", 70)]); + // So is another file coming forward. + nav.observe(sample("b.rs", 0, 10), t0, false); + assert_eq!(nav.history.back, [at("a.rs", 70), at("a.rs", 300)]); + // No buffer in front keeps the last place for the next one. + nav.observe(None, t0, false); + nav.observe(sample("c.rs", 0, 10), t0, false); + assert_eq!(nav.history.back.last(), Some(&at("b.rs", 0))); + } + + #[test] + fn a_pending_back_is_not_mistaken_for_a_jump() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 10), t0, false); + nav.observe(sample("b.rs", 50, 10), t0, false); + let target = nav.history.go_back(Some(at("b.rs", 50))).unwrap(); + nav.expect(target, t0); + // The file is still opening: b.rs is on screen, and that is fine. + nav.observe(sample("b.rs", 50, 10), t0, false); + nav.observe(sample("a.rs", 0, 10), t0, false); + assert_eq!(nav.history.lens(), (0, 1)); + // A target that never arrives stops being waited on. + nav.expect(at("gone.rs", 0), t0); + nav.observe( + sample("a.rs", 0, 10), + t0 + PENDING_TIMEOUT + Duration::from_secs(1), + false, + ); + nav.observe( + sample("b.rs", 0, 10), + t0 + PENDING_TIMEOUT + Duration::from_secs(1), + false, + ); + assert_eq!(nav.history.back, [at("a.rs", 0)]); + } + + #[test] + fn paging_pasting_and_undoing_are_the_quiet_moves() { + use gpui_component::input::{MoveDown, MovePageDown, MovePageUp, Paste, Redo, Undo}; + assert!(is_quiet_move(&MovePageDown)); + assert!(is_quiet_move(&MovePageUp)); + assert!(is_quiet_move(&Paste)); + assert!(is_quiet_move(&Undo)); + assert!(is_quiet_move(&Redo)); + assert!(!is_quiet_move(&MoveDown)); + } + + #[test] + fn a_page_down_or_a_paste_is_not_a_jump() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 100), t0, false); + nav.observe(sample("a.rs", 60, 100), t0, true); + nav.observe(sample("a.rs", 120, 100), t0, true); + assert_eq!(nav.history.lens(), (0, 0)); + // Only the move the key made: the next one is judged on its own. + nav.observe(sample("a.rs", 400, 100), t0, false); + assert_eq!(nav.history.back, [at("a.rs", 120)]); + } + + #[test] + fn places_move_with_lines_inserted_or_deleted_above_them() { + let mut nav = TabNav::default(); + let t0 = Instant::now(); + nav.observe(sample("a.rs", 40, 10), t0, false); + nav.observe(sample("b.rs", 0, 10), t0, false); + nav.history.push(at("a.rs", 90)); + // Three lines pasted at line 10 of a.rs, then lines 20..=29 deleted. + let edits = [ + LineEdit { + start_line: 10, + end_line: 10, + new_lines: 3, + at_line_start: false, + }, + LineEdit { + start_line: 20, + end_line: 30, + new_lines: 0, + at_line_start: false, + }, + ]; + nav.shift(&at("a.rs", 0), &edits); + // b.rs is untouched; 40 → 43 → 33, 90 → 93 → 83. + assert_eq!(nav.history.back, [at("a.rs", 33), at("a.rs", 83)]); + nav.shift(&at("b.rs", 0), &edits[..1]); + assert_eq!(nav.last.as_ref().unwrap().at, at("b.rs", 0)); + } + + #[test] + fn breadcrumb_paths_start_at_their_project() { + let roots = [PathBuf::from("/work/tty7")]; + assert_eq!( + path_segments(Path::new("/work/tty7/src/ui/code_editor.rs"), &roots), + ["tty7", "src", "ui", "code_editor.rs"] + ); + assert_eq!( + path_segments(Path::new("/etc/hosts"), &roots), + ["etc", "hosts"] + ); + } + + #[test] + fn symbol_rows_carry_their_containers_and_the_breadcrumb_chain_names_them() { + let src = "impl Tty7App {\n fn open_file(&self) {\n let x = 1;\n }\n}\n"; + let outline = outline::outline_of("rust", src); + let rows = symbol_rows(&outline); + let shown: Vec<_> = rows + .iter() + .map(|(d, i)| { + ( + *d, + i.title.as_str(), + i.subtitle.as_deref(), + i.note.as_deref(), + ) + }) + .collect(); + assert_eq!( + shown, + [ + (0, "impl Tty7App", None, Some("impl")), + (1, "open_file", Some("impl Tty7App"), Some("method")), + ] + ); + assert_eq!( + rows[1].1.kind, + CommandKind::GoToSymbol { line: 1, column: 7 } + ); + let chain: Vec<_> = outline + .chain_at(Position::new(2, 8)) + .into_iter() + .map(|i| { + format!( + "{} {}", + outline.symbols[i].kind.tag(), + outline.symbols[i].name + ) + }) + .collect(); + assert_eq!(chain, ["impl impl Tty7App", "method open_file"]); + } +} + +#[cfg(test)] +pub(super) mod gpui_tests { + use super::*; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::editor_text::{EditorConfig, TextFormat}; + use gpui::{Entity, TestAppContext, VisualTestContext}; + + /// `alpha` on line 0, `beta` on 30, `gamma` on 50. + pub(crate) fn source() -> String { + let mut lines = vec!["fn alpha() {".to_string()]; + lines.extend((1..=20).map(|_| " let x = 1;".to_string())); + lines.push("}".into()); + lines.extend((22..30).map(|_| String::new())); + lines.push("fn beta() {".into()); + lines.push("}".into()); + lines.extend((32..50).map(|_| String::new())); + lines.push("fn gamma() {".into()); + lines.push("}".into()); + lines.join("\n") + "\n" + } + + pub(crate) fn open_rs( + app: &Entity, + vcx: &mut VisualTestContext, + path: &str, + ) -> BufferId { + let path = PathBuf::from(path); + app.update_in(vcx, |app, window, cx| { + let host = app.active_host(cx).expect("a host"); + let tab = app.nav_tab().expect("a tab"); + let indent = crate::ui::editor_text::detect_indent("", "rust"); + let id = app.editor_new_buffer( + host, + path, + None, + source(), + TextFormat::default(), + EditorConfig::default(), + indent, + None, + window, + cx, + ); + app.editor_show_in_tab(tab, id, true, window, cx); + id + }) + } + + pub(crate) fn caret(app: &Entity, vcx: &mut VisualTestContext) -> Position { + app.read_with(vcx, |app, cx| { + app.active_buffer() + .unwrap() + .input + .read(cx) + .cursor_position() + }) + } + + /// What drawing the editor does: sample the caret. + pub(crate) fn draw(app: &Entity, vcx: &mut VisualTestContext) { + app.update(vcx, |app, cx| app.editor_nav_tick(cx)); + } + + pub(crate) fn put_caret(app: &Entity, vcx: &mut VisualTestContext, line: u32) { + app.update_in(vcx, |app, window, cx| { + let input = app.active_buffer().unwrap().input.clone(); + input.update(cx, |s, cx| { + s.set_cursor_position(Position::new(line, 4), window, cx) + }); + }); + draw(app, vcx); + } + + #[gpui::test] + fn go_to_symbol_previews_and_escape_puts_the_caret_back(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + let symbols = app.read_with(&vcx, |app, cx| { + let view = app.search.clone().expect("the search is open"); + assert_eq!(view.read(cx).tab(), SearchTab::Symbols); + view.read(cx).symbol_count() + }); + assert_eq!(symbols, 3); + // Opening on the symbol around the caret does not move it. + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4)); + + vcx.simulate_keystrokes("down"); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(30, 3), + "beta is previewed" + ); + + vcx.simulate_keystrokes("escape"); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_none())); + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4), "and put back"); + draw(&app, &mut vcx); + app.read_with(&vcx, |app, _| { + let tab = app.nav_tab().unwrap(); + assert!( + app.editor.nav.tabs[&tab].history.back.is_empty(), + "looking is not jumping" + ); + }); + } + + #[gpui::test] + fn a_symbol_jump_can_be_walked_back_and_forward(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + vcx.simulate_keystrokes("g a m m a"); + vcx.run_until_parked(); + vcx.simulate_keystrokes("enter"); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_none())); + assert_eq!(caret(&app, &mut vcx), Position::new(50, 3)); + draw(&app, &mut vcx); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(5, 4), + "back to where it was" + ); + draw(&app, &mut vcx); + + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(true, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx), + Position::new(50, 3), + "and forward again" + ); + draw(&app, &mut vcx); + + // Another file coming forward is a jump too, and Back returns to the + // first one. + open_rs(&app, &mut vcx, "/nav-test/b.rs"); + draw(&app, &mut vcx); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + app.read_with(&vcx, |app, _| { + assert_eq!( + app.active_buffer().unwrap().path, + PathBuf::from("/nav-test/a.rs") + ); + }); + assert_eq!(caret(&app, &mut vcx), Position::new(50, 3)); + } + + #[gpui::test] + fn history_follows_edits_to_the_file(cx: &mut TestAppContext) { + use gpui::EntityInputHandler as _; + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + put_caret(&app, &mut vcx, 50); + // Three lines typed at the top of the file. + app.update_in(&mut vcx, |app, window, cx| { + let input = app.active_buffer().unwrap().input.clone(); + input.update(cx, |s, cx| { + s.replace_text_in_range(Some(0..0), "//\n//\n//\n", window, cx); + // Typing took the caret to the top; back to where it was + // reading, which has moved down with its line. + s.set_cursor_position(Position::new(53, 4), window, cx); + }); + }); + vcx.run_until_parked(); + draw(&app, &mut vcx); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx).line, + 8, + "the place moved down with its code" + ); + } + + #[gpui::test] + fn the_symbol_chord_puts_go_to_symbol_away(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_some())); + vcx.simulate_keystrokes("secondary-shift-o"); + vcx.run_until_parked(); + assert!( + app.read_with(&vcx, |app, _| app.search.is_none()), + "the chord that opened it closes it" + ); + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4)); + } + + /// Go to File and Go to Symbol share the editor's row: Tab walks from one + /// to the other, each arriving set up the way its chord sets it up. + #[gpui::test] + fn tab_walks_the_editors_row(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + // No file in front: Files is all the row would hold. + assert_eq!( + app.read_with(&vcx, |app, _| app.editor_search_tabs()), + vec![SearchTab::Files] + ); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + assert_eq!( + app.read_with(&vcx, |app, _| app.editor_search_tabs()), + vec![SearchTab::Files, SearchTab::Symbols] + ); + app.update_in(&mut vcx, |app, window, cx| app.quick_open_file(window, cx)); + vcx.run_until_parked(); + let tab = |app: &Entity, vcx: &mut gpui::VisualTestContext| { + app.read_with(vcx, |app, cx| app.search.as_ref().map(|v| v.read(cx).tab())) + }; + assert_eq!(tab(&app, &mut vcx), Some(SearchTab::Files)); + vcx.simulate_keystrokes("tab"); + vcx.run_until_parked(); + assert_eq!(tab(&app, &mut vcx), Some(SearchTab::Symbols)); + vcx.simulate_keystrokes("tab"); + vcx.run_until_parked(); + assert_eq!(tab(&app, &mut vcx), Some(SearchTab::Files)); + } + + #[gpui::test] + fn the_palette_offers_the_editor_commands_only_with_a_file_open(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + assert!(app.read_with(&vcx, |app, _| app.editor_palette_items().is_empty())); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + let kinds: Vec = app.read_with(&vcx, |app, _| { + app.editor_palette_items() + .into_iter() + .map(|i| i.kind) + .collect() + }); + assert_eq!( + kinds, + [ + CommandKind::EditorGoToSymbol, + CommandKind::EditorNavigateBack, + CommandKind::EditorNavigateForward, + CommandKind::EditorSplitRight, + ] + ); + } +} diff --git a/src/ui/code_editor/outline.rs b/src/ui/code_editor/outline.rs new file mode 100644 index 00000000..d3d19401 --- /dev/null +++ b/src/ui/code_editor/outline.rs @@ -0,0 +1,778 @@ +//! A file's symbols — what Go to Symbol lists and the breadcrumbs walk. +//! +//! Built from the tree the editor already parsed for highlighting +//! ([`gpui_component::input::InputState::syntax_tree`]), with one small query +//! per language saying which nodes are symbols and where their names are. No +//! second parse, and nothing to install. +//! +//! An [`Outline`] is plain data, so it need not come from tree-sitter at all: +//! [`Outline::from_nodes`] takes the same nested shape a language server's +//! `textDocument/documentSymbol` answers with, and a buffer given one through +//! `Tty7App::editor_set_document_symbols` shows that instead of the tree's. + +use std::collections::HashMap; +use std::ops::Range; +use std::sync::{Arc, LazyLock, Mutex}; + +use gpui_component::Rope; +use gpui_component::highlighter::LanguageRegistry; +use gpui_component::highlighter::tree_sitter::{self, Query, QueryCursor, StreamingIterator as _}; +use gpui_component::input::{Position, RopeExt as _}; + +/// What a symbol is — the shapes the picker and the breadcrumbs tell apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymbolKind { + Module, + Namespace, + Class, + Struct, + Enum, + Interface, + Trait, + Impl, + Function, + Method, + Constructor, + Constant, + Variable, + Field, + Type, + Macro, + Heading, +} + +impl SymbolKind { + /// The capture suffix a query names this kind by (`@item.function`). + fn from_capture(suffix: &str) -> Option { + Some(match suffix { + "module" => Self::Module, + "namespace" => Self::Namespace, + "class" => Self::Class, + "struct" => Self::Struct, + "enum" => Self::Enum, + "interface" => Self::Interface, + "trait" => Self::Trait, + "impl" => Self::Impl, + "function" => Self::Function, + "method" => Self::Method, + "constructor" => Self::Constructor, + "constant" => Self::Constant, + "variable" => Self::Variable, + "field" => Self::Field, + "type" => Self::Type, + "macro" => Self::Macro, + "heading" => Self::Heading, + _ => return None, + }) + } + + /// LSP's `SymbolKind` number, for a language server's answer. Kinds with + /// no counterpart here (a file, a package, an event…) fold into the + /// nearest one that draws the same. + pub(crate) fn from_lsp(kind: u32) -> Self { + match kind { + 1 | 2 | 4 => Self::Module, // File, Module, Package + 3 => Self::Namespace, + 5 => Self::Class, + 6 => Self::Method, + 7 | 8 => Self::Field, // Property, Field + 9 => Self::Constructor, + 10 => Self::Enum, + 11 => Self::Interface, + 12 => Self::Function, + 13 => Self::Variable, + 14 | 22 => Self::Constant, // Constant, EnumMember + 23 => Self::Struct, + 26 => Self::Type, // TypeParameter + _ => Self::Variable, + } + } + + /// Whether a function directly inside this is a method of it. + fn holds_methods(self) -> bool { + matches!( + self, + Self::Class | Self::Struct | Self::Enum | Self::Interface | Self::Trait | Self::Impl + ) + } + + /// The short tag the picker shows beside a name. Code's own words, the + /// same in every locale, like the keywords they stand for. + pub(crate) fn tag(self) -> &'static str { + match self { + Self::Module => "mod", + Self::Namespace => "namespace", + Self::Class => "class", + Self::Struct => "struct", + Self::Enum => "enum", + Self::Interface => "interface", + Self::Trait => "trait", + Self::Impl => "impl", + Self::Function => "fn", + Self::Method => "method", + Self::Constructor => "ctor", + Self::Constant => "const", + Self::Variable => "var", + Self::Field => "field", + Self::Type => "type", + Self::Macro => "macro", + Self::Heading => "#", + } + } +} + +/// One symbol, flattened into its outline. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Symbol { + pub name: String, + pub kind: SymbolKind, + /// The whole item, 0-based, in the editor's own [`Position`]s. + pub range: Range, + /// Where a jump to it lands: its name. + pub selection: Position, + /// How many symbols it sits inside. + pub depth: usize, + /// The symbol it sits directly inside, as an index into the outline. + pub parent: Option, +} + +/// A nested symbol, the shape an LSP `DocumentSymbol` has — what an outline +/// from anywhere other than the tree is built out of. +#[derive(Clone, Debug)] +pub(crate) struct SymbolNode { + pub name: String, + pub kind: SymbolKind, + pub range: Range, + pub selection: Position, + pub children: Vec, +} + +/// A file's symbols in document order, every container before what it holds. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Outline { + pub symbols: Vec, +} + +fn key(p: Position) -> (u32, u32) { + (p.line, p.character) +} + +impl Outline { + /// Flattens a nested answer (a language server's `documentSymbol`). + pub(crate) fn from_nodes(nodes: Vec) -> Self { + fn walk( + nodes: Vec, + parent: Option, + depth: usize, + out: &mut Vec, + ) { + let mut nodes = nodes; + nodes.sort_by_key(|n| key(n.range.start)); + for node in nodes { + let ix = out.len(); + out.push(Symbol { + name: node.name, + kind: node.kind, + range: node.range, + selection: node.selection, + depth, + parent, + }); + walk(node.children, Some(ix), depth + 1, out); + } + } + let mut symbols = Vec::new(); + walk(nodes, None, 0, &mut symbols); + Self { symbols } + } + + pub(crate) fn is_empty(&self) -> bool { + self.symbols.is_empty() + } + + /// The symbols `pos` is inside, outermost first — the breadcrumbs' trail. + pub(crate) fn chain_at(&self, pos: Position) -> Vec { + let at = key(pos); + // Children follow their parent and siblings never overlap, so the + // last symbol that holds the position is the innermost one. + let Some(innermost) = self + .symbols + .iter() + .rposition(|s| key(s.range.start) <= at && at <= key(s.range.end)) + else { + return Vec::new(); + }; + let mut chain = vec![innermost]; + let mut cur = innermost; + while let Some(parent) = self.symbols[cur].parent { + chain.push(parent); + cur = parent; + } + chain.reverse(); + chain + } + + /// The names of what symbol `ix` sits inside, outermost first. + pub(crate) fn container_path(&self, ix: usize) -> Vec<&str> { + let mut names = Vec::new(); + let mut cur = self.symbols.get(ix).and_then(|s| s.parent); + while let Some(p) = cur { + names.push(self.symbols[p].name.as_str()); + cur = self.symbols[p].parent; + } + names.reverse(); + names + } +} + +// ---- tree-sitter ---- +// +// Captures: `@item.` on the node that is the symbol, `@name` on its name +// (several are joined with a space), `@body` on an impl's block — which names +// the symbol by everything before it, `impl Display for Wrapper` — and +// `@receiver` on a Go method's receiver list, which goes in front of the name +// as `(*Server).Serve`. When two patterns claim one node the earlier wins, so +// a catch-all belongs last. + +const RUST: &str = r#" +(mod_item name: (identifier) @name) @item.module +(struct_item name: (type_identifier) @name) @item.struct +(union_item name: (type_identifier) @name) @item.struct +(enum_item name: (type_identifier) @name) @item.enum +(trait_item name: (type_identifier) @name) @item.trait +(impl_item body: (declaration_list) @body) @item.impl +(function_item name: (identifier) @name) @item.function +(function_signature_item name: (identifier) @name) @item.function +(const_item name: (identifier) @name) @item.constant +(static_item name: (identifier) @name) @item.constant +(type_item name: (type_identifier) @name) @item.type +(macro_definition name: (identifier) @name) @item.macro +"#; + +const GO: &str = r#" +(function_declaration name: (identifier) @name) @item.function +(method_declaration receiver: (parameter_list) @receiver name: (field_identifier) @name) @item.method +(type_spec name: (type_identifier) @name type: (struct_type)) @item.struct +(type_spec name: (type_identifier) @name type: (interface_type)) @item.interface +(type_spec name: (type_identifier) @name) @item.type +(const_spec name: (identifier) @name) @item.constant +"#; + +const PYTHON: &str = r#" +(class_definition name: (identifier) @name) @item.class +(function_definition name: (identifier) @name) @item.function +"#; + +const JAVASCRIPT: &str = r#" +(class_declaration name: (_) @name) @item.class +(function_declaration name: (identifier) @name) @item.function +(generator_function_declaration name: (identifier) @name) @item.function +(method_definition name: (_) @name) @item.method +(variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)]) @item.function +"#; + +const TYPESCRIPT_EXTRA: &str = r#" +(abstract_class_declaration name: (_) @name) @item.class +(interface_declaration name: (_) @name) @item.interface +(type_alias_declaration name: (_) @name) @item.type +(enum_declaration name: (_) @name) @item.enum +(internal_module name: (_) @name) @item.module +(function_signature name: (_) @name) @item.function +(method_signature name: (_) @name) @item.method +(abstract_method_signature name: (_) @name) @item.method +"#; + +const C: &str = r#" +(function_definition declarator: (function_declarator declarator: (_) @name)) @item.function +(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (_) @name))) @item.function +(struct_specifier name: (_) @name body: (_)) @item.struct +(union_specifier name: (_) @name body: (_)) @item.struct +(enum_specifier name: (_) @name body: (_)) @item.enum +(type_definition declarator: (type_identifier) @name) @item.type +"#; + +const CPP_EXTRA: &str = r#" +(namespace_definition name: (_) @name) @item.namespace +(class_specifier name: (_) @name body: (_)) @item.class +(function_definition declarator: (reference_declarator (function_declarator declarator: (_) @name))) @item.function +(field_declaration declarator: (function_declarator declarator: (_) @name)) @item.method +(alias_declaration name: (type_identifier) @name) @item.type +"#; + +const JAVA: &str = r#" +(class_declaration name: (identifier) @name) @item.class +(interface_declaration name: (identifier) @name) @item.interface +(enum_declaration name: (identifier) @name) @item.enum +(record_declaration name: (identifier) @name) @item.class +(annotation_type_declaration name: (identifier) @name) @item.interface +(method_declaration name: (identifier) @name) @item.method +(constructor_declaration name: (identifier) @name) @item.constructor +"#; + +const MARKDOWN: &str = r#" +(section (atx_heading (inline) @name)) @item.heading +(setext_heading (paragraph (inline) @name)) @item.heading +"#; + +const RUBY: &str = r#" +(class name: (_) @name) @item.class +(module name: (_) @name) @item.module +(method name: (_) @name) @item.method +(singleton_method name: (_) @name) @item.method +"#; + +const BASH: &str = r#" +(function_definition name: (word) @name) @item.function +"#; + +/// The query for a language the editor highlights, or `None` for one it has +/// no outline for. +fn query_source(language: &str) -> Option { + Some(match language { + "rust" => RUST.into(), + "go" => GO.into(), + "python" => PYTHON.into(), + "javascript" => JAVASCRIPT.into(), + "typescript" | "tsx" => format!("{JAVASCRIPT}{TYPESCRIPT_EXTRA}"), + "c" => C.into(), + "cpp" => format!("{C}{CPP_EXTRA}"), + "java" => JAVA.into(), + "markdown" => MARKDOWN.into(), + "ruby" => RUBY.into(), + "bash" => BASH.into(), + _ => return None, + }) +} + +/// Compiled once per language per process. A query that does not compile +/// against its grammar is a bug here, not in the file: it is logged once and +/// the language goes without an outline. +fn query_for(language: &str) -> Option> { + static QUERIES: LazyLock>>>> = + LazyLock::new(Default::default); + let mut queries = QUERIES.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(q) = queries.get(language) { + return q.clone(); + } + let compiled = query_source(language).and_then(|source| { + let config = LanguageRegistry::singleton().language(language)?; + Query::new(&config.language, &source) + .map_err(|e| log::warn!("outline: the {language} query does not compile: {e}")) + .ok() + .map(Arc::new) + }); + queries.insert(language.to_string(), compiled.clone()); + compiled +} + +/// Whether `language` has an outline at all. +pub(crate) fn supports(language: &str) -> bool { + query_source(language).is_some() +} + +/// The longest name a symbol shows; an impl header with a where clause can +/// otherwise run the width of the screen. +const NAME_MAX_CHARS: usize = 80; + +fn collapse(text: &str) -> String { + let mut out = text.split_whitespace().collect::>().join(" "); + if out.chars().count() > NAME_MAX_CHARS { + out = out.chars().take(NAME_MAX_CHARS - 1).collect::() + "…"; + } + out +} + +fn text_of(text: &Rope, range: Range) -> String { + let end = range.end.min(text.len()); + let start = range.start.min(end); + text.slice(start..end).to_string() +} + +/// The outline of `text`, as parsed into `tree`. +pub(crate) fn outline_from_tree(language: &str, tree: &tree_sitter::Tree, text: &Rope) -> Outline { + let Some(query) = query_for(language) else { + return Outline::default(); + }; + let names = query.capture_names(); + struct Found { + pattern: usize, + kind: SymbolKind, + range: Range, + name: String, + selection: usize, + } + let mut found: HashMap = HashMap::new(); + let mut cursor = QueryCursor::new(); + // No pattern has a predicate, so the text is never consulted. + let mut matches = cursor.matches(&query, tree.root_node(), &[] as &[u8]); + while let Some(m) = matches.next() { + let mut item = None; + let mut name_parts: Vec<(usize, String)> = Vec::new(); + let mut body = None; + let mut receiver = None; + for cap in m.captures { + let capture = names[cap.index as usize]; + let node = cap.node; + if let Some(kind) = capture.strip_prefix("item.") { + item = SymbolKind::from_capture(kind).map(|k| (k, node)); + } else if capture == "name" { + name_parts.push((node.start_byte(), text_of(text, node.byte_range()))); + } else if capture == "body" { + body = Some(node.start_byte()); + } else if capture == "receiver" { + receiver = Some(text_of(text, node.byte_range())); + } + } + let Some((kind, node)) = item else { continue }; + if found + .get(&node.id()) + .is_some_and(|f| f.pattern <= m.pattern_index) + { + continue; + } + name_parts.sort_by_key(|(at, _)| *at); + let (name, selection) = match (name_parts.first(), body) { + (Some((at, _)), _) => ( + name_parts + .iter() + .map(|(_, n)| n.as_str()) + .collect::>() + .join(" "), + *at, + ), + (None, Some(body)) => (text_of(text, node.start_byte()..body), node.start_byte()), + (None, None) => continue, + }; + let mut name = collapse(&name); + if let Some(receiver) = receiver { + let ty = receiver + .trim_matches(|c| c == '(' || c == ')') + .split_whitespace() + .last() + .unwrap_or_default() + .to_string(); + if !ty.is_empty() { + name = format!("({ty}).{name}"); + } + } + if name.is_empty() { + continue; + } + found.insert( + node.id(), + Found { + pattern: m.pattern_index, + kind, + range: node.byte_range(), + name, + selection, + }, + ); + } + + // Document order, a container before what it holds: by start, and the + // longer of two that start together first. + let mut items: Vec = found.into_values().collect(); + items.sort_by(|a, b| { + a.range + .start + .cmp(&b.range.start) + .then(b.range.end.cmp(&a.range.end)) + }); + let mut symbols: Vec = Vec::with_capacity(items.len()); + let mut open: Vec<(usize, usize)> = Vec::new(); // (index, end byte) + for f in items { + while open.last().is_some_and(|(_, end)| *end <= f.range.start) { + open.pop(); + } + let parent = open.last().map(|(ix, _)| *ix); + let kind = match (f.kind, parent) { + (SymbolKind::Function, Some(p)) if symbols[p].kind.holds_methods() => { + SymbolKind::Method + } + (kind, _) => kind, + }; + let ix = symbols.len(); + symbols.push(Symbol { + name: f.name, + kind, + range: text.offset_to_position(f.range.start)..text.offset_to_position(f.range.end), + selection: text.offset_to_position(f.selection), + depth: open.len(), + parent, + }); + open.push((ix, f.range.end)); + } + Outline { symbols } +} + +/// The outline of a text nobody has parsed yet — a buffer that has not been +/// drawn. Parses it once, here. +pub(crate) fn outline_of_text(language: &str, text: &Rope) -> Outline { + let Some(config) = LanguageRegistry::singleton().language(language) else { + return Outline::default(); + }; + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&config.language).is_err() { + return Outline::default(); + } + let source = text.to_string(); + match parser.parse(&source, None) { + Some(tree) => outline_from_tree(language, &tree, text), + None => Outline::default(), + } +} + +#[cfg(test)] +pub(crate) fn outline_of(language: &str, source: &str) -> Outline { + outline_of_text(language, &Rope::from(source)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `name@depth:kind` for every symbol, in order. + fn shape(language: &str, source: &str) -> Vec { + outline_of(language, source) + .symbols + .iter() + .map(|s| format!("{}@{}:{}", s.name, s.depth, s.kind.tag())) + .collect() + } + + #[test] + fn every_outline_query_compiles_against_its_grammar() { + for language in [ + "rust", + "go", + "python", + "javascript", + "typescript", + "tsx", + "c", + "cpp", + "java", + "markdown", + "ruby", + "bash", + ] { + assert!(query_for(language).is_some(), "{language}"); + } + assert!(query_for("json").is_none()); + } + + #[test] + fn rust_nests_methods_under_their_impl() { + let src = "mod net {\n pub struct Server { port: u16 }\n impl Display for Wrapper where T: Debug {\n fn fmt(&self) {}\n }\n impl Server {\n pub fn serve(&self) {}\n const MAX: usize = 3;\n }\n}\ntrait Run { fn run(&self); }\nenum Mode { A, B }\nfn main() {}\n"; + assert_eq!( + shape("rust", src), + [ + "net@0:mod", + "Server@1:struct", + "impl Display for Wrapper where T: Debug@1:impl", + "fmt@2:method", + "impl Server@1:impl", + "serve@2:method", + "MAX@2:const", + "Run@0:trait", + "run@1:method", + "Mode@0:enum", + "main@0:fn", + ] + ); + } + + #[test] + fn go_methods_carry_their_receiver() { + let src = "package main\n\ntype Server struct{}\ntype Handler interface{ Serve() }\ntype ID int\nconst Max = 3\nfunc (s *Server) Serve() {}\nfunc main() {}\n"; + assert_eq!( + shape("go", src), + [ + "Server@0:struct", + "Handler@0:interface", + "ID@0:type", + "Max@0:const", + "(*Server).Serve@0:method", + "main@0:fn", + ] + ); + } + + #[test] + fn python_classes_hold_their_methods() { + let src = "class A:\n def f(self):\n def inner():\n pass\n\n@decorator\ndef g():\n pass\n"; + assert_eq!( + shape("python", src), + ["A@0:class", "f@1:method", "inner@2:fn", "g@0:fn"] + ); + } + + #[test] + fn javascript_finds_classes_methods_and_arrow_functions() { + let src = "class Widget {\n render() {}\n}\nfunction boot() {}\nconst handler = () => {};\nconst n = 3;\n"; + assert_eq!( + shape("javascript", src), + [ + "Widget@0:class", + "render@1:method", + "boot@0:fn", + "handler@0:fn" + ] + ); + } + + #[test] + fn typescript_adds_its_type_declarations() { + let src = "interface Props { name: string }\ntype Id = number;\nenum Color { Red }\nexport class View {\n show(): void {}\n}\nnamespace Util { export function f() {} }\n"; + let got = shape("typescript", src); + assert_eq!( + got, + [ + "Props@0:interface", + "Id@0:type", + "Color@0:enum", + "View@0:class", + "show@1:method", + "Util@0:mod", + "f@1:fn", + ] + ); + let tsx = shape("tsx", "function App() { return
; }\n"); + assert_eq!(tsx, ["App@0:fn"]); + } + + #[test] + fn c_and_cpp_find_functions_through_their_declarators() { + let c = "struct point { int x; };\ntypedef int id_t;\nstatic char *name(void) { return 0; }\nint main(void) { return 0; }\n"; + assert_eq!( + shape("c", c), + ["point@0:struct", "id_t@0:type", "name@0:fn", "main@0:fn"] + ); + let cpp = "namespace app {\nclass Server {\n public:\n void serve();\n};\nvoid Server::serve() {}\n}\n"; + assert_eq!( + shape("cpp", cpp), + [ + "app@0:namespace", + "Server@1:class", + "serve@2:method", + "Server::serve@1:fn", + ] + ); + } + + #[test] + fn java_nests_members_in_their_class() { + let src = "class App {\n App() {}\n void run() {}\n interface Listener { void on(); }\n}\nenum Kind { A }\n"; + assert_eq!( + shape("java", src), + [ + "App@0:class", + "App@1:ctor", + "run@1:method", + "Listener@1:interface", + "on@2:method", + "Kind@0:enum", + ] + ); + } + + #[test] + fn markdown_headings_nest_by_level() { + let src = "# Title\n\nintro\n\n## Install\n\ntext\n\n### From source\n\n## Usage\n"; + assert_eq!( + shape("markdown", src), + ["Title@0:#", "Install@1:#", "From source@2:#", "Usage@1:#",] + ); + // tree-sitter-md opens no section for an underlined heading, so it + // names only its own line. + assert_eq!( + shape("markdown", "Setext\n======\n\ntext\n"), + ["Setext@0:#"] + ); + } + + #[test] + fn ruby_and_bash_have_outlines_too() { + assert_eq!( + shape("ruby", "module M\n class C\n def go; end\n end\nend\n"), + ["M@0:mod", "C@1:class", "go@2:method"] + ); + assert_eq!( + shape("bash", "setup() {\n :\n}\nfunction run { :; }\n"), + ["setup@0:fn", "run@0:fn"] + ); + } + + #[test] + fn a_language_without_a_query_has_an_empty_outline() { + assert!(outline_of("json", "{\"a\": 1}").is_empty()); + } + + #[test] + fn the_chain_at_a_position_runs_outermost_to_innermost() { + let src = "mod net {\n impl Server {\n fn serve(&self) {\n let x = 1;\n }\n }\n}\nfn main() {}\n"; + let outline = outline_of("rust", src); + let names = |pos: Position| -> Vec { + outline + .chain_at(pos) + .into_iter() + .map(|i| outline.symbols[i].name.clone()) + .collect() + }; + assert_eq!(names(Position::new(3, 12)), ["net", "impl Server", "serve"]); + assert_eq!(names(Position::new(1, 0)), ["net"]); + assert_eq!(names(Position::new(7, 3)), ["main"]); + assert_eq!(names(Position::new(6, 0)), ["net"]); + assert!(names(Position::new(6, 2)).is_empty()); + let serve = outline + .symbols + .iter() + .position(|s| s.name == "serve") + .unwrap(); + assert_eq!(outline.container_path(serve), ["net", "impl Server"]); + // Lands on the name, not on the start of the item. + assert_eq!(outline.symbols[serve].selection, Position::new(2, 11)); + } + + #[test] + fn a_language_servers_nested_answer_flattens_the_same_way() { + let p = |l, c| Position::new(l, c); + let outline = Outline::from_nodes(vec![ + SymbolNode { + name: "later".into(), + kind: SymbolKind::from_lsp(12), + range: p(10, 0)..p(12, 1), + selection: p(10, 3), + children: vec![], + }, + SymbolNode { + name: "Server".into(), + kind: SymbolKind::from_lsp(5), + range: p(0, 0)..p(8, 1), + selection: p(0, 6), + children: vec![SymbolNode { + name: "serve".into(), + kind: SymbolKind::from_lsp(6), + range: p(2, 2)..p(4, 3), + selection: p(2, 6), + children: vec![], + }], + }, + ]); + let got: Vec<_> = outline + .symbols + .iter() + .map(|s| (s.name.as_str(), s.depth, s.parent)) + .collect(); + assert_eq!( + got, + [ + ("Server", 0, None), + ("serve", 1, Some(0)), + ("later", 0, None) + ] + ); + assert_eq!(outline.chain_at(p(3, 0)), [0, 1]); + } +} diff --git a/src/ui/code_editor/problems.rs b/src/ui/code_editor/problems.rs new file mode 100644 index 00000000..31c1b21b --- /dev/null +++ b/src/ui/code_editor/problems.rs @@ -0,0 +1,415 @@ +//! The editor's Problems list: every error, warning and note the language +//! servers have published for the open files, grouped by file. +//! +//! It lives at the foot of the code panel rather than as a right-panel tab: +//! problems belong to the files open in the editor, the status bar's error and +//! warning counts are where people look for them, and a click lands in the +//! editor right above it. The status bar counts toggle it, as does the +//! `ToggleEditorProblems` command. +//! +//! Hints are left out, as VS Code leaves them out: servers use them for +//! inlay-ish suggestions that would bury the real problems. +use std::path::{Path, PathBuf}; + +use gpui::prelude::*; +use gpui::{AnyElement, Context, ScrollHandle, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; +use lsp_types::DiagnosticSeverity; + +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::lsp::LspStore; + +/// The pane's height: a handful of rows, leaving the editor the rest. +const PANE_HEIGHT: f32 = 180.; + +/// Past this many rows the list stops drawing and says how many are left. +const MAX_ROWS: usize = 1000; + +#[derive(Default)] +pub(crate) struct ProblemsPane { + pub(crate) open: bool, + scroll: ScrollHandle, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Severity { + Error, + Warning, + Info, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Problem { + /// Zero-based, in editor columns. + pub(crate) line: u32, + pub(crate) column: u32, + pub(crate) severity: Severity, + pub(crate) message: String, + pub(crate) source: Option, +} + +/// One file's problems, in the order they appear in it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FileProblems { + pub(crate) path: PathBuf, + pub(crate) problems: Vec, +} + +/// Sort the store's snapshot into what the list draws: files by path, each +/// file's problems by position, hints dropped, files left with nothing gone. +pub(crate) fn collect(snapshot: Vec<(PathBuf, Vec)>) -> Vec { + let mut files: Vec = snapshot + .into_iter() + .map(|(path, diagnostics)| { + let mut problems: Vec = diagnostics + .into_iter() + .filter_map(|d| { + let severity = match d.severity { + // A server that says nothing means an error (LSP leaves + // it to the client; every editor reads it that way). + None | Some(DiagnosticSeverity::ERROR) => Severity::Error, + Some(DiagnosticSeverity::WARNING) => Severity::Warning, + Some(DiagnosticSeverity::INFORMATION) => Severity::Info, + _ => return None, + }; + Some(Problem { + line: d.range.start.line, + column: d.range.start.character, + severity, + message: d.message, + source: d.source, + }) + }) + .collect(); + problems.sort_by(|a, b| { + (a.line, a.column, a.severity).cmp(&(b.line, b.column, b.severity)) + }); + FileProblems { path, problems } + }) + .filter(|f| !f.problems.is_empty()) + .collect(); + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +/// The first line of a message: the list is one row per problem, and the +/// rest is a hover away in the editor. +fn headline(message: &str) -> &str { + message.lines().next().unwrap_or("").trim_end() +} + +impl Tty7App { + pub(crate) fn toggle_editor_problems(&mut self, cx: &mut Context) { + self.editor.problems.open = !self.editor.problems.open; + cx.notify(); + } + + /// Where a file's name sits in the list: relative to the project the + /// tab shows, when it is inside it. + fn problems_label(&self, path: &Path) -> (String, String) { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + let dir = path.parent().map(|parent| { + self.tab_code() + .and_then(|c| { + c.roots + .iter() + .find_map(|r| parent.strip_prefix(r).ok().map(|p| (r, p))) + }) + .map(|(root, rel)| { + let repo = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + match rel.as_os_str().is_empty() { + true => repo, + false => format!("{repo}/{}", rel.display()), + } + }) + .unwrap_or_else(|| parent.display().to_string()) + }); + (name, dir.unwrap_or_default()) + } + + pub(crate) fn render_editor_problems( + &self, + window: &Window, + cx: &mut Context, + ) -> Option { + if !self.editor.problems.open { + return None; + } + let files = collect(LspStore::diagnostics_snapshot(cx)); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let (errors, warnings) = + files + .iter() + .flat_map(|f| &f.problems) + .fold((0, 0), |(e, w), p| match p.severity { + Severity::Error => (e + 1, w), + Severity::Warning => (e, w + 1), + Severity::Info => (e, w), + }); + let header = h_flex() + .flex_none() + .h(px(28.)) + .px(px(crate::ui::app::CONTENT_INSET)) + .gap_2() + .items_center() + .border_t(crate::ui::theme::hairline(window)) + .border_color(theme.sidebar_border) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .child(t(L10nKey::EditorProblemsTitle)), + ) + .child(div().text_xs().text_color(muted).child(t_fmt( + L10nKey::LspProblemsTooltip, + &[ + ("errors", &errors.to_string()), + ("warnings", &warnings.to_string()), + ], + ))) + .child(div().flex_1()) + .child( + Button::new("editor-problems-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click(cx.listener(|this, _, _, cx| this.toggle_editor_problems(cx))), + ); + + let mut rows: Vec = Vec::new(); + let mut left = 0usize; + for (file_ix, file) in files.iter().enumerate() { + if rows.len() >= MAX_ROWS { + left += file.problems.len(); + continue; + } + let (name, dir) = self.problems_label(&file.path); + rows.push( + h_flex() + .id(("editor-problems-file", file_ix)) + .h(px(24.)) + .px(px(crate::ui::app::CONTENT_INSET)) + .gap_2() + .items_center() + .text_sm() + .child( + div() + .flex_none() + .font_weight(gpui::FontWeight::MEDIUM) + .child(name), + ) + .child( + div() + .min_w_0() + .text_ellipsis() + .text_xs() + .text_color(muted) + .child(dir), + ) + .child( + div() + .flex_none() + .text_xs() + .text_color(muted) + .child(file.problems.len().to_string()), + ) + .into_any_element(), + ); + for (ix, problem) in file.problems.iter().enumerate() { + if rows.len() >= MAX_ROWS { + left += file.problems.len() - ix; + break; + } + let (icon, color) = match problem.severity { + Severity::Error => (IconName::CircleX, theme.danger), + Severity::Warning => (IconName::TriangleAlert, theme.warning), + Severity::Info => (IconName::Info, theme.info), + }; + let at = t_fmt( + L10nKey::EditorLnCol, + &[ + ("line", &(problem.line + 1).to_string()), + ("column", &(problem.column + 1).to_string()), + ], + ); + let path = file.path.clone(); + let (line, column) = (problem.line + 1, problem.column + 1); + rows.push( + h_flex() + .id(("editor-problem", rows.len())) + .h(px(22.)) + .pl(px(crate::ui::app::CONTENT_INSET + 12.)) + .pr(px(crate::ui::app::CONTENT_INSET)) + .gap_2() + .items_center() + .text_sm() + .cursor_pointer() + .hover(|s| s.bg(theme.list_hover)) + .child(Icon::new(icon).xsmall().text_color(color)) + .child( + div() + .min_w_0() + .flex_1() + .text_ellipsis() + .whitespace_nowrap() + .child(headline(&problem.message).to_string()), + ) + .children(problem.source.clone().map(|source| { + div().flex_none().text_xs().text_color(muted).child(source) + })) + .child(div().flex_none().text_xs().text_color(muted).child(at)) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_file_in_editor_at( + &path, + Some(line), + Some(column), + window, + cx, + ); + })) + .into_any_element(), + ); + } + } + if left > 0 { + rows.push( + div() + .px(px(crate::ui::app::CONTENT_INSET)) + .py_1() + .text_xs() + .text_color(muted) + .child(t_fmt( + L10nKey::EditorProblemsMore, + &[("n", &left.to_string())], + )) + .into_any_element(), + ); + } + let body: AnyElement = if files.is_empty() { + div() + .px(px(crate::ui::app::CONTENT_INSET)) + .py_2() + .text_sm() + .text_color(muted) + .child(t(L10nKey::EditorProblemsNone)) + .into_any_element() + } else { + let scroll = self.editor.problems.scroll.clone(); + crate::ui::scrollbar::with_vertical_scrollbar( + "editor-problems-scrollbar", + v_flex() + .id("editor-problems-list") + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .children(rows), + &scroll, + ) + }; + Some( + v_flex() + .id("editor-problems") + .flex_none() + .h(px(PANE_HEIGHT)) + .w_full() + .child(header) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Diagnostic, Position, Range}; + + fn d(line: u32, character: u32, severity: Option, msg: &str) -> Diagnostic { + Diagnostic { + range: Range::new( + Position::new(line, character), + Position::new(line, character + 1), + ), + severity, + message: msg.into(), + ..Default::default() + } + } + + #[test] + fn problems_are_sorted_by_file_then_position_without_hints() { + let files = collect(vec![ + ( + PathBuf::from("/p/src/b.rs"), + vec![ + d(9, 0, Some(DiagnosticSeverity::WARNING), "w"), + d(2, 4, Some(DiagnosticSeverity::ERROR), "e"), + d(2, 1, None, "unspecified is an error"), + d(5, 0, Some(DiagnosticSeverity::HINT), "hint"), + ], + ), + ( + PathBuf::from("/p/src/a.rs"), + vec![d(0, 0, Some(DiagnosticSeverity::INFORMATION), "i")], + ), + ( + PathBuf::from("/p/src/c.rs"), + vec![d(0, 0, Some(DiagnosticSeverity::HINT), "only a hint")], + ), + ]); + assert_eq!(files.len(), 2, "a file with only hints has no problems"); + assert_eq!(files[0].path, PathBuf::from("/p/src/a.rs")); + assert_eq!(files[0].problems[0].severity, Severity::Info); + let b: Vec<_> = files[1] + .problems + .iter() + .map(|p| (p.line, p.column, p.severity)) + .collect(); + assert_eq!( + b, + vec![ + (2, 1, Severity::Error), + (2, 4, Severity::Error), + (9, 0, Severity::Warning) + ] + ); + } + + #[test] + fn a_row_shows_the_first_line_of_the_message() { + assert_eq!( + headline("mismatched types\nexpected `u8`"), + "mismatched types" + ); + assert_eq!(headline(""), ""); + } + + /// The pane opens from its command and draws with nothing to show. + #[gpui::test] + fn the_pane_toggles_and_draws_empty(cx: &mut gpui::TestAppContext) { + use crate::ui::app::test_window; + + let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_new_file(window, cx); + app.toggle_editor_problems(cx); + }); + vcx.run_until_parked(); + vcx.update(|window, cx| window.draw(cx)); + app.update_in(&mut vcx, |app, window, cx| { + assert!(app.editor.problems.open); + assert!(app.render_editor_problems(window, cx).is_some()); + app.toggle_editor_problems(cx); + assert!(app.render_editor_problems(window, cx).is_none()); + }); + } +} diff --git a/src/ui/code_editor/split.rs b/src/ui/code_editor/split.rs new file mode 100644 index 00000000..1c983393 --- /dev/null +++ b/src/ui/code_editor/split.rs @@ -0,0 +1,898 @@ +//! The editor split in two: two groups of files side by side, each with a +//! file in front, the way VS Code's ⌘\ splits an editor. +//! +//! **One group has the focus, and it is `TabCode`'s own `files`/`active`.** +//! The other one waits in [`OtherGroup`]. Moving the focus swaps the two, so +//! everything that acts on "the file in front" — saving, go to line, the +//! language servers, the change markers, multiple cursors, back/forward, the +//! strip in the header — acts on the focused group without knowing a split +//! exists. The header's strip is the focused group's; the other group shows +//! its file in front and its breadcrumbs. +//! +//! **The same file in both groups.** A buffer is one `OpenFile` with one +//! `InputState`, which is what the language servers, the gutter markers, the +//! undo history and every hook in `code_editor.rs` key on. A second +//! `InputState` mirroring the first would need every one of those attached to +//! it twice, and edits copied back and forth without echoing — a lot of +//! surface for two views of one text. So the text is drawn once: in the group +//! with the focus. The other group shows a placeholder naming the file, and a +//! click on it moves the focus (and with it the live editor) over. The caret +//! and scroll of that file are therefore shared between the two groups; two +//! *different* files each keep their own, being different buffers. +//! +//! A group whose last file closes goes away, and the split with it. + +use gpui::Focusable as _; +use gpui::prelude::*; +use gpui::{AnyElement, Context, MouseButton, SharedString, Window, div, px}; +use gpui_component::{ActiveTheme as _, h_flex, v_flex}; + +use super::{BufferId, TabCode}; +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t}; + +/// The group without the focus. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct OtherGroup { + pub(crate) files: Vec, + pub(crate) active: usize, + /// Whether the group *with* the focus is the left one. + pub(crate) focus_left: bool, +} + +impl OtherGroup { + fn active_id(&self) -> Option { + self.files.get(self.active).copied() + } +} + +/// Takes `id` out of one group's strip, bringing its neighbour forward the +/// way `TabCode::forget` does. +fn remove(files: &mut Vec, active: &mut usize, id: BufferId) -> bool { + let Some(pos) = files.iter().position(|f| *f == id) else { + return false; + }; + files.remove(pos); + if *active > pos || *active >= files.len() { + *active = active.saturating_sub(1); + } + true +} + +/// One side of the editor, as it is drawn. +pub(crate) struct Side { + pub(crate) files: Vec, + pub(crate) active: usize, + pub(crate) focused: bool, +} + +impl TabCode { + pub(crate) fn is_split(&self) -> bool { + self.split.is_some() + } + + /// Whether either group shows this buffer. + pub(crate) fn shows(&self, id: BufferId) -> bool { + self.files.contains(&id) || self.split.as_ref().is_some_and(|o| o.files.contains(&id)) + } + + /// How many groups show this buffer: 0, 1 or 2. + pub(crate) fn views_of(&self, id: BufferId) -> usize { + usize::from(self.files.contains(&id)) + + usize::from(self.split.as_ref().is_some_and(|o| o.files.contains(&id))) + } + + /// Every buffer either group shows, once each, the focused group's first. + pub(crate) fn all_files(&self) -> Vec { + let mut all = self.files.clone(); + if let Some(other) = &self.split { + for id in &other.files { + if !all.contains(id) { + all.push(*id); + } + } + } + all + } + + /// The file in front of the group without the focus. + pub(crate) fn other_active_id(&self) -> Option { + self.split.as_ref().and_then(OtherGroup::active_id) + } + + /// Gives the focus to the other group. + pub(crate) fn swap_focus(&mut self) { + let Some(other) = self.split.as_mut() else { + return; + }; + std::mem::swap(&mut self.files, &mut other.files); + std::mem::swap(&mut self.active, &mut other.active); + other.focus_left = !other.focus_left; + } + + /// Which side the focused group is drawn on: 0 left, 1 right. What tells + /// the two groups' elements apart. + pub(crate) fn focused_slot(&self) -> usize { + usize::from(!self.focus_is_left()) + } + + /// Whether the focused group is on the left. `true` when not split. + pub(crate) fn focus_is_left(&self) -> bool { + self.split.as_ref().is_none_or(|o| o.focus_left) + } + + /// Shows `id` in the other group and gives that group the focus — + /// splitting the editor first if it is not split yet, with the new group + /// on the right. + pub(crate) fn split_with(&mut self, id: BufferId) { + match self.split.as_mut() { + None => { + self.split = Some(OtherGroup { + files: std::mem::replace(&mut self.files, vec![id]), + active: std::mem::replace(&mut self.active, 0), + focus_left: false, + }); + } + Some(_) => { + self.swap_focus(); + self.show(id); + } + } + } + + /// Closes a file in the focused group only. The group goes if it was its + /// last file, and the focus passes to the one that is left. + pub(crate) fn close_in_focused(&mut self, id: BufferId) -> bool { + let closed = self.forget(id); + self.collapse(); + closed + } + + /// Takes a buffer out of both groups, for a buffer that is going away. + pub(crate) fn forget_everywhere(&mut self, id: BufferId) -> bool { + let mut gone = self.forget(id); + if let Some(other) = self.split.as_mut() { + gone |= remove(&mut other.files, &mut other.active, id); + } + self.collapse(); + gone + } + + /// A group with nothing left in it closes. + fn collapse(&mut self) { + let Some(other) = self.split.as_ref() else { + return; + }; + if other.files.is_empty() { + self.split = None; + } else if self.files.is_empty() { + let other = self.split.take().unwrap_or_default(); + self.files = other.files; + self.active = other.active; + } + } + + /// Lays a tab's recorded groups back out once their files have loaded. + /// + /// `restored` are the buffers the restore opened, already listed in the + /// focused group (they arrive in the background). Anything else the tab + /// shows was opened while they loaded, and is kept: + /// + /// - A tab split in the meantime keeps its layout; the restored files + /// stay where they arrived, behind the file in front. + /// - Otherwise the recorded layout is put back, the files opened in the + /// meantime join the focused group, and the last of them stays in front. + /// + /// `true` when nothing had been opened in the meantime. + pub(crate) fn restore_groups( + &mut self, + restored: &[BufferId], + left: (Vec, usize), + right: Option<(Vec, usize, bool)>, + ) -> bool { + if self.is_split() { + return false; + } + let extras: Vec = self + .files + .iter() + .copied() + .filter(|id| !restored.contains(id)) + .collect(); + let front = self.active_id().filter(|id| extras.contains(id)); + let (left, left_active) = left; + let right = right.filter(|(files, ..)| !files.is_empty()); + match (left.is_empty(), right) { + (false, Some((right, right_active, focused))) => { + self.files = left; + self.active = left_active; + self.split = Some(OtherGroup { + files: right, + active: right_active, + focus_left: true, + }); + if focused { + self.swap_focus(); + } + } + (false, None) => { + self.files = left; + self.active = left_active; + } + (true, Some((right, right_active, _))) => { + self.files = right; + self.active = right_active; + } + (true, None) => {} + } + for id in &extras { + if !self.files.contains(id) { + self.files.push(*id); + } + } + if let Some(front) = front { + self.show(front); + } + extras.is_empty() + } + + /// The two sides, left first. One when the editor is not split. + pub(crate) fn sides(&self) -> Vec { + let focused = Side { + files: self.files.clone(), + active: self.active, + focused: true, + }; + let Some(other) = &self.split else { + return vec![focused]; + }; + let other_side = Side { + files: other.files.clone(), + active: other.active, + focused: false, + }; + match other.focus_left { + true => vec![focused, other_side], + false => vec![other_side, focused], + } + } +} + +impl Tty7App { + /// Splits the editor: the file in front opens in a second group to the + /// right, which takes the focus. Already split, it opens in the other + /// group instead. `false` with no file to split. + pub(crate) fn editor_split(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(code) = self.tab_code_mut() else { + return false; + }; + let Some(id) = code.active_id() else { + return false; + }; + code.split_with(id); + self.editor.bar = None; + self.focus_editor(window, cx); + cx.notify(); + true + } + + /// Gives the focus to the group on the left (or right). `false` when there + /// is no such group to go to — not split, or already there — so the chord + /// can carry on to the panes. + pub(crate) fn editor_focus_group( + &mut self, + left: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(code) = self.tab_code_mut() else { + return false; + }; + if !code.is_split() || code.focus_is_left() == left { + return false; + } + code.swap_focus(); + self.editor.bar = None; + self.focus_editor(window, cx); + cx.notify(); + true + } + + /// Keeps the focused group the one whose text has the keyboard: a click, + /// or anything else that focuses the other group's editor, moves the + /// group focus there too. Run on every draw, and before every editor + /// command ([`Self::editor_split_command_sync`]): focus moves between + /// frames, and F12 or F2 in the first frame after one must act on the + /// group the caret is now in. + pub(crate) fn editor_split_follow_focus(&mut self, window: &Window, cx: &mut Context) { + let Some(other) = self.tab_code().and_then(TabCode::other_active_id) else { + return; + }; + if self.tab_code().and_then(TabCode::active_id) == Some(other) { + return; + } + let focused = self.buffer(other).is_some_and(|f| { + f.input + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + }); + if focused && let Some(code) = self.tab_code_mut() { + code.swap_focus(); + cx.notify(); + } + } + + /// Settles the group focus before any editor command runs, on the element + /// that holds the editor. + /// + /// gpui draws a window whose focus moved before it dispatches the next + /// key, which settles the group on its own; a command from the menu bar + /// or a context menu is dispatched without that draw. A capture-phase + /// listener runs before the command's own handler wherever that handler + /// sits — here, in `app.rs` or in `ui::lsp` — and lets the command carry + /// on. + pub(crate) fn editor_split_command_sync( + &self, + element: E, + cx: &mut Context, + ) -> E { + use crate::core::actions::*; + macro_rules! before { + ($el:expr, $($action:ty),+ $(,)?) => { + $el$(.capture_action(cx.listener(|this, _: &$action, window, cx| { + this.editor_split_follow_focus(window, cx); + })))+ + }; + } + before!( + element, + EditorSave, + EditorSaveAs, + EditorGoToLine, + EditorGoToSymbol, + EditorNavigateBack, + EditorNavigateForward, + EditorSplitRight, + EditorGoToDefinition, + EditorQuickFix, + EditorRenameSymbol, + EditorFormatDocument, + EditorTransformUppercase, + EditorTransformLowercase, + EditorTransformTitleCase, + EditorTrimTrailingWhitespace, + EditorJoinLines, + EditorRemoveSurroundingBrackets, + EditorFindReferences, + EditorNextChange, + EditorPrevChange, + EditorRevertChange, + EditorPeekChange, + ToggleDocumentPreview, + ToggleDocumentWrap, + ) + } + + /// The editor's text area: one group, or two side by side. + pub(crate) fn render_editor_groups( + &mut self, + focused_crumbs: Option, + focused_body: AnyElement, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let pane = |crumbs: Option, body: AnyElement| { + v_flex() + .flex_1() + .min_w_0() + .min_h_0() + .h_full() + .children(crumbs) + .child(div().flex_1().min_h_0().child(body)) + }; + let Some(code) = self.tab_code() else { + return pane(focused_crumbs, focused_body).into_any_element(); + }; + if !code.is_split() { + return pane(focused_crumbs, focused_body).into_any_element(); + } + let focus_left = code.focus_is_left(); + let other = code.other_active_id(); + let same = other.is_some() && other == code.active_id(); + let other_slot = usize::from(focus_left); + let (other_crumbs, other_body) = match other { + Some(id) if same => (None, self.render_editor_same_file(id, focus_left, cx)), + Some(id) => ( + self.render_editor_breadcrumbs_for(id, other_slot, false, window, cx), + self.render_editor_body(Some(id), other_slot, cx), + ), + None => (None, self.render_editor_empty(cx).into_any_element()), + }; + let accent = cx.theme().primary; + let focused = pane(focused_crumbs, focused_body) + .border_t_2() + .border_color(accent) + .into_any_element(); + let unfocused = pane(other_crumbs, other_body) + .border_t_2() + .border_color(gpui::transparent_black()) + .id(("editor-group", other_slot)) + // Before the text sees the click, so what the click does lands in + // the group it was made in. + .capture_any_mouse_down(cx.listener(move |this, _, window, cx| { + this.editor_focus_group(!focus_left, window, cx); + })) + .into_any_element(); + let divider = div() + .flex_none() + .w(px(1.)) + .h_full() + .bg(cx.theme().sidebar_border); + let (left, right) = match focus_left { + true => (focused, unfocused), + false => (unfocused, focused), + }; + h_flex() + .flex_1() + .min_h_0() + .size_full() + .child(left) + .child(divider) + .child(right) + .into_any_element() + } + + /// The other group's view of the file the focused group is showing: it is + /// drawn once, there, and a click brings it here. + fn render_editor_same_file( + &self, + id: BufferId, + focus_left: bool, + cx: &mut Context, + ) -> AnyElement { + let name: SharedString = self.buffer(id).map(|f| f.label()).unwrap_or_default(); + let theme = cx.theme(); + v_flex() + .id("editor-group-same-file") + .size_full() + .items_center() + .justify_center() + .gap_1() + .cursor_pointer() + .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .text_color(theme.muted_foreground) + .child(div().text_color(theme.foreground).child(name)) + .child(t(L10nKey::EditorSplitSameFile)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.editor_focus_group(!focus_left, window, cx); + }), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(gpui::EntityId::from).collect() + } + + fn code(files: &[BufferId], active: usize) -> TabCode { + let mut code = TabCode::new(); + code.files = files.to_vec(); + code.active = active; + code + } + + #[test] + fn splitting_opens_the_file_in_front_in_a_focused_right_group() { + let ids = ids(3); + let mut code = code(&ids, 1); + code.split_with(ids[1]); + assert_eq!(code.files, [ids[1]]); + assert_eq!(code.active_id(), Some(ids[1])); + assert!(!code.focus_is_left()); + let sides = code.sides(); + assert_eq!(sides.len(), 2); + assert_eq!( + (sides[0].files.clone(), sides[0].focused), + (ids.clone(), false) + ); + assert!(sides[1].focused); + assert_eq!(code.views_of(ids[1]), 2); + assert_eq!(code.views_of(ids[0]), 1); + assert_eq!(code.all_files(), [ids[1], ids[0], ids[2]]); + } + + #[test] + fn the_focus_swaps_the_groups_but_not_their_places() { + let ids = ids(3); + let mut code = code(&ids[..2], 0); + code.split_with(ids[0]); + code.show(ids[2]); + code.swap_focus(); + assert!(code.focus_is_left()); + assert_eq!(code.files, ids[..2]); + assert_eq!(code.other_active_id(), Some(ids[2])); + let sides = code.sides(); + assert!(sides[0].focused && !sides[1].focused); + assert_eq!(sides[1].files, [ids[0], ids[2]]); + } + + #[test] + fn a_group_that_loses_its_last_file_closes() { + let ids = ids(2); + let mut code = code(&ids, 0); + code.split_with(ids[1]); + // Closing the right group's only file leaves the left one, focused. + assert!(code.close_in_focused(ids[1])); + assert!(!code.is_split()); + assert_eq!(code.files, ids); + assert_eq!(code.active_id(), Some(ids[0]), "it keeps what was in front"); + + // Closing the other group's last file from elsewhere (the buffer went + // away) closes that group instead. + code.split_with(ids[0]); + code.swap_focus(); + assert!(code.forget_everywhere(ids[0])); + assert!(!code.is_split()); + assert_eq!(code.files, [ids[1]]); + assert!(!code.shows(ids[0])); + } + + #[test] + fn a_restore_lays_out_the_recorded_groups() { + let ids = ids(3); + // As the restore finds it: every file arrived at the end of the strip. + let mut code = code(&ids, 0); + let untouched = code.restore_groups( + &ids, + (vec![ids[0], ids[1]], 1), + Some((vec![ids[2]], 0, true)), + ); + assert!(untouched); + assert!(!code.focus_is_left()); + assert_eq!(code.active_id(), Some(ids[2])); + assert_eq!(code.other_active_id(), Some(ids[1])); + } + + #[test] + fn a_file_opened_while_the_restore_loaded_stays_in_front() { + let ids = ids(4); + // ids[3] was opened by hand; the restore's three arrived behind it. + let mut code = code(&[ids[3], ids[0], ids[1], ids[2]], 0); + let untouched = code.restore_groups( + &ids[..3], + (vec![ids[0], ids[1]], 0), + Some((vec![ids[2]], 0, false)), + ); + assert!(!untouched); + assert!(code.is_split(), "the recorded split is still put back"); + assert!(code.focus_is_left()); + assert_eq!(code.files, [ids[0], ids[1], ids[3]]); + assert_eq!( + code.active_id(), + Some(ids[3]), + "what was opened stays in front" + ); + assert_eq!(code.other_active_id(), Some(ids[2])); + } + + #[test] + fn a_split_made_while_the_restore_loaded_is_kept() { + let ids = ids(4); + let mut code = code(&[ids[3]], 0); + code.split_with(ids[3]); + // The restore's files arrive in the focused group. + code.files.extend([ids[0], ids[1]]); + let before = (code.files.clone(), code.active, code.split.clone()); + let untouched = + code.restore_groups(&ids[..2], (vec![ids[0]], 0), Some((vec![ids[1]], 0, true))); + assert!(!untouched); + assert_eq!( + (code.files.clone(), code.active, code.split.clone()), + before + ); + } + + #[test] + fn closing_in_one_group_keeps_the_file_in_the_other() { + let ids = ids(2); + let mut code = code(&ids, 0); + code.split_with(ids[0]); + code.show(ids[1]); + code.close_in_focused(ids[0]); + assert!(code.is_split()); + assert!(code.shows(ids[0])); + assert_eq!(code.views_of(ids[0]), 1); + } +} + +#[cfg(test)] +mod gpui_tests { + use std::path::PathBuf; + + use gpui::{Entity, TestAppContext, VisualTestContext}; + + use super::super::nav::gpui_tests::{caret, open_rs, put_caret}; + use super::*; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::editor_session::{self, SplitEditor, TabEditor}; + + fn front(app: &Entity, vcx: &mut VisualTestContext) -> PathBuf { + app.read_with(vcx, |app, _| app.active_buffer().unwrap().path.clone()) + } + + fn split(app: &Entity, vcx: &mut VisualTestContext) -> bool { + app.read_with(vcx, |app, _| app.tab_code().is_some_and(TabCode::is_split)) + } + + #[gpui::test] + fn two_groups_keep_their_own_file_and_caret(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/split/a.rs"); + put_caret(&app, &mut vcx, 5); + assert!(app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx))); + assert!(split(&app, &mut vcx)); + // The new group is on the right, focused, showing the same file. + app.read_with(&vcx, |app, _| { + let code = app.tab_code().unwrap(); + assert!(!code.focus_is_left()); + assert_eq!(code.active_id(), code.other_active_id()); + }); + // A file opened now opens in the focused (right) group only. + open_rs(&app, &mut vcx, "/split/b.rs"); + put_caret(&app, &mut vcx, 40); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/b.rs")); + + // To the left: a.rs, with its own caret. + assert!(app.update_in(&mut vcx, |app, window, cx| { + app.editor_focus_group(true, window, cx) + })); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs")); + assert_eq!(caret(&app, &mut vcx).line, 5); + assert!( + !app.update_in(&mut vcx, |app, window, cx| app + .editor_focus_group(true, window, cx)), + "no group further left: the chord carries on to the panes" + ); + // And back. + assert!(app.update_in(&mut vcx, |app, window, cx| { + app.editor_focus_group(false, window, cx) + })); + assert_eq!(caret(&app, &mut vcx).line, 40); + + // Closing both files of the right group closes the group. + app.update_in(&mut vcx, |app, window, cx| { + let n = app.tab_code().unwrap().files.len(); + for _ in 0..n { + app.editor_close_file(0, window, cx); + } + }); + assert!(!split(&app, &mut vcx)); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs")); + assert!( + app.read_with(&vcx, |app, _| app + .editor + .buffers + .iter() + .all(|b| b.path != std::path::Path::new("/split/b.rs"))), + "a file no group shows any more is closed" + ); + } + + #[gpui::test] + fn a_split_is_recorded_and_comes_back(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + // Canonical, as a loaded file's path is: /var is /private/var here. + let root = crate::ui::code_editor::tests::test_real_dir(dir.path()); + let (a, b, c) = (root.join("a.rs"), root.join("b.rs"), root.join("c.rs")); + for p in [&a, &b, &c] { + std::fs::write(p, "fn main() {}\n").unwrap(); + } + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + crate::ui::i18n::set_locale("en"); + let state = TabEditor { + files: vec![a.clone(), b.clone()], + active: 1, + visible: true, + split: Some(SplitEditor { + files: vec![c.clone()], + active: 0, + focused: true, + }), + }; + // The second tab was split when the window last closed. + let tab = app.read_with(&vcx, |app, _| app.tabs[1].tree_id.get()); + vcx.update(|_, cx| editor_session::put(cx, tab, state.clone())); + app.update_in(&mut vcx, |app, window, cx| { + app.active = 1; + // As on a relaunch: nothing has been put back for it yet. + app.editor.restored.remove(&tab); + app.editor_restore_active(window, cx); + }); + // The files are read on a real thread; let them land. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !split(&app, &mut vcx) { + assert!( + std::time::Instant::now() < deadline, + "the files never landed" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + vcx.run_until_parked(); + } + app.read_with(&vcx, |app, _| { + let code = app.tab_code().unwrap(); + assert!(code.is_split()); + assert!(!code.focus_is_left(), "the right group had the focus"); + let path = |id| app.buffer(id).unwrap().path.clone(); + assert_eq!(path(code.active_id().unwrap()), c); + assert_eq!(path(code.other_active_id().unwrap()), b); + }); + // And recording it writes the same thing down. + app.update(&mut vcx, |app, cx| app.editor_record_sessions(cx)); + let recorded = vcx.update(|_, cx| editor_session::get(cx, tab)).unwrap(); + assert_eq!(recorded, state); + } + + #[gpui::test] + fn a_split_with_nothing_on_the_left_still_comes_back(cx: &mut TestAppContext) { + // The left group held only untitled or remote buffers, which are + // not recorded: only the right group's files were written down. + let dir = tempfile::tempdir().unwrap(); + let root = crate::ui::code_editor::tests::test_real_dir(dir.path()); + let c = root.join("c.rs"); + std::fs::write(&c, "fn main() {}\n").unwrap(); + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + let state = TabEditor { + files: vec![], + active: 0, + visible: true, + split: Some(SplitEditor { + files: vec![c.clone()], + active: 0, + focused: true, + }), + }; + let tab = app.read_with(&vcx, |app, _| app.tabs[0].tree_id.get()); + vcx.update(|_, cx| editor_session::put(cx, tab, state.clone())); + app.update_in(&mut vcx, |app, window, cx| { + app.editor.restored.remove(&tab); + app.editor_restore_active(window, cx); + }); + let has_files = |app: &Entity, vcx: &mut VisualTestContext| { + app.read_with(vcx, |app, _| { + app.tab_code().is_some_and(|c| c.active_id().is_some()) + }) + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !has_files(&app, &mut vcx) { + assert!( + std::time::Instant::now() < deadline, + "the right group's file never came back" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + vcx.run_until_parked(); + } + app.read_with(&vcx, |app, _| { + let code = app.tab_code().unwrap(); + assert_eq!(app.buffer(code.active_id().unwrap()).unwrap().path, c); + }); + } + + #[gpui::test] + fn a_key_pressed_right_after_a_focus_move_acts_on_the_new_group(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/split/a.rs"); + app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx)); + open_rs(&app, &mut vcx, "/split/b.rs"); + vcx.run_until_parked(); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/b.rs")); + // The keyboard moves to the left group's text, and a key follows + // before anything is drawn. + vcx.update(|window, cx| { + let left = app.read(cx).tab_code().unwrap().other_active_id().unwrap(); + let input = app.read(cx).buffer(left).unwrap().input.clone(); + input.update(cx, |state, cx| state.focus(window, cx)); + let before = app.read(cx).active_buffer().unwrap().path.clone(); + assert_eq!( + before, + PathBuf::from("/split/b.rs"), + "nothing has drawn yet" + ); + // Go to Line asks whether the editor has the keyboard, which it + // only does if the group focus has followed it. + window.dispatch_keystroke(gpui::Keystroke::parse("ctrl-g").unwrap(), cx); + assert!( + app.read(cx).editor.bar.is_some(), + "the command saw the group the keyboard is in" + ); + }); + } + + /// A command from a menu is dispatched without a draw first, so nothing + /// but the capture listener can have settled the group. + #[gpui::test] + fn a_menu_command_right_after_a_focus_move_acts_on_the_new_group(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/split/a.rs"); + app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx)); + open_rs(&app, &mut vcx, "/split/b.rs"); + vcx.run_until_parked(); + vcx.update(|window, cx| { + let left = app.read(cx).tab_code().unwrap().other_active_id().unwrap(); + let input = app.read(cx).buffer(left).unwrap().input.clone(); + input.update(cx, |state, cx| state.focus(window, cx)); + let node = window.focused(cx).unwrap(); + node.dispatch_action(&crate::core::actions::EditorGoToLine, window, cx); + assert!( + app.read(cx).editor.bar.is_some(), + "the command saw the group the keyboard is in" + ); + let front = app.read(cx).active_buffer().unwrap().path.clone(); + assert_eq!(front, PathBuf::from("/split/a.rs")); + }); + } + + #[gpui::test] + fn what_is_opened_while_a_restore_loads_survives_it(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let root = crate::ui::code_editor::tests::test_real_dir(dir.path()); + let (a, b, c) = (root.join("a.rs"), root.join("b.rs"), root.join("c.rs")); + for p in [&a, &b, &c] { + std::fs::write(p, "fn main() {}\n").unwrap(); + } + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + let state = TabEditor { + files: vec![a.clone(), b.clone()], + active: 0, + visible: true, + split: Some(SplitEditor { + files: vec![c.clone()], + active: 0, + focused: false, + }), + }; + let tab = app.read_with(&vcx, |app, _| app.tabs[0].tree_id.get()); + vcx.update(|_, cx| editor_session::put(cx, tab, state)); + app.update_in(&mut vcx, |app, window, cx| { + app.editor.restored.remove(&tab); + app.editor_restore_active(window, cx); + }); + // Opened by hand while the three are still being read. + open_rs(&app, &mut vcx, "/split/opened.rs"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while !split(&app, &mut vcx) { + assert!( + std::time::Instant::now() < deadline, + "the files never landed" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + vcx.run_until_parked(); + } + app.read_with(&vcx, |app, _| { + let code = app.tab_code().unwrap(); + let path = |id| app.buffer(id).unwrap().path.clone(); + assert_eq!( + path(code.active_id().unwrap()), + PathBuf::from("/split/opened.rs") + ); + let shown: Vec = code.all_files().into_iter().map(path).collect(); + for p in [&a, &b, &c] { + assert!(shown.contains(p), "{} came back", p.display()); + } + assert_eq!(path(code.other_active_id().unwrap()), c); + assert!(code.visible); + }); + } +} diff --git a/src/ui/editor_session.rs b/src/ui/editor_session.rs index 4b198fb5..4fc79d19 100644 --- a/src/ui/editor_session.rs +++ b/src/ui/editor_session.rs @@ -24,7 +24,8 @@ const MAX_TABS: usize = 256; const WRITE_DELAY: std::time::Duration = std::time::Duration::from_millis(500); -/// One tab's editor, as it was last seen. +/// One tab's editor, as it was last seen. `files` and `active` are the left +/// group's — the only group, unless the editor was split. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct TabEditor { pub(crate) files: Vec, @@ -32,6 +33,20 @@ pub(crate) struct TabEditor { pub(crate) active: usize, #[serde(default)] pub(crate) visible: bool, + /// The right group of a split editor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) split: Option, +} + +/// The right-hand group of a split editor. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SplitEditor { + pub(crate) files: Vec, + #[serde(default)] + pub(crate) active: usize, + /// Whether this group, not the left one, had the focus. + #[serde(default)] + pub(crate) focused: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -188,6 +203,11 @@ mod tests { files: vec![PathBuf::from("/src/main.rs"), PathBuf::from("/README.md")], active: 1, visible: true, + split: Some(SplitEditor { + files: vec![PathBuf::from("/src/lib.rs")], + active: 0, + focused: true, + }), }, touched: 7, }, @@ -197,6 +217,16 @@ mod tests { assert_eq!(back.tabs[&id].state, doc.tabs[&id].state); } + #[test] + fn a_record_from_before_the_split_still_reads() { + let old = r#"{"tabs": {}}"#; + assert!(serde_json::from_str::(old).is_ok()); + let state: TabEditor = + serde_json::from_str(r#"{"files": ["/a.rs"], "active": 0, "visible": true}"#).unwrap(); + assert_eq!(state.split, None); + assert!(!serde_json::to_string(&state).unwrap().contains("split")); + } + #[test] fn the_oldest_tabs_are_forgotten_first() { let mut doc = Doc::default(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 3ee0a81a..f6f6ac44 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -125,10 +125,16 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SearchTabActions => "Commands", L10nKey::SearchTabTerminals => "Terminals", L10nKey::SearchTabHosts => "Hosts", + L10nKey::SearchTabSymbols => "Symbols", L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…", L10nKey::SearchPlaceholderActions => "Search commands…", L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…", L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…", + L10nKey::SearchPlaceholderSymbols => "Go to a symbol in this file…", + L10nKey::SearchSymbolsNone => "No symbols in this file", + L10nKey::SearchSymbolsNoneHint => { + "Symbols are listed for Rust, Go, Python, JavaScript, TypeScript, C, C++, Java, Ruby, shell and Markdown." + } L10nKey::SearchTabFiles => "Files", L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…", L10nKey::SearchFilesNoRoots => "No project to search", @@ -1179,6 +1185,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorNoFileOpen => "No file open", L10nKey::EditorBackToTerminal => "Back to Terminal (Esc)", L10nKey::EditorLnCol => "Ln {line}, Col {column}", + L10nKey::EditorSelections => "({n} selections)", L10nKey::EditorEdit => "Edit", L10nKey::EditorPreview => "Preview", L10nKey::EditorWrapOn => "Wrap: on", @@ -1201,8 +1208,71 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorAlreadyOpen => "\"{path}\" is already open in the editor", L10nKey::EditorGoToLine => "Go to line", L10nKey::EditorGoToLineAction => "Go to Line…", + L10nKey::EditorGoToMatchingBracket => "Go to Matching Bracket", + L10nKey::EditorToggleComment => "Toggle Comment", + L10nKey::EditorMoveLineUp => "Move Line Up", + L10nKey::EditorMoveLineDown => "Move Line Down", + L10nKey::EditorDuplicateLine => "Duplicate Line", + L10nKey::EditorDeleteLine => "Delete Line", L10nKey::EditorCopyRelativePath => "Copy Relative Path", + L10nKey::EditorGitNextChange => "Go to Next Change", + L10nKey::EditorGitPrevChange => "Go to Previous Change", + L10nKey::EditorGitRevertChange => "Revert Change", + L10nKey::EditorGitToggleGutter => "Toggle Git Change Markers", + L10nKey::EditorGitPeekRevert => "Revert", + L10nKey::EditorGitPeekSummary => "−{removed} +{added} lines against the staged version", + L10nKey::EditorGitPeekAddedOnly => { + "These lines are new: the staged version has nothing here." + } + L10nKey::EditorGitPeekChange => "Peek Change", + L10nKey::EditorGitPeekKeys => "Enter reverts · Esc closes", + L10nKey::EditorProblemsTitle => "Problems", + L10nKey::EditorProblemsToggle => "Toggle Problems", + L10nKey::EditorProblemsNone => "No problems in the open files.", + L10nKey::EditorProblemsMore => "…and {n} more", + L10nKey::SettingsEditor => "Editor", + L10nKey::SettingsEditorGitGutter => "Git change markers", + L10nKey::SettingsEditorGitGutterDesc => { + "Mark lines that differ from the staged version beside the line numbers and on the scrollbar." + } + L10nKey::SettingsEditorLsp => "Language servers", + L10nKey::SettingsEditorLspDesc => { + "Start a language server for files it knows, for errors, completion and go to definition. Only files on this machine." + } + L10nKey::SettingsEditorSoftWrap => "Wrap long lines", + L10nKey::SettingsEditorSoftWrapDesc => { + "Open files with soft wrap on. The status bar's Wrap button changes it for one file." + } + L10nKey::SettingsEditorMarkdownPreview => "Open Markdown rendered", + L10nKey::SettingsEditorMarkdownPreviewDesc => { + "Show Markdown files as a rendered preview rather than as source when they open." + } + L10nKey::SettingsSearchEditorGitGutterKeywords => { + "git gutter diff changes markers staged index scm vcs modified added deleted" + } + L10nKey::SettingsSearchEditorLspKeywords => { + "lsp language server diagnostics errors warnings completion rust-analyzer definition" + } + L10nKey::SettingsSearchEditorSoftWrapKeywords => "wrap soft wrap long lines editor", + L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => { + "markdown preview rendered md readme" + } L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})", + L10nKey::EditorGoToSymbolAction => "Go to Symbol in Editor…", + L10nKey::EditorNavigateBack => "Go Back", + L10nKey::EditorNavigateForward => "Go Forward", + L10nKey::EditorSplitRight => "Split Editor Right", + L10nKey::EditorFocusLeftGroup => "Focus Left Editor Group", + L10nKey::EditorFocusRightGroup => "Focus Right Editor Group", + L10nKey::EditorSplitSameFile => "Open in the other group — click to edit it here", + L10nKey::CmdEditorGoToSymbol => "Editor: Go to Symbol…", + L10nKey::SearchHeadingReferences => "References", + L10nKey::SearchSectionThisFile => "In This File", + L10nKey::SearchSectionProject => "Project", + L10nKey::SearchHeadingDefinitions => "Definitions", + L10nKey::CmdEditorGoBack => "Editor: Go Back", + L10nKey::CmdEditorGoForward => "Editor: Go Forward", + L10nKey::CmdEditorSplitRight => "Editor: Split Right", L10nKey::EditorSaveAs => "Save as", L10nKey::EditorSaveAsAction => "Save As…", L10nKey::EditorSaveAsPlaceholder => "Full path to save to", @@ -1213,6 +1283,21 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorFileDeletedOnDisk => "This file was deleted on disk", L10nKey::EditorIndentSpaces => "Spaces: {n}", L10nKey::EditorIndentTabs => "Tab Size: {n}", + L10nKey::LspGoToDefinition => "Go to Definition", + L10nKey::LspQuickFix => "Quick Fix…", + L10nKey::LspFormatDocument => "Format Document", + L10nKey::LspRenameSymbol => "Rename symbol", + L10nKey::LspRenameSymbolAction => "Rename Symbol…", + L10nKey::LspRenamePlaceholder => "New name for {name}", + L10nKey::LspRenameFailed => "Could not rename {name}", + L10nKey::LspServerMissing => "{name} not installed", + L10nKey::LspServerStarting => "{name} starting…", + L10nKey::LspServerDown => "{name} stopped", + L10nKey::LspProblemsTooltip => "{errors} errors, {warnings} warnings", + L10nKey::SearchTabLocations => "Locations", + L10nKey::SearchPlaceholderLocations => "Filter the places found…", + L10nKey::SearchLocationsNone => "Nothing found", + L10nKey::LspFindReferences => "Find All References", L10nKey::PanelInfoTitle => "Info", L10nKey::PanelChangesTitle => "Changes", L10nKey::PanelScmTitle => "Changes", @@ -1681,6 +1766,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width", L10nKey::CmdToggleDocumentPreview => "Document: Toggle Markdown Preview", L10nKey::CmdToggleDocumentWrap => "Document: Toggle Word Wrap", + L10nKey::CmdEditorTransformUppercase => "Document: Transform to Uppercase", + L10nKey::CmdEditorTransformLowercase => "Document: Transform to Lowercase", + L10nKey::CmdEditorTransformTitleCase => "Document: Transform to Title Case", + L10nKey::CmdEditorTrimTrailingWhitespace => "Document: Trim Trailing Whitespace", + L10nKey::CmdEditorJoinLines => "Document: Join Lines", + L10nKey::CmdEditorRemoveSurroundingBrackets => "Document: Remove Surrounding Brackets", L10nKey::CmdGitCommit => "Git: Commit", L10nKey::CmdGitStageAll => "Git: Stage All Changes", L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2730f897..9714507c 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -126,10 +126,16 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "コマンド", L10nKey::SearchTabTerminals => "ターミナル", L10nKey::SearchTabHosts => "ホスト", + L10nKey::SearchTabSymbols => "シンボル", L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…", L10nKey::SearchPlaceholderActions => "コマンドを検索…", L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…", L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…", + L10nKey::SearchPlaceholderSymbols => "このファイル内のシンボルへ移動…", + L10nKey::SearchSymbolsNone => "このファイルにシンボルはありません", + L10nKey::SearchSymbolsNoneHint => { + "Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、シェル、Markdown のシンボルを一覧できます。" + } L10nKey::SearchTabFiles => "ファイル", L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…", L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません", @@ -1227,6 +1233,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorNoFileOpen => "開かれているファイルはありません", L10nKey::EditorBackToTerminal => "ターミナルに戻る (Esc)", L10nKey::EditorLnCol => "行 {line}, 列 {column}", + L10nKey::EditorSelections => "({n} 個の選択範囲)", L10nKey::EditorEdit => "編集", L10nKey::EditorPreview => "プレビュー", L10nKey::EditorWrapOn => "折り返し: オン", @@ -1249,8 +1256,69 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorAlreadyOpen => "「{path}」はすでにエディタで開かれています", L10nKey::EditorGoToLine => "行へ移動", L10nKey::EditorGoToLineAction => "行へ移動…", + L10nKey::EditorGoToMatchingBracket => "対応する括弧へ移動", + L10nKey::EditorToggleComment => "コメントの切り替え", + L10nKey::EditorMoveLineUp => "行を上へ移動", + L10nKey::EditorMoveLineDown => "行を下へ移動", + L10nKey::EditorDuplicateLine => "行を複製", + L10nKey::EditorDeleteLine => "行を削除", L10nKey::EditorCopyRelativePath => "相対パスをコピー", + L10nKey::EditorGitNextChange => "次の変更へ移動", + L10nKey::EditorGitPrevChange => "前の変更へ移動", + L10nKey::EditorGitRevertChange => "変更を元に戻す", + L10nKey::EditorGitToggleGutter => "Git 変更マーカーの切り替え", + L10nKey::EditorGitPeekRevert => "元に戻す", + L10nKey::EditorGitPeekSummary => "ステージ済みの版と比べて −{removed} +{added} 行", + L10nKey::EditorGitPeekAddedOnly => { + "新しく追加された行です。ステージ済みの版にはここに何もありません。" + } + L10nKey::EditorGitPeekChange => "変更をプレビュー", + L10nKey::EditorGitPeekKeys => "Enter で元に戻す · Esc で閉じる", + L10nKey::EditorProblemsTitle => "問題", + L10nKey::EditorProblemsToggle => "問題の表示切り替え", + L10nKey::EditorProblemsNone => "開いているファイルに問題はありません。", + L10nKey::EditorProblemsMore => "…ほか {n} 件", + L10nKey::SettingsEditor => "エディタ", + L10nKey::SettingsEditorGitGutter => "Git 変更マーカー", + L10nKey::SettingsEditorGitGutterDesc => { + "ステージ済みの版と異なる行を、行番号の横とスクロールバーに示します。" + } + L10nKey::SettingsEditorLsp => "言語サーバー", + L10nKey::SettingsEditorLspDesc => { + "対応するファイルで言語サーバーを起動し、エラー表示・補完・定義へ移動を使えるようにします。このマシン上のファイルのみ。" + } + L10nKey::SettingsEditorSoftWrap => "長い行を折り返す", + L10nKey::SettingsEditorSoftWrapDesc => { + "ファイルを折り返し表示で開きます。ステータスバーの折り返しボタンでファイルごとに切り替えられます。" + } + L10nKey::SettingsEditorMarkdownPreview => "Markdown をレンダリングして開く", + L10nKey::SettingsEditorMarkdownPreviewDesc => { + "Markdown ファイルを開いたとき、ソースではなくレンダリングしたプレビューを表示します。" + } + L10nKey::SettingsSearchEditorGitGutterKeywords => { + "git 変更 マーカー 差分 ステージ 変更 追加 削除 gutter diff" + } + L10nKey::SettingsSearchEditorLspKeywords => "lsp 言語サーバー 診断 エラー 警告 補完 定義", + L10nKey::SettingsSearchEditorSoftWrapKeywords => "折り返し 長い行 wrap", + L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => { + "markdown プレビュー レンダリング md" + } L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})", + L10nKey::EditorGoToSymbolAction => "エディタ内のシンボルへ移動…", + L10nKey::EditorNavigateBack => "戻る", + L10nKey::EditorNavigateForward => "進む", + L10nKey::EditorSplitRight => "エディタを右に分割", + L10nKey::EditorFocusLeftGroup => "左のエディタグループにフォーカス", + L10nKey::EditorFocusRightGroup => "右のエディタグループにフォーカス", + L10nKey::EditorSplitSameFile => "もう一方のグループで開いています — クリックしてここで編集", + L10nKey::CmdEditorGoToSymbol => "エディタ: シンボルへ移動…", + L10nKey::SearchHeadingReferences => "参照", + L10nKey::SearchSectionThisFile => "このファイル", + L10nKey::SearchSectionProject => "プロジェクト", + L10nKey::SearchHeadingDefinitions => "定義", + L10nKey::CmdEditorGoBack => "エディタ: 戻る", + L10nKey::CmdEditorGoForward => "エディタ: 進む", + L10nKey::CmdEditorSplitRight => "エディタ: 右に分割", L10nKey::EditorSaveAs => "名前を付けて保存", L10nKey::EditorSaveAsAction => "名前を付けて保存…", L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス", @@ -1261,6 +1329,21 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileDeletedOnDisk => "このファイルはディスク上で削除されました", L10nKey::EditorIndentSpaces => "スペース: {n}", L10nKey::EditorIndentTabs => "タブ幅: {n}", + L10nKey::LspGoToDefinition => "定義へ移動", + L10nKey::LspQuickFix => "クイックフィックス…", + L10nKey::LspFormatDocument => "ドキュメントのフォーマット", + L10nKey::LspRenameSymbol => "シンボル名を変更", + L10nKey::LspRenameSymbolAction => "シンボル名を変更…", + L10nKey::LspRenamePlaceholder => "{name} の新しい名前", + L10nKey::LspRenameFailed => "{name} の名前を変更できませんでした", + L10nKey::LspServerMissing => "{name} 未インストール", + L10nKey::LspServerStarting => "{name} 起動中…", + L10nKey::LspServerDown => "{name} 停止", + L10nKey::LspProblemsTooltip => "エラー {errors} 件、警告 {warnings} 件", + L10nKey::SearchTabLocations => "場所", + L10nKey::SearchPlaceholderLocations => "見つかった場所を絞り込む…", + L10nKey::SearchLocationsNone => "見つかりませんでした", + L10nKey::LspFindReferences => "すべての参照を検索", L10nKey::PanelInfoTitle => "情報", L10nKey::PanelChangesTitle => "変更", L10nKey::PanelScmTitle => "変更", @@ -1726,6 +1809,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2", L10nKey::CmdToggleDocumentPreview => "ドキュメント: Markdown プレビューを切り替え", L10nKey::CmdToggleDocumentWrap => "ドキュメント: 折り返しを切り替え", + L10nKey::CmdEditorTransformUppercase => "ドキュメント: 大文字に変換", + L10nKey::CmdEditorTransformLowercase => "ドキュメント: 小文字に変換", + L10nKey::CmdEditorTransformTitleCase => "ドキュメント: 単語の先頭を大文字に変換", + L10nKey::CmdEditorTrimTrailingWhitespace => "ドキュメント: 末尾の空白を削除", + L10nKey::CmdEditorJoinLines => "ドキュメント: 行を結合", + L10nKey::CmdEditorRemoveSurroundingBrackets => "ドキュメント: 外側の括弧を削除", L10nKey::CmdGitCommit => "Git: コミット", L10nKey::CmdGitStageAll => "Git: すべての変更をステージ", L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index bfb2d548..e4c4acad 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -174,10 +174,14 @@ l10n_keys! { SearchTabActions, SearchTabTerminals, SearchTabHosts, + SearchTabSymbols, SearchPlaceholderAll, SearchPlaceholderActions, SearchPlaceholderTerminals, SearchPlaceholderHosts, + SearchPlaceholderSymbols, + SearchSymbolsNone, + SearchSymbolsNoneHint, SearchTabFiles, SearchPlaceholderFiles, SearchFilesNoRoots, @@ -917,6 +921,7 @@ l10n_keys! { EditorNoFileOpen, EditorBackToTerminal, EditorLnCol, + EditorSelections, EditorEdit, EditorPreview, EditorWrapOn, @@ -935,8 +940,55 @@ l10n_keys! { EditorAlreadyOpen, EditorGoToLine, EditorGoToLineAction, + EditorGoToMatchingBracket, + EditorToggleComment, + EditorMoveLineUp, + EditorMoveLineDown, + EditorDuplicateLine, + EditorDeleteLine, EditorCopyRelativePath, + EditorGitNextChange, + EditorGitPrevChange, + EditorGitRevertChange, + EditorGitToggleGutter, + EditorGitPeekRevert, + EditorGitPeekSummary, + EditorGitPeekAddedOnly, + EditorGitPeekChange, + EditorGitPeekKeys, + EditorProblemsTitle, + EditorProblemsToggle, + EditorProblemsNone, + EditorProblemsMore, + SettingsEditor, + SettingsEditorGitGutter, + SettingsEditorGitGutterDesc, + SettingsEditorLsp, + SettingsEditorLspDesc, + SettingsEditorSoftWrap, + SettingsEditorSoftWrapDesc, + SettingsEditorMarkdownPreview, + SettingsEditorMarkdownPreviewDesc, + SettingsSearchEditorGitGutterKeywords, + SettingsSearchEditorLspKeywords, + SettingsSearchEditorSoftWrapKeywords, + SettingsSearchEditorMarkdownPreviewKeywords, EditorGoToLinePlaceholder, + EditorGoToSymbolAction, + EditorNavigateBack, + EditorNavigateForward, + EditorSplitRight, + EditorFocusLeftGroup, + EditorFocusRightGroup, + EditorSplitSameFile, + CmdEditorGoToSymbol, + SearchHeadingReferences, + SearchSectionThisFile, + SearchSectionProject, + SearchHeadingDefinitions, + CmdEditorGoBack, + CmdEditorGoForward, + CmdEditorSplitRight, EditorSaveAs, EditorSaveAsAction, EditorSaveAsPlaceholder, @@ -947,6 +999,21 @@ l10n_keys! { EditorFileDeletedOnDisk, EditorIndentSpaces, EditorIndentTabs, + LspGoToDefinition, + LspQuickFix, + LspFormatDocument, + LspRenameSymbol, + LspRenameSymbolAction, + LspRenamePlaceholder, + LspRenameFailed, + LspServerMissing, + LspServerStarting, + LspServerDown, + LspProblemsTooltip, + SearchTabLocations, + SearchPlaceholderLocations, + SearchLocationsNone, + LspFindReferences, PanelInfoTitle, PanelChangesTitle, PanelScmTitle, @@ -1431,6 +1498,12 @@ l10n_keys! { CmdDocumentWidthTwoThirds, CmdToggleDocumentPreview, CmdToggleDocumentWrap, + CmdEditorTransformUppercase, + CmdEditorTransformLowercase, + CmdEditorTransformTitleCase, + CmdEditorTrimTrailingWhitespace, + CmdEditorJoinLines, + CmdEditorRemoveSurroundingBrackets, CmdGitCommit, CmdGitStageAll, CmdGitUnstageAll, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 975aebc5..f523c724 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -110,10 +110,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SearchTabActions => "命令", L10nKey::SearchTabTerminals => "终端", L10nKey::SearchTabHosts => "主机", + L10nKey::SearchTabSymbols => "符号", L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…", L10nKey::SearchPlaceholderActions => "搜索命令…", L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…", L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…", + L10nKey::SearchPlaceholderSymbols => "跳转到本文件中的符号…", + L10nKey::SearchSymbolsNone => "此文件中没有符号", + L10nKey::SearchSymbolsNoneHint => { + "支持 Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、Shell 和 Markdown 的符号。" + } L10nKey::SearchTabFiles => "文件", L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…", L10nKey::SearchFilesNoRoots => "没有可搜索的项目", @@ -1099,6 +1105,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorNoFileOpen => "没有打开的文件", L10nKey::EditorBackToTerminal => "返回终端 (Esc)", L10nKey::EditorLnCol => "行 {line},列 {column}", + L10nKey::EditorSelections => "({n} 个选区)", L10nKey::EditorEdit => "编辑", L10nKey::EditorPreview => "预览", L10nKey::EditorWrapOn => "自动换行:开", @@ -1121,8 +1128,63 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorAlreadyOpen => "“{path}”已在编辑器中打开", L10nKey::EditorGoToLine => "跳转到行", L10nKey::EditorGoToLineAction => "跳转到行…", + L10nKey::EditorGoToMatchingBracket => "跳转到匹配的括号", + L10nKey::EditorToggleComment => "切换注释", + L10nKey::EditorMoveLineUp => "上移行", + L10nKey::EditorMoveLineDown => "下移行", + L10nKey::EditorDuplicateLine => "复制行", + L10nKey::EditorDeleteLine => "删除行", L10nKey::EditorCopyRelativePath => "复制相对路径", + L10nKey::EditorGitNextChange => "转到下一处更改", + L10nKey::EditorGitPrevChange => "转到上一处更改", + L10nKey::EditorGitRevertChange => "还原此处更改", + L10nKey::EditorGitToggleGutter => "切换 Git 更改标记", + L10nKey::EditorGitPeekRevert => "还原", + L10nKey::EditorGitPeekSummary => "与暂存版本相比 −{removed} +{added} 行", + L10nKey::EditorGitPeekAddedOnly => "这些是新增的行:暂存版本中此处没有内容。", + L10nKey::EditorGitPeekChange => "查看更改", + L10nKey::EditorGitPeekKeys => "回车还原 · Esc 关闭", + L10nKey::EditorProblemsTitle => "问题", + L10nKey::EditorProblemsToggle => "切换问题列表", + L10nKey::EditorProblemsNone => "打开的文件中没有问题。", + L10nKey::EditorProblemsMore => "……另有 {n} 项", + L10nKey::SettingsEditor => "编辑器", + L10nKey::SettingsEditorGitGutter => "Git 更改标记", + L10nKey::SettingsEditorGitGutterDesc => "在行号旁和滚动条上标出与暂存版本不同的行。", + L10nKey::SettingsEditorLsp => "语言服务器", + L10nKey::SettingsEditorLspDesc => { + "为支持的文件启动语言服务器,提供错误提示、补全和跳转到定义。仅限本机文件。" + } + L10nKey::SettingsEditorSoftWrap => "自动换行", + L10nKey::SettingsEditorSoftWrapDesc => { + "打开文件时启用自动换行。状态栏的换行按钮可单独切换某个文件。" + } + L10nKey::SettingsEditorMarkdownPreview => "以渲染形式打开 Markdown", + L10nKey::SettingsEditorMarkdownPreviewDesc => { + "打开 Markdown 文件时显示渲染后的预览而不是源码。" + } + L10nKey::SettingsSearchEditorGitGutterKeywords => { + "git 更改 标记 差异 暂存 修改 新增 删除 gutter diff" + } + L10nKey::SettingsSearchEditorLspKeywords => "lsp 语言服务器 诊断 错误 警告 补全 定义", + L10nKey::SettingsSearchEditorSoftWrapKeywords => "换行 自动换行 长行 wrap", + L10nKey::SettingsSearchEditorMarkdownPreviewKeywords => "markdown 预览 渲染 md", L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})", + L10nKey::EditorGoToSymbolAction => "跳转到编辑器中的符号…", + L10nKey::EditorNavigateBack => "后退", + L10nKey::EditorNavigateForward => "前进", + L10nKey::EditorSplitRight => "向右拆分编辑器", + L10nKey::EditorFocusLeftGroup => "聚焦左侧编辑器组", + L10nKey::EditorFocusRightGroup => "聚焦右侧编辑器组", + L10nKey::EditorSplitSameFile => "已在另一组中打开 — 点击在此编辑", + L10nKey::CmdEditorGoToSymbol => "编辑器:跳转到符号…", + L10nKey::SearchHeadingReferences => "引用", + L10nKey::SearchSectionThisFile => "本文件", + L10nKey::SearchSectionProject => "项目", + L10nKey::SearchHeadingDefinitions => "定义", + L10nKey::CmdEditorGoBack => "编辑器:后退", + L10nKey::CmdEditorGoForward => "编辑器:前进", + L10nKey::CmdEditorSplitRight => "编辑器:向右拆分", L10nKey::EditorSaveAs => "另存为", L10nKey::EditorSaveAsAction => "另存为…", L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径", @@ -1133,6 +1195,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorFileDeletedOnDisk => "此文件已在磁盘上被删除", L10nKey::EditorIndentSpaces => "空格:{n}", L10nKey::EditorIndentTabs => "Tab 宽度:{n}", + L10nKey::LspGoToDefinition => "转到定义", + L10nKey::LspQuickFix => "快速修复…", + L10nKey::LspFormatDocument => "格式化文档", + L10nKey::LspRenameSymbol => "重命名符号", + L10nKey::LspRenameSymbolAction => "重命名符号…", + L10nKey::LspRenamePlaceholder => "{name} 的新名称", + L10nKey::LspRenameFailed => "无法重命名 {name}", + L10nKey::LspServerMissing => "未安装 {name}", + L10nKey::LspServerStarting => "{name} 启动中…", + L10nKey::LspServerDown => "{name} 已停止", + L10nKey::LspProblemsTooltip => "{errors} 个错误,{warnings} 个警告", + L10nKey::SearchTabLocations => "位置", + L10nKey::SearchPlaceholderLocations => "筛选找到的位置…", + L10nKey::SearchLocationsNone => "未找到任何位置", + L10nKey::LspFindReferences => "查找所有引用", L10nKey::PanelInfoTitle => "信息", L10nKey::PanelChangesTitle => "更改", L10nKey::PanelScmTitle => "更改", @@ -1559,6 +1636,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽", L10nKey::CmdToggleDocumentPreview => "文档:切换 Markdown 预览", L10nKey::CmdToggleDocumentWrap => "文档:切换自动换行", + L10nKey::CmdEditorTransformUppercase => "文档:转换为大写", + L10nKey::CmdEditorTransformLowercase => "文档:转换为小写", + L10nKey::CmdEditorTransformTitleCase => "文档:转换为词首字母大写", + L10nKey::CmdEditorTrimTrailingWhitespace => "文档:删除行尾空白", + L10nKey::CmdEditorJoinLines => "文档:合并行", + L10nKey::CmdEditorRemoveSurroundingBrackets => "文档:删除外层括号", L10nKey::CmdGitCommit => "Git:提交", L10nKey::CmdGitStageAll => "Git:暂存全部更改", L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e28d7b91..ea2752a7 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -70,6 +70,89 @@ fn fixed_bindings() -> Vec { let search = Some(crate::ui::search::KEY_CONTEXT); bindings.push(KeyBinding::new("tab", SearchNextTab, search)); bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search)); + // The code editor's multi-cursor chords, laid down again after tty7's + // own table. gpui ranks a context-free binding as deep as the focused + // context, and breaks the tie by whichever was added last — so the copies + // gpui-component installs lose ⌘D to `SplitRight` and ⌘⌥↑/↓ to + // `FocusPaneUp`/`Down`, whose handlers sit on the workspace root and take + // the key before the editor is asked. Re-added here they win inside an + // `Input`; only the code editor handles them, so in any other text field + // gpui falls through to the pane binding next in line. + { + use gpui_component::input::{ + AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence, + }; + let input = Some("Input"); + bindings.push(KeyBinding::new("secondary-d", SelectNextOccurrence, input)); + bindings.push(KeyBinding::new( + "secondary-shift-l", + SelectAllOccurrences, + input, + )); + bindings.push(KeyBinding::new("secondary-alt-up", AddCursorAbove, input)); + bindings.push(KeyBinding::new("secondary-alt-down", AddCursorBelow, input)); + // ⌘K ⌘D: a chord whose first key is `ClearScrollback` on macOS. gpui + // drops a pending chord that ranks below a complete match, so this + // too has to come after tty7's table. Code editor only: in any other + // field ⌘K goes straight through, without waiting for a second key. + bindings.push(KeyBinding::new( + "secondary-k secondary-d", + gpui_component::input::SkipOccurrence, + Some(gpui_component::input::CODE_EDITOR_CONTEXT), + )); + } + // The language-server commands, on the keys VS Code taught everyone, and + // only inside a text field: F2 and F12 in a terminal belong to the + // program running there. The handlers act only for the code editor and + // let the key go on anywhere else. ⌘. / Ctrl+. needs nothing here: the + // editor binds it to the same code-action menu itself. + let input = Some("Input"); + bindings.push(KeyBinding::new("f12", EditorGoToDefinition, input)); + bindings.push(KeyBinding::new("f2", EditorRenameSymbol, input)); + bindings.push(KeyBinding::new("shift-alt-f", EditorFormatDocument, input)); + bindings.push(KeyBinding::new("shift-f12", EditorFindReferences, input)); + // The gutter's change markers, on VS Code's chords. Only in an `Input`: + // a function key bound for the whole window would never reach the shell, + // and the handlers let the key through when the code editor is not the + // field that has it. + { + let input = Some("Input"); + bindings.push(KeyBinding::new("alt-f5", EditorNextChange, input)); + bindings.push(KeyBinding::new("shift-alt-f5", EditorPrevChange, input)); + bindings.push(KeyBinding::new("alt-f3", EditorPeekChange, input)); + } + // The code editor's line commands, for the same tie: ⌘/ is the shortcut + // sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize and ⌥↑/⌥↓ pane focus off macOS, all + // context-free. Bound on `CodeEditor`, which only a multi-line code + // editor declares, so every other text field and the terminal keep the + // app's keys. + { + use gpui_component::input::{ + CODE_EDITOR_CONTEXT, CopyLineDown, CopyLineUp, DeleteLine, InsertLineAbove, + InsertLineBelow, MoveLineDown, MoveLineUp, MoveToMatchingBracket, SelectLine, + ToggleBlockComment, ToggleLineComment, + }; + let editor = Some(CODE_EDITOR_CONTEXT); + bindings.extend([ + KeyBinding::new("secondary-/", ToggleLineComment, editor), + KeyBinding::new("alt-shift-a", ToggleBlockComment, editor), + KeyBinding::new("alt-up", MoveLineUp, editor), + KeyBinding::new("alt-down", MoveLineDown, editor), + KeyBinding::new("alt-shift-up", CopyLineUp, editor), + KeyBinding::new("alt-shift-down", CopyLineDown, editor), + KeyBinding::new("secondary-shift-k", DeleteLine, editor), + KeyBinding::new("secondary-enter", InsertLineBelow, editor), + KeyBinding::new("secondary-shift-enter", InsertLineAbove, editor), + KeyBinding::new("secondary-l", SelectLine, editor), + KeyBinding::new("secondary-shift-\\", MoveToMatchingBracket, editor), + ]); + #[cfg(target_os = "macos")] + bindings.push(KeyBinding::new( + "ctrl-j", + gpui_component::input::JoinLines, + editor, + )); + } bindings } @@ -292,6 +375,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,8 +678,60 @@ 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"), + ), + // VS Code's Split Editor. Off macOS Ctrl+\ is the terminal's SIGQUIT, + // which it keeps: the binding lives in the editor's context only. + ("EditorSplitRight", "secondary-\\"), + // Moving between the two groups, on the chords that move between + // panes — at the edge of the editor, or with no split, they carry on + // to the panes. Off macOS those are Alt+←/→, which Back and Forward + // hold inside the editor. + ( + "EditorFocusLeftGroup", + per_platform("secondary-alt-left", "ctrl-alt-left"), + ), + ( + "EditorFocusRightGroup", + per_platform("secondary-alt-right", "ctrl-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 + // function key would take it from the shell — and bound instead in + // the editor's own context by `fixed_bindings`. + ("EditorGoToDefinition", ""), + ("EditorQuickFix", ""), + ("EditorRenameSymbol", ""), + ("EditorFormatDocument", ""), + // The editor's text commands. Unbound: none of them has a chord in + // VS Code either, except Join Lines, whose ⌃J is a fixed `CodeEditor` + // binding on macOS (in the terminal it is a line feed). + ("EditorTransformUppercase", ""), + ("EditorTransformLowercase", ""), + ("EditorTransformTitleCase", ""), + ("EditorTrimTrailingWhitespace", ""), + ("EditorJoinLines", ""), + ("EditorRemoveSurroundingBrackets", ""), + ("EditorFindReferences", ""), + // Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context + // bindings: a default on a function key would hide it from the shell. + ("EditorNextChange", ""), + ("EditorPrevChange", ""), + ("EditorRevertChange", ""), + ("ToggleEditorGitGutter", ""), + // Alt+F3 is a fixed `Input`-context binding, like Alt+F5. + ("EditorPeekChange", ""), + // VS Code's chord for its Problems panel. + ("ToggleEditorProblems", "secondary-shift-m"), ("OpenSshProfiles", ""), ("RestartSshSession", "secondary-shift-r"), ("Quit", per_platform("secondary-q", "secondary-shift-q")), @@ -878,10 +1018,99 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Terminal, t(L10nKey::EditorGoToLineAction).to_string(), ), + "EditorGoToDefinition" => ( + CommandGroup::Terminal, + t(L10nKey::LspGoToDefinition).to_string(), + ), + "EditorQuickFix" => (CommandGroup::Terminal, t(L10nKey::LspQuickFix).to_string()), + "EditorRenameSymbol" => ( + CommandGroup::Terminal, + t(L10nKey::LspRenameSymbolAction).to_string(), + ), + "EditorFormatDocument" => ( + CommandGroup::Terminal, + t(L10nKey::LspFormatDocument).to_string(), + ), + "EditorTransformUppercase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformUppercase).to_string(), + ), + "EditorTransformLowercase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformLowercase).to_string(), + ), + "EditorTransformTitleCase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformTitleCase).to_string(), + ), + "EditorTrimTrailingWhitespace" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTrimTrailingWhitespace).to_string(), + ), + "EditorJoinLines" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorJoinLines).to_string(), + ), + "EditorRemoveSurroundingBrackets" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorRemoveSurroundingBrackets).to_string(), + ), + "EditorFindReferences" => ( + CommandGroup::Terminal, + t(L10nKey::LspFindReferences).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(), + ), + "EditorSplitRight" => ( + CommandGroup::Terminal, + t(L10nKey::EditorSplitRight).to_string(), + ), + "EditorFocusLeftGroup" => ( + CommandGroup::Terminal, + t(L10nKey::EditorFocusLeftGroup).to_string(), + ), + "EditorFocusRightGroup" => ( + CommandGroup::Terminal, + t(L10nKey::EditorFocusRightGroup).to_string(), + ), "EditorNewFile" => ( CommandGroup::Terminal, t(L10nKey::EditorNewFile).to_string(), ), + "EditorNextChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitNextChange).to_string(), + ), + "EditorPrevChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitPrevChange).to_string(), + ), + "EditorRevertChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitRevertChange).to_string(), + ), + "ToggleEditorGitGutter" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitToggleGutter).to_string(), + ), + "EditorPeekChange" => ( + CommandGroup::Terminal, + t(L10nKey::EditorGitPeekChange).to_string(), + ), + "ToggleEditorProblems" => ( + CommandGroup::Terminal, + t(L10nKey::EditorProblemsToggle).to_string(), + ), "OpenSshProfiles" => ( CommandGroup::Ssh, t(L10nKey::CmdSshManageProfiles).to_string(), @@ -1440,6 +1669,12 @@ 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" + | "EditorSplitRight" + | "EditorFocusLeftGroup" + | "EditorFocusRightGroup" => Some(crate::ui::code_editor::NAV_KEY_CONTEXT), _ => None, } } @@ -1572,7 +1807,58 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), "EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None), "EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None), + "EditorGoToSymbol" => KeyBinding::new( + keystroke, + EditorGoToSymbol, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorNavigateBack" => KeyBinding::new( + keystroke, + EditorNavigateBack, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorNavigateForward" => KeyBinding::new( + keystroke, + EditorNavigateForward, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorSplitRight" => KeyBinding::new( + keystroke, + EditorSplitRight, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorFocusLeftGroup" => KeyBinding::new( + keystroke, + EditorFocusLeftGroup, + Some(crate::ui::code_editor::NAV_KEY_CONTEXT), + ), + "EditorFocusRightGroup" => KeyBinding::new( + keystroke, + EditorFocusRightGroup, + 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), + "EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None), + "EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None), + "EditorTransformUppercase" => KeyBinding::new(keystroke, EditorTransformUppercase, None), + "EditorTransformLowercase" => KeyBinding::new(keystroke, EditorTransformLowercase, None), + "EditorTransformTitleCase" => KeyBinding::new(keystroke, EditorTransformTitleCase, None), + "EditorTrimTrailingWhitespace" => { + KeyBinding::new(keystroke, EditorTrimTrailingWhitespace, None) + } + "EditorJoinLines" => KeyBinding::new(keystroke, EditorJoinLines, None), + "EditorRemoveSurroundingBrackets" => { + KeyBinding::new(keystroke, EditorRemoveSurroundingBrackets, None) + } + "EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None), + "EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None), + "EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None), + "EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None), + "ToggleEditorGitGutter" => KeyBinding::new(keystroke, ToggleEditorGitGutter, None), + "EditorPeekChange" => KeyBinding::new(keystroke, EditorPeekChange, None), + "ToggleEditorProblems" => KeyBinding::new(keystroke, ToggleEditorProblems, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), "Quit" => KeyBinding::new(keystroke, Quit, None), @@ -2595,6 +2881,60 @@ mod gpui_tests { .collect() } + /// The code editor's line commands sit on chords the app also spends — + /// ⌘/ is the shortcut sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize, ⌥↑/⌥↓ pane + /// focus off macOS. `fixed_bindings` re-adds them on the `CodeEditor` + /// context the editor declares beside `Input`, after the app's table, so + /// inside the editor they win and everywhere else nothing changes. + #[gpui::test] + fn the_code_editor_chords_win_inside_the_editor(cx: &mut TestAppContext) { + cx.update(|cx| { + running_on_json(cx, "{}"); + let first = |keys: &str, stack: &[&str]| { + let input: Vec = keys + .split(' ') + .map(|k| Keystroke::parse(k).expect("the typed keystroke parses")) + .collect(); + let stack: Vec = stack + .iter() + .map(|c| gpui::KeyContext::parse(c).expect("the context parses")) + .collect(); + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &stack) + .0 + .first() + .map(|b| b.action().name()) + }; + let editor = ["Workspace", "Input CodeEditor"]; + let join_lines = cfg!(target_os = "macos").then_some(("ctrl-j", "input::JoinLines")); + for (keys, action) in join_lines.into_iter().chain([ + ("secondary-/", "input::ToggleLineComment"), + ("alt-up", "input::MoveLineUp"), + ("alt-down", "input::MoveLineDown"), + ("alt-shift-up", "input::CopyLineUp"), + ("alt-shift-down", "input::CopyLineDown"), + ("secondary-shift-k", "input::DeleteLine"), + ("secondary-enter", "input::InsertLineBelow"), + ("secondary-shift-enter", "input::InsertLineAbove"), + ("secondary-l", "input::SelectLine"), + ("secondary-shift-\\", "input::MoveToMatchingBracket"), + ]) { + assert_eq!(first(keys, &editor), Some(action), "{keys} in the editor"); + assert_ne!( + first(keys, &["Workspace", "Input"]), + Some(action), + "{keys} in a plain text field" + ); + assert_ne!( + first(keys, &["Workspace", "Terminal"]), + Some(action), + "{keys} in a terminal" + ); + } + }); + } + #[gpui::test] fn a_chord_added_in_config_keeps_the_default_one(cx: &mut TestAppContext) { use gpui::Action as _; @@ -2833,4 +3173,174 @@ mod gpui_tests { assert_eq!(backspace(cx), inherited, "nor may a rebind"); }); } + + /// The gutter's next / previous change chords live only in an `Input`: + /// the editor gets them, a terminal never loses a function key to them. + #[gpui::test] + fn editor_change_chords_are_bound_only_in_an_input(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json(cx, "{}"); + let hits = |keys: &str, contexts: &[&str]| -> Vec<&'static str> { + let input = [Keystroke::parse(keys).expect("the keystroke parses")]; + let context: Vec<_> = contexts + .iter() + .map(|c| gpui::KeyContext::parse(c).expect("the context parses")) + .collect(); + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &context) + .0 + .iter() + .map(|b| b.action().name()) + .collect() + }; + assert_eq!( + hits("alt-f5", &["Workspace", "Input"]).first(), + Some(&EditorNextChange::name_for_type()) + ); + assert_eq!( + hits("shift-alt-f5", &["Workspace", "Input"]).first(), + Some(&EditorPrevChange::name_for_type()) + ); + assert_eq!( + hits("alt-f3", &["Workspace", "Input"]).first(), + Some(&EditorPeekChange::name_for_type()) + ); + assert!(hits("alt-f5", &["Workspace"]).is_empty()); + }); + } + + /// The code editor's multi-cursor chords sit on keys tty7 ships for panes + /// (⌘D split, ⌘⌥↑/↓ focus). `fixed_bindings` re-adds them after the + /// pane table so they rank first inside an `Input`; any other text field + /// has no handler for them, and gpui falls through to the pane binding + /// listed after. A terminal never sees them. + #[gpui::test] + fn editor_multi_cursor_chords_outrank_the_pane_bindings(cx: &mut TestAppContext) { + use gpui::Action as _; + use gpui_component::input::{ + AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence, + }; + 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 cases = [ + ("secondary-d", SelectNextOccurrence::name_for_type()), + ("secondary-shift-l", SelectAllOccurrences::name_for_type()), + ("secondary-alt-up", AddCursorAbove::name_for_type()), + ("secondary-alt-down", AddCursorBelow::name_for_type()), + ]; + for (keys, editor_action) in cases { + let hits = in_input(keys); + assert_eq!(hits.first(), Some(&editor_action), "{keys} in the editor"); + } + if cfg!(target_os = "macos") { + assert!(in_input("secondary-d").contains(&SplitRight::name_for_type())); + assert_eq!( + fired(cx, "secondary-d").first(), + Some(&SplitRight::name_for_type()), + "a terminal still splits on ⌘D" + ); + } + + // ⌘K ⌘D in the code editor skips to the next occurrence, and ⌘K + // there waits for the chord's second key. + let editor = [ + gpui::KeyContext::parse("Workspace").expect("the context parses"), + gpui::KeyContext::parse("Input CodeEditor").expect("the context parses"), + ]; + let keymap = cx.key_bindings(); + let keymap = keymap.borrow(); + let chord = [ + Keystroke::parse("secondary-k").unwrap(), + Keystroke::parse("secondary-d").unwrap(), + ]; + let (hits, _) = keymap.bindings_for_input(&chord, &editor); + assert_eq!( + hits.first().map(|b| b.action().name()), + Some(gpui_component::input::SkipOccurrence::name_for_type()) + ); + let (_, pending) = + keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &editor); + assert!(pending, "⌘K waits for the chord in the code editor"); + // A plain text field isn't held up. + let field = [ + gpui::KeyContext::parse("Workspace").expect("the context parses"), + gpui::KeyContext::parse("Input").expect("the context parses"), + ]; + let (_, pending) = + keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &field); + assert!(!pending, "⌘K goes straight through in other fields"); + }); + } + + /// 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()), + ("secondary-\\", EditorSplitRight::name_for_type()), + ( + per_platform("secondary-alt-left", "ctrl-alt-left"), + EditorFocusLeftGroup::name_for_type(), + ), + ( + per_platform("secondary-alt-right", "ctrl-alt-right"), + EditorFocusRightGroup::name_for_type(), + ), + ] { + assert_eq!( + in_input(keys).first(), + Some(&action), + "{keys} in the editor" + ); + assert!(!fired(cx, keys).contains(&action), "{keys} in a terminal"); + } + assert_eq!( + fired(cx, "secondary-shift-o").first(), + Some(&ToggleSwitcher::name_for_type()), + "a terminal still opens the switcher" + ); + }); + } } diff --git a/src/ui/lsp/client.rs b/src/ui/lsp/client.rs new file mode 100644 index 00000000..f409a7e0 --- /dev/null +++ b/src/ui/lsp/client.rs @@ -0,0 +1,576 @@ +//! One running language server: its process, the threads that pump its +//! pipes, and the request/response bookkeeping on top of [`super::rpc`]. +//! +//! Each pipe gets a dedicated thread rather than a task on gpui's background +//! executor: a read that blocks until the server says something would park +//! one of the executor's few workers for the life of the server. The threads +//! end on their own when the pipes close, which killing the process does. +//! +//! What the server sends unprompted — diagnostics, its own requests, the pipe +//! closing — arrives on the [`ServerEvent`] channel, for the store to handle +//! on the main thread. + +use std::collections::HashMap; +use std::io::{BufRead as _, BufReader, Read, Write}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use anyhow::{Context as _, anyhow}; +use serde_json::Value; + +use super::rpc::{self, FrameReader, Incoming, RequestId, ResponseError}; + +type Reply = smol::channel::Sender>; +/// Requests waiting for an answer. `None` once the server's output has +/// closed: nothing will ever be answered again, and a request made after +/// that fails at once instead of waiting forever. +type Pending = Arc>>>; + +/// How long an ordinary request waits for its answer. Everything the editor +/// asks is interactive — someone is waiting on a completion, a jump, a +/// rename — and a server that has not answered in this long is stuck. +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// `initialize` may index before answering; give it longer. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Something the server did that nobody asked for. +#[derive(Debug)] +pub(crate) enum ServerEvent { + Notification { + method: String, + params: Value, + }, + Request { + id: Value, + method: String, + params: Value, + }, + /// The server's output closed: it exited, crashed or was killed. + Exited, +} + +pub(crate) struct LspClient { + next_id: AtomicI64, + pending: Pending, + out: std::sync::mpsc::Sender>, + /// Messages held back until the server has answered `initialize`, which + /// the protocol requires to be the first thing it sees. `None` once the + /// gate is open. + gate: Mutex>>>, + child: Mutex>, +} + +impl LspClient { + /// Starts `program` in `root` and wires up its pipes. + pub(crate) fn spawn( + name: &str, + program: &Path, + args: &[&str], + root: &Path, + ) -> anyhow::Result<(Arc, smol::channel::Receiver)> { + let mut command = Command::new(program); + command + .args(args) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // CREATE_NO_WINDOW: a server is a pipe, not a console. + command.creation_flags(0x0800_0000); + } + let mut child = command + .spawn() + .with_context(|| format!("starting {}", program.display()))?; + let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; + let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; + if let Some(stderr) = child.stderr.take() { + let name = name.to_owned(); + // A server that fills its stderr pipe with nobody reading blocks + // on the next write — and stops answering. Drained into the log. + std::thread::Builder::new() + .name("tty7-lsp-stderr".into()) + .spawn(move || { + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + log::debug!("lsp {name} stderr: {line}"); + } + }) + .ok(); + } + let (client, events) = Self::from_streams(name, stdout, stdin)?; + *client.child.lock().unwrap() = Some(child); + Ok((client, events)) + } + + /// The client over any pair of pipes — a real process's, or a test's. + pub(crate) fn from_streams( + name: &str, + reader: impl Read + Send + 'static, + mut writer: impl Write + Send + 'static, + ) -> anyhow::Result<(Arc, smol::channel::Receiver)> { + let (out, out_rx) = std::sync::mpsc::channel::>(); + std::thread::Builder::new() + .name("tty7-lsp-write".into()) + .spawn(move || { + // Ends when every sender is gone — the client dropped — or + // the pipe breaks. Either way stdin closes behind it, which + // is the last word a server needs to exit. + while let Ok(bytes) = out_rx.recv() { + if writer + .write_all(&bytes) + .and_then(|_| writer.flush()) + .is_err() + { + break; + } + } + })?; + + let pending: Pending = Arc::new(Mutex::new(Some(HashMap::new()))); + let (events_tx, events) = smol::channel::unbounded(); + { + let pending = pending.clone(); + let name = name.to_owned(); + let mut reader = reader; + std::thread::Builder::new() + .name("tty7-lsp-read".into()) + .spawn(move || { + let mut frames = FrameReader::default(); + let mut chunk = vec![0u8; 64 * 1024]; + loop { + let n = match reader.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + frames.push(&chunk[..n]); + while let Some(body) = frames.next_frame() { + let Some(message) = rpc::classify(&body) else { + log::debug!("lsp {name}: ignoring an unreadable message"); + continue; + }; + let event = match message { + Incoming::Response { id, result } => { + let reply = pending + .lock() + .unwrap() + .as_mut() + .and_then(|waiting| waiting.remove(&id)); + if let Some(reply) = reply { + let _ = reply.try_send(result); + } + continue; + } + Incoming::Request { id, method, params } => { + ServerEvent::Request { id, method, params } + } + Incoming::Notification { method, params } => { + ServerEvent::Notification { method, params } + } + }; + if events_tx.send_blocking(event).is_err() { + // Nobody is listening any more; keep reading + // so the server never blocks on a full pipe. + } + } + } + // Every request still waiting will never be answered: + // dropping the senders fails them rather than leaving + // them hanging. + pending.lock().unwrap().take(); + let _ = events_tx.send_blocking(ServerEvent::Exited); + })?; + } + + Ok(( + Arc::new(Self { + next_id: AtomicI64::new(1), + pending, + out, + gate: Mutex::new(Some(Vec::new())), + child: Mutex::new(None), + }), + events, + )) + } + + fn send(&self, bytes: Vec, gated: bool) { + let mut gate = self.gate.lock().unwrap(); + match gate.as_mut() { + Some(held) if gated => held.push(bytes), + _ => { + let _ = self.out.send(bytes); + } + } + } + + /// Lets through everything held back for `initialize`, in the order it + /// was sent. Held under the gate's lock, so nothing sent meanwhile can + /// overtake it. + pub(crate) fn open_gate(&self) { + let mut gate = self.gate.lock().unwrap(); + for bytes in gate.take().unwrap_or_default() { + let _ = self.out.send(bytes); + } + } + + /// Sends a request, returning the server's answer. Dropping the future + /// before it resolves tells the server to stop working on it. + pub(crate) fn request_raw( + self: &Arc, + method: &str, + params: Value, + ) -> impl std::future::Future> + use<> { + self.request_inner(method, params, true, REQUEST_TIMEOUT) + } + + /// [`Self::request_raw`] with its own time limit. + pub(crate) fn request_raw_within( + self: &Arc, + method: &str, + params: Value, + timeout: Duration, + ) -> impl std::future::Future> + use<> { + self.request_inner(method, params, true, timeout) + } + + /// `initialize`, which is the one request that goes past the gate. + pub(crate) fn initialize( + self: &Arc, + params: Value, + ) -> impl std::future::Future> + use<> { + self.request_inner("initialize", params, false, INITIALIZE_TIMEOUT) + } + + fn request_inner( + self: &Arc, + method: &str, + params: Value, + gated: bool, + timeout: Duration, + ) -> impl std::future::Future> + use<> { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = smol::channel::bounded(1); + // Checked and registered under one lock: the reader closes the map + // under the same lock, so a request either makes it in before the + // server went away (and is failed with the rest) or sees it gone. + let registered = match self.pending.lock().unwrap().as_mut() { + Some(waiting) => { + waiting.insert(id, tx); + true + } + None => false, + }; + if registered { + self.send(rpc::encode(&rpc::request(id, method, params)), gated); + } + let guard = CancelOnDrop { + client: Arc::downgrade(self), + id, + done: !registered, + }; + let method = method.to_owned(); + async move { + // Bound whole, not just the field written below: a closure + // capturing only `guard.done` would drop the guard — and cancel + // the request — before it was ever sent an answer. + let mut guard = guard; + if !registered { + return Err(anyhow!("{method}: the language server has exited")); + } + let answer = smol::future::or(async { Some(rx.recv().await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await; + // A timeout leaves `done` unset, so dropping the guard tells the + // server to stop working on it. + let Some(answer) = answer else { + return Err(anyhow!("{method}: no answer in {}s", timeout.as_secs_f32())); + }; + guard.done = true; + match answer { + Ok(Ok(value)) => Ok(value), + Ok(Err(e)) => Err(anyhow::Error::new(e).context(method)), + Err(_) => Err(anyhow!("{method}: the language server went away")), + } + } + } + + /// A typed request. + pub(crate) fn request( + self: &Arc, + params: R::Params, + ) -> impl std::future::Future> + use { + let params = serde_json::to_value(params).unwrap_or(Value::Null); + let answer = self.request_raw(R::METHOD, params); + async move { Ok(serde_json::from_value(answer.await?)?) } + } + + pub(crate) fn notify_raw(&self, method: &str, params: Value) { + self.send(rpc::encode(&rpc::notification(method, params)), true); + } + + pub(crate) fn notify(&self, params: N::Params) { + let params = serde_json::to_value(params).unwrap_or(Value::Null); + self.notify_raw(N::METHOD, params); + } + + /// Answers a request the server made. + pub(crate) fn respond(&self, id: Value, result: Result) { + self.send(rpc::encode(&rpc::response(id, result)), false); + } + + /// Kills the process, if there is one and it is still running, and reaps + /// it so it does not linger as a zombie. + pub(crate) fn kill(&self) { + if let Some(mut child) = self.child.lock().unwrap().take() { + if matches!(child.try_wait(), Ok(None)) { + let _ = child.kill(); + } + let _ = child.wait(); + } + } + + /// Whether the process has already exited on its own. + pub(crate) fn has_exited(&self) -> bool { + match self.child.lock().unwrap().as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => true, + } + } + + /// Reaps the process if it has exited, without killing it. + pub(crate) fn reap_if_exited(&self) -> bool { + let mut slot = self.child.lock().unwrap(); + let exited = match slot.as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => return true, + }; + if exited { + slot.take(); + } + exited + } +} + +impl Drop for LspClient { + fn drop(&mut self) { + // The last line of defence against a server outliving tty7's + // interest in it. A clean shutdown has already reaped it. + self.kill(); + } +} + +struct CancelOnDrop { + client: Weak, + id: RequestId, + done: bool, +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if self.done { + return; + } + let Some(client) = self.client.upgrade() else { + return; + }; + let removed = client + .pending + .lock() + .unwrap() + .as_mut() + .and_then(|waiting| waiting.remove(&self.id)); + if removed.is_some() { + client.notify_raw("$/cancelRequest", serde_json::json!({ "id": self.id })); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A server on the other end of two pipes, driven by the test. + struct FakeServer { + frames: FrameReader, + from_client: std::io::PipeReader, + to_client: std::io::PipeWriter, + } + + impl FakeServer { + fn next(&mut self) -> Value { + let mut chunk = [0u8; 256]; + loop { + if let Some(body) = self.frames.next_frame() { + return serde_json::from_slice(&body).unwrap(); + } + let n = self.from_client.read(&mut chunk).unwrap(); + assert!(n > 0, "the client closed its end"); + self.frames.push(&chunk[..n]); + } + } + + fn send(&mut self, value: Value) { + // Written in two pieces, so the client's reader has to put a + // frame back together. + let bytes = rpc::encode(&value); + let (a, b) = bytes.split_at(bytes.len() / 2); + self.to_client.write_all(a).unwrap(); + self.to_client.flush().unwrap(); + self.to_client.write_all(b).unwrap(); + self.to_client.flush().unwrap(); + } + } + + fn connect() -> ( + Arc, + smol::channel::Receiver, + FakeServer, + ) { + let (client_reads, server_writes) = std::io::pipe().unwrap(); + let (server_reads, client_writes) = std::io::pipe().unwrap(); + let (client, events) = + LspClient::from_streams("fake", client_reads, client_writes).unwrap(); + ( + client, + events, + FakeServer { + frames: FrameReader::default(), + from_client: server_reads, + to_client: server_writes, + }, + ) + } + + #[test] + fn nothing_but_initialize_reaches_the_server_until_the_gate_opens() { + let (client, _events, mut server) = connect(); + client.notify_raw("textDocument/didOpen", json!({ "n": 1 })); + let init = client.initialize(json!({})); + let first = server.next(); + assert_eq!(first["method"], "initialize"); + server + .send(json!({ "jsonrpc": "2.0", "id": first["id"], "result": { "capabilities": {} } })); + let answer = smol::block_on(init).unwrap(); + assert_eq!(answer, json!({ "capabilities": {} })); + client.notify_raw("initialized", json!({})); + client.open_gate(); + // Held messages come out first, in the order they were sent. + assert_eq!(server.next()["method"], "textDocument/didOpen"); + assert_eq!(server.next()["method"], "initialized"); + } + + #[test] + fn answers_find_their_way_back_to_the_request_that_asked() { + let (client, _events, mut server) = connect(); + client.open_gate(); + let a = client.request_raw("a", json!(1)); + let b = client.request_raw("b", json!(2)); + let ra = server.next(); + let rb = server.next(); + // Answered out of order. + server.send(json!({ "jsonrpc": "2.0", "id": rb["id"], "result": "B" })); + server.send(json!({ + "jsonrpc": "2.0", + "id": ra["id"], + "error": { "code": -32800, "message": "cancelled" }, + })); + assert_eq!(smol::block_on(b).unwrap(), json!("B")); + let err = smol::block_on(a).unwrap_err(); + assert!(format!("{err:#}").contains("cancelled"), "{err:#}"); + } + + #[test] + fn server_requests_and_notifications_arrive_as_events_and_can_be_answered() { + let (client, events, mut server) = connect(); + client.open_gate(); + server.send(json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { "uri": "file:///a.rs", "diagnostics": [] }, + })); + server.send(json!({ + "jsonrpc": "2.0", + "id": "cfg-1", + "method": "workspace/configuration", + "params": { "items": [{}] }, + })); + match smol::block_on(events.recv()).unwrap() { + ServerEvent::Notification { method, .. } => { + assert_eq!(method, "textDocument/publishDiagnostics") + } + other => panic!("{other:?}"), + } + let ServerEvent::Request { id, method, .. } = smol::block_on(events.recv()).unwrap() else { + panic!("expected a request"); + }; + assert_eq!(method, "workspace/configuration"); + client.respond(id, Ok(json!([null]))); + let reply = server.next(); + assert_eq!(reply["id"], "cfg-1"); + assert_eq!(reply["result"], json!([null])); + } + + #[test] + fn a_closed_pipe_fails_waiting_requests_and_says_the_server_exited() { + let (client, events, server) = connect(); + client.open_gate(); + let waiting = client.request_raw("slow", Value::Null); + drop(server); + assert!(smol::block_on(waiting).is_err()); + assert!(matches!( + smol::block_on(events.recv()).unwrap(), + ServerEvent::Exited + )); + } + + #[test] + fn a_request_after_the_server_exited_fails_at_once() { + let (client, events, server) = connect(); + client.open_gate(); + drop(server); + assert!(matches!( + smol::block_on(events.recv()).unwrap(), + ServerEvent::Exited + )); + // No timeout involved: a limit of an hour would hang the test. + let late = + client.request_raw_within("textDocument/hover", Value::Null, Duration::from_secs(3600)); + let err = smol::block_on(late).unwrap_err(); + assert!(format!("{err:#}").contains("exited"), "{err:#}"); + } + + #[test] + fn a_request_nobody_answers_times_out_and_is_cancelled() { + let (client, _events, mut server) = connect(); + client.open_gate(); + let asked = client.request_raw_within("slow", Value::Null, Duration::from_millis(50)); + let sent = server.next(); + let err = smol::block_on(asked).unwrap_err(); + assert!(format!("{err:#}").contains("no answer"), "{err:#}"); + let cancel = server.next(); + assert_eq!(cancel["method"], "$/cancelRequest"); + assert_eq!(cancel["params"]["id"], sent["id"]); + } + + #[test] + fn dropping_an_unanswered_request_cancels_it() { + let (client, _events, mut server) = connect(); + client.open_gate(); + let asked = client.request_raw("textDocument/completion", Value::Null); + let sent = server.next(); + drop(asked); + let cancel = server.next(); + assert_eq!(cancel["method"], "$/cancelRequest"); + assert_eq!(cancel["params"]["id"], sent["id"]); + } +} diff --git a/src/ui/lsp/convert.rs b/src/ui/lsp/convert.rs new file mode 100644 index 00000000..e32ccdc6 --- /dev/null +++ b/src/ui/lsp/convert.rs @@ -0,0 +1,445 @@ +//! Translating between the positions a language server speaks and the ones +//! the editor does. +//! +//! A server counts columns in UTF-16 code units unless it negotiated +//! otherwise. The editor keeps byte offsets into its rope, and +//! gpui-component's own LSP plumbing (`RopeExt::position_to_offset`, the +//! diagnostic set, the completion menu) reads a [`Position`]'s `character` as +//! a count of `char`s. So everything crossing the boundary is converted here, +//! once: server → editor positions before they reach gpui-component, editor +//! byte offsets → server positions before a request goes out. +//! +//! The three agree on ASCII and part ways on everything else: `é` is one +//! char and one UTF-16 unit but two bytes; `🎉` is one char, two UTF-16 +//! units and four bytes. + +use std::ops::Range; + +use gpui_component::{Rope, RopeExt as _}; +use lsp_types::{Position, TextEdit}; + +/// What a server's `character` counts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Encoding { + Utf8, + #[default] + Utf16, + Utf32, +} + +impl Encoding { + pub(crate) fn from_kind(kind: Option<&lsp_types::PositionEncodingKind>) -> Self { + match kind.map(|k| k.as_str()) { + Some("utf-8") => Self::Utf8, + Some("utf-32") => Self::Utf32, + _ => Self::Utf16, + } + } + + fn units(self, c: char) -> usize { + match self { + Self::Utf8 => c.len_utf8(), + Self::Utf16 => c.len_utf16(), + Self::Utf32 => 1, + } + } +} + +/// The server's column for the byte offset `byte` into `line`. +pub(crate) fn column_of_byte(line: &str, byte: usize, enc: Encoding) -> u32 { + line.char_indices() + .take_while(|(i, _)| *i < byte) + .map(|(_, c)| enc.units(c)) + .sum::() as u32 +} + +/// The byte offset into `line` of the server's column `units`. A column past +/// the end of the line lands at its end; one inside a character (half a +/// surrogate pair) lands before that character. +pub(crate) fn byte_of_column(line: &str, units: u32, enc: Encoding) -> usize { + let units = units as usize; + let mut seen = 0; + for (i, c) in line.char_indices() { + let next = seen + enc.units(c); + if next > units { + return i; + } + seen = next; + } + line.len() +} + +fn line_text(rope: &Rope, row: usize) -> String { + rope.slice_line(row).to_string() +} + +/// A byte offset in the editor → a server position. +pub(crate) fn offset_to_lsp(rope: &Rope, offset: usize, enc: Encoding) -> Position { + let point = rope.offset_to_point(offset); + let line = line_text(rope, point.row); + Position::new(point.row as u32, column_of_byte(&line, point.column, enc)) +} + +/// A server position → a byte offset in the editor, clamped into the text. +#[cfg(test)] +pub(crate) fn lsp_to_offset(rope: &Rope, pos: Position, enc: Encoding) -> usize { + let row = pos.line as usize; + if row >= rope.lines_len() { + return rope.len(); + } + let line = line_text(rope, row); + rope.line_start_offset(row) + byte_of_column(&line, pos.character, enc) +} + +/// A server position → the char-counted position gpui-component expects. +pub(crate) fn lsp_to_editor(rope: &Rope, pos: Position, enc: Encoding) -> Position { + if enc == Encoding::Utf32 { + return pos; + } + let row = pos.line as usize; + if row >= rope.lines_len() { + return pos; + } + let line = line_text(rope, row); + let byte = byte_of_column(&line, pos.character, enc); + Position::new(pos.line, line[..byte].chars().count() as u32) +} + +pub(crate) fn range_to_editor( + rope: &Rope, + range: lsp_types::Range, + enc: Encoding, +) -> lsp_types::Range { + lsp_types::Range::new( + lsp_to_editor(rope, range.start, enc), + lsp_to_editor(rope, range.end, enc), + ) +} + +/// A server's diagnostics with their ranges in editor columns, ready for +/// `DiagnosticSet::extend`. +pub(crate) fn diagnostics_to_editor( + rope: &Rope, + diagnostics: &[lsp_types::Diagnostic], + enc: Encoding, +) -> Vec { + diagnostics + .iter() + .map(|d| lsp_types::Diagnostic { + range: range_to_editor(rope, d.range, enc), + ..d.clone() + }) + .collect() +} + +/// How many errors and warnings a set of diagnostics holds. A diagnostic +/// without a severity counts as neither — the client decides, and the +/// status bar only claims what the server did. +pub(crate) fn count_problems(diagnostics: &[lsp_types::Diagnostic]) -> (usize, usize) { + diagnostics + .iter() + .fold((0, 0), |(e, w), d| match d.severity { + Some(lsp_types::DiagnosticSeverity::ERROR) => (e + 1, w), + Some(lsp_types::DiagnosticSeverity::WARNING) => (e, w + 1), + _ => (e, w), + }) +} + +/// `edits` in the order they can be applied one at a time. +/// +/// Every edit in a `TextEdit[]` is written against the original text, so +/// applying the one furthest into the document first keeps the positions of +/// the rest valid. Edits that start at the same place are inserted in the +/// order the server listed them, which going backwards means reversing them. +pub(crate) fn in_application_order(edits: &[TextEdit]) -> Vec { + let mut indexed: Vec<(usize, &TextEdit)> = edits.iter().enumerate().collect(); + indexed.sort_by(|(ia, a), (ib, b)| { + let ka = (a.range.start.line, a.range.start.character, *ia); + let kb = (b.range.start.line, b.range.start.character, *ib); + kb.cmp(&ka) + }); + indexed.into_iter().map(|(_, e)| e.clone()).collect() +} + +/// `edits` with their ranges in editor columns and in application order — +/// what `InputState::apply_lsp_edits`, which applies them as given, needs. +pub(crate) fn edits_to_editor(rope: &Rope, edits: &[TextEdit], enc: Encoding) -> Vec { + in_application_order(edits) + .into_iter() + .map(|e| TextEdit { + range: range_to_editor(rope, e.range, enc), + new_text: e.new_text, + }) + .collect() +} + +/// `text` with `edits` applied — for a file a rename touches that has no +/// buffer open. +pub(crate) fn apply_edits(text: &str, edits: &[TextEdit], enc: Encoding) -> String { + let starts = line_starts(text); + let offset = |pos: Position| -> usize { + let Some(&start) = starts.get(pos.line as usize) else { + return text.len(); + }; + let end = starts + .get(pos.line as usize + 1) + .map(|&next| next - 1) + .unwrap_or(text.len()); + let line = &text[start..end]; + let line = line.strip_suffix('\r').unwrap_or(line); + start + byte_of_column(line, pos.character, enc) + }; + let mut out = text.to_owned(); + for edit in in_application_order(edits) { + let start = offset(edit.range.start); + let end = offset(edit.range.end).max(start); + out.replace_range(start..end, &edit.new_text); + } + out +} + +fn line_starts(text: &str) -> Vec { + std::iter::once(0) + .chain(text.match_indices('\n').map(|(i, _)| i + 1)) + .collect() +} + +/// The byte range an editor range covers, for a request about a selection. +pub(crate) fn offsets_to_lsp_range( + rope: &Rope, + range: Range, + enc: Encoding, +) -> lsp_types::Range { + lsp_types::Range::new( + offset_to_lsp(rope, range.start, enc), + offset_to_lsp(rope, range.end, enc), + ) +} + +/// A completion's snippet as the plain text it would expand to with every +/// placeholder left at its default. The editor has no snippet engine; this +/// is what it inserts instead of `${1:x}` litter. +pub(crate) fn snippet_to_plain(snippet: &str) -> String { + let mut out = String::with_capacity(snippet.len()); + let mut chars = snippet.chars().peekable(); + expand(&mut chars, &mut out, false); + out +} + +/// Expands until the end, or until the `}` closing the placeholder being +/// expanded when `nested`. +fn expand(chars: &mut std::iter::Peekable>, out: &mut String, nested: bool) { + while let Some(c) = chars.next() { + match c { + '\\' => { + if let Some(next) = chars.next() { + out.push(next); + } + } + '}' if nested => return, + '$' => match chars.peek().copied() { + Some(d) if d.is_ascii_digit() => { + while chars.peek().is_some_and(|d| d.is_ascii_digit()) { + chars.next(); + } + } + Some('{') => { + chars.next(); + // `${1:default}`, `${1|one,two|}`, `${1}` or `${name}`. + while chars + .peek() + .is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_') + { + chars.next(); + } + match chars.next() { + Some(':') => expand(chars, out, true), + Some('|') => { + let mut first = true; + while let Some(c) = chars.next() { + match c { + '|' => { + // The closing `}`. + chars.next(); + break; + } + ',' => first = false, + '\\' => { + if let Some(next) = chars.next() + && first + { + out.push(next); + } + } + c if first => out.push(c), + _ => {} + } + } + } + // `${1}`, or something this does not understand: + // skip to its end. + Some('}') | None => {} + Some(_) => { + let mut depth = 1; + for c in chars.by_ref() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + } + } + } + // `$name` (a variable) expands to nothing we know. + Some(d) if d.is_ascii_alphabetic() || d == '_' => { + while chars + .peek() + .is_some_and(|d| d.is_ascii_alphanumeric() || *d == '_') + { + chars.next(); + } + } + _ => out.push('$'), + }, + c => out.push(c), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::Range as LspRange; + + fn pos(line: u32, character: u32) -> Position { + Position::new(line, character) + } + + fn edit(sl: u32, sc: u32, el: u32, ec: u32, text: &str) -> TextEdit { + TextEdit { + range: LspRange::new(pos(sl, sc), pos(el, ec)), + new_text: text.into(), + } + } + + #[test] + fn columns_count_utf16_units_by_default() { + // a é 🎉 b: bytes 0,1,3,7 — UTF-16 units 0,1,2,4 — chars 0,1,2,3. + let line = "aé🎉b"; + let enc = Encoding::Utf16; + assert_eq!(column_of_byte(line, 0, enc), 0); + assert_eq!(column_of_byte(line, 1, enc), 1); + assert_eq!(column_of_byte(line, 3, enc), 2); + assert_eq!(column_of_byte(line, 7, enc), 4); + assert_eq!(column_of_byte(line, 8, enc), 5); + assert_eq!(byte_of_column(line, 2, enc), 3); + assert_eq!(byte_of_column(line, 4, enc), 7); + assert_eq!(byte_of_column(line, 5, enc), 8); + // Half a surrogate pair lands before the character, never inside it. + assert_eq!(byte_of_column(line, 3, enc), 3); + // Past the end of the line: the end of the line. + assert_eq!(byte_of_column(line, 99, enc), 8); + } + + #[test] + fn utf8_and_utf32_count_bytes_and_chars() { + let line = "aé🎉b"; + assert_eq!(column_of_byte(line, 7, Encoding::Utf8), 7); + assert_eq!(byte_of_column(line, 3, Encoding::Utf8), 3); + assert_eq!(column_of_byte(line, 7, Encoding::Utf32), 3); + assert_eq!(byte_of_column(line, 3, Encoding::Utf32), 7); + } + + #[test] + fn rope_offsets_and_server_positions_round_trip() { + let rope = Rope::from("fn a() {}\nlet 🎉 = \"é\";\n"); + let enc = Encoding::Utf16; + let second = "fn a() {}\n".len(); + let quote = second + "let 🎉 = ".len(); + assert_eq!(offset_to_lsp(&rope, quote, enc), pos(1, 9)); + assert_eq!(lsp_to_offset(&rope, pos(1, 9), enc), quote); + // A line past the end of the text clamps to the end. + assert_eq!(lsp_to_offset(&rope, pos(9, 0), enc), rope.len()); + // And the editor's own positions count chars. + assert_eq!(lsp_to_editor(&rope, pos(1, 9), enc), pos(1, 8)); + assert_eq!(lsp_to_editor(&rope, pos(0, 3), enc), pos(0, 3)); + } + + #[test] + fn diagnostics_move_to_editor_columns_and_are_counted() { + let rope = Rope::from("🎉🎉x\n"); + let d = |sev, c| lsp_types::Diagnostic { + range: LspRange::new(pos(0, c), pos(0, c + 1)), + severity: sev, + message: "m".into(), + ..Default::default() + }; + let diags = vec![ + d(Some(lsp_types::DiagnosticSeverity::ERROR), 4), + d(Some(lsp_types::DiagnosticSeverity::WARNING), 0), + d(None, 0), + ]; + let mapped = diagnostics_to_editor(&rope, &diags, Encoding::Utf16); + assert_eq!(mapped[0].range, LspRange::new(pos(0, 2), pos(0, 3))); + assert_eq!(mapped[0].message, "m"); + assert_eq!(count_problems(&diags), (1, 1)); + } + + #[test] + fn edits_apply_back_to_front_and_same_place_inserts_keep_their_order() { + let edits = vec![ + edit(0, 0, 0, 0, "A"), + edit(1, 0, 1, 3, "LET"), + edit(0, 0, 0, 0, "B"), + edit(0, 3, 0, 5, "__"), + ]; + let ordered = in_application_order(&edits); + let texts: Vec<&str> = ordered.iter().map(|e| e.new_text.as_str()).collect(); + assert_eq!(texts, ["LET", "__", "B", "A"]); + assert_eq!( + apply_edits("abcdef\nlet x\n", &edits, Encoding::Utf16), + "ABabc__f\nLET x\n" + ); + } + + #[test] + fn edits_on_a_file_count_utf16_columns_and_ignore_crlf() { + let text = "let 🎉x = 1;\r\nx;\r\n"; + // `x` after the emoji is UTF-16 column 6. + let edits = vec![edit(0, 6, 0, 7, "y"), edit(1, 0, 1, 1, "y")]; + assert_eq!( + apply_edits(text, &edits, Encoding::Utf16), + "let 🎉y = 1;\r\ny;\r\n" + ); + } + + #[test] + fn editor_edits_convert_each_range_against_the_original_text() { + let rope = Rope::from("🎉a🎉b\n"); + let edits = vec![edit(0, 2, 0, 3, "A"), edit(0, 5, 0, 6, "B")]; + let mapped = edits_to_editor(&rope, &edits, Encoding::Utf16); + assert_eq!(mapped[0].range, LspRange::new(pos(0, 3), pos(0, 4))); + assert_eq!(mapped[0].new_text, "B"); + assert_eq!(mapped[1].range, LspRange::new(pos(0, 1), pos(0, 2))); + } + + #[test] + fn snippets_become_their_default_text() { + assert_eq!(snippet_to_plain("println!(\"$1\")$0"), "println!(\"\")"); + assert_eq!( + snippet_to_plain("fn ${1:name}(${2:args}) {\n\t$0\n}"), + "fn name(args) {\n\t\n}" + ); + assert_eq!(snippet_to_plain("${1:outer ${2:inner}}"), "outer inner"); + assert_eq!(snippet_to_plain("${1|one,two|}"), "one"); + assert_eq!(snippet_to_plain("cost: \\$5 ${TM_FILENAME}"), "cost: $5 "); + assert_eq!(snippet_to_plain("a $ b"), "a $ b"); + assert_eq!(snippet_to_plain("x${1}y"), "xy"); + } +} diff --git a/src/ui/lsp/editor.rs b/src/ui/lsp/editor.rs new file mode 100644 index 00000000..9e6fd6af --- /dev/null +++ b/src/ui/lsp/editor.rs @@ -0,0 +1,447 @@ +//! Where the code editor meets the language servers: the few calls +//! `code_editor.rs` makes when its buffers change, the commands (go to +//! definition, format, rename), and the status bar's word on the server. + +use std::path::PathBuf; +use std::rc::Rc; + +use gpui::prelude::*; +use gpui::{AnyElement, Context, Entity, WeakEntity, Window, div, px}; +use gpui_component::input::InputState; +use gpui_component::menu::PopupMenu; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, RopeExt as _, Sizable as _, WindowExt as _, h_flex, +}; +use lsp_types::Position; + +use super::convert::{self, Encoding}; +use super::{Freshen, LspStatus, LspStore}; +use crate::ui::app::Tty7App; +use crate::ui::code_editor::{BufferId, LocalFile}; +use crate::ui::i18n::{L10nKey, t, t_fmt}; + +/// One buffer offered to the language servers. +pub(crate) struct LspBuffer { + pub(crate) input: Entity, + pub(crate) path: PathBuf, + pub(crate) language: &'static str, + pub(crate) app: WeakEntity, +} + +impl Tty7App { + fn lsp_enabled(cx: &gpui::App) -> bool { + cx.global::().editor_lsp + } + + /// The set of open buffers changed — one opened, closed, or was saved + /// under a new name. Servers start and documents open or close to match. + pub(crate) fn lsp_sync_buffers(&self, cx: &mut Context) { + let app = cx.entity().downgrade(); + let buffers = if Self::lsp_enabled(cx) { + self.editor_local_files() + .into_iter() + .map(|f| LspBuffer { + input: f.input, + path: f.path, + language: f.language, + app: app.clone(), + }) + .collect() + } else { + Vec::new() + }; + let app = cx.entity().downgrade(); + LspStore::register_window( + cx.entity_id(), + Rc::new(move |cx: &mut gpui::App| { + app.update(cx, |app, cx| app.lsp_sync_buffers(cx)).is_ok() + }), + cx, + ); + LspStore::sync_window(cx.entity_id(), buffers, cx); + } + + /// The window is closing: every document it owns is closed now, so an + /// idle server can wind down instead of outliving the window (with a + /// tray icon the app itself keeps running). + pub(crate) fn lsp_window_closed(&self, cx: &mut Context) { + LspStore::sync_window(cx.entity_id(), Vec::new(), cx); + } + + /// A buffer's text changed. + pub(crate) fn lsp_buffer_edited(&self, id: BufferId, cx: &mut Context) { + if let Some(f) = self.editor_local_file(id) { + LspStore::edited(f.input.entity_id(), &f.path, cx); + self.lsp_signature_help_after_edit(&f, cx); + } + } + + /// A buffer was written to disk. + pub(crate) fn lsp_buffer_saved(&self, id: BufferId, cx: &mut Context) { + if let Some(f) = self.editor_local_file(id) { + LspStore::saved(f.input.entity_id(), &f.path, cx); + } + } + + /// The front file, if a server has it and the editor has the focus. + fn lsp_active( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(LocalFile, super::DocContext)> { + let f = self.editor_active_local_file()?; + let text = f.input.read(cx).text().clone(); + let doc = LspStore::context( + &f.path, + f.input.entity_id(), + Freshen::Text(&text), + Some(window.window_handle()), + cx, + )?; + Some((f, doc)) + } + + /// Opens `path` with the cursor on a server's `position` — through the + /// same open-at-line path a `file.rs:12:5` link takes, so navigation + /// history sees a definition jump like any other. + pub(crate) fn lsp_open_location( + &mut self, + path: PathBuf, + position: Position, + encoding: Encoding, + window: &mut Window, + cx: &mut Context, + ) { + // The open path counts columns in chars, from one. + let target = path.clone(); + let open = move |app: &mut Tty7App, + character: u32, + window: &mut Window, + cx: &mut Context| { + app.open_file_in_editor_at( + &target, + Some(position.line + 1), + Some(character + 1), + window, + cx, + ); + }; + let open_buffer = self + .editor_local_files() + .into_iter() + .find(|f| f.path == path.as_path()); + if let Some(f) = open_buffer { + let text = f.input.read(cx).text().clone(); + let character = convert::lsp_to_editor(&text, position, encoding).character; + open(self, character, window, cx); + return; + } + // Not open: the column is converted against the file on disk. + let read_from = path.clone(); + let line = position.line as usize; + cx.spawn_in(window, async move |app, cx| { + let character = cx + .background_spawn(async move { + let text = std::fs::read_to_string(&read_from).ok()?; + let line = text.lines().nth(line)?; + let byte = convert::byte_of_column(line, position.character, encoding); + Some(line[..byte].chars().count() as u32) + }) + .await + .unwrap_or(position.character); + let _ = app.update_in(cx, |app, window, cx| open(app, character, window, cx)); + }) + .detach(); + } + + /// F12: the definition of the symbol under the cursor. + pub(crate) fn lsp_go_to_definition(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + let state = f.input.read(cx); + let request = super::providers::definition_request(&doc, state.text(), state.cursor()); + let (encoding, root) = (doc.encoding, doc.root.clone()); + cx.spawn_in(window, async move |app, cx| { + let links = match request.await { + Ok(links) => links, + Err(e) => { + log::debug!("lsp: definition: {e:#}"); + return; + } + }; + let places: Vec<_> = links + .into_iter() + .filter_map(|l| { + Some(( + super::uri_to_path(&l.target_uri)?, + l.target_selection_range.start, + )) + }) + .collect(); + let _ = app.update_in(cx, |app, window, cx| match places.as_slice() { + [] => {} + // One answer is followed straight away, through the same path + // a secondary-click takes. + [(path, position)] => { + app.lsp_open_location(path.clone(), *position, encoding, window, cx) + } + _ => app.lsp_show_places( + L10nKey::SearchHeadingDefinitions, + places, + encoding, + root, + window, + cx, + ), + }); + }) + .detach(); + } + + /// ⇧⌥F: the server's formatting of the whole file, applied as edits so + /// it undoes like one. + pub(crate) fn lsp_format_document(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + if doc.caps.document_formatting_provider.is_none() { + return; + } + let before = f.input.read(cx).text().clone(); + let request = doc.client.request::( + lsp_types::DocumentFormattingParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + options: lsp_types::FormattingOptions { + tab_size: f.indent.size as u32, + insert_spaces: !f.indent.hard_tabs, + trim_trailing_whitespace: Some(true), + insert_final_newline: Some(true), + ..Default::default() + }, + work_done_progress_params: Default::default(), + }, + ); + let input = f.input.clone(); + let encoding = doc.encoding; + cx.spawn_in(window, async move |_, cx| { + let edits = match request.await { + Ok(Some(edits)) if !edits.is_empty() => edits, + Ok(_) => return, + Err(e) => { + log::info!("lsp: formatting: {e:#}"); + return; + } + }; + let _ = cx.update(|window, cx| { + input.update(cx, |state, cx| { + // Typed over while the server worked: its edits describe + // a text that is gone. + if *state.text() != before { + return; + } + let edits = convert::edits_to_editor(&before, &edits, encoding); + state.apply_lsp_edits(&edits, window, cx); + }); + }); + }) + .detach(); + } + + /// F2: asks for a new name for the symbol under the cursor. + pub(crate) fn lsp_rename_start(&mut self, window: &mut Window, cx: &mut Context) { + let Some((f, doc)) = self.lsp_active(window, cx) else { + return; + }; + if doc.caps.rename_provider.is_none() { + return; + } + let state = f.input.read(cx); + let offset = state.cursor(); + let Some(word) = state + .text() + .word_range(offset) + .map(|r| state.text().slice(r).to_string()) + else { + return; + }; + if word.trim().is_empty() { + return; + } + self.editor_open_rename_bar(f.id, offset, word, window, cx); + } + + /// The rename prompt was answered. + pub(crate) fn lsp_rename( + &mut self, + id: BufferId, + offset: usize, + new_name: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(f) = self.editor_local_file(id) else { + return; + }; + let text = f.input.read(cx).text().clone(); + let Some(doc) = LspStore::context( + &f.path, + f.input.entity_id(), + Freshen::Text(&text), + Some(window.window_handle()), + cx, + ) else { + return; + }; + // Every open buffer as the server sees it now; the answer is applied + // only if none of those it touches has changed by then. + let baseline = LspStore::snapshot(cx); + let old_name = text + .word_range(offset) + .map(|r| text.slice(r).to_string()) + .unwrap_or_default(); + let request = doc + .client + .request::(lsp_types::RenameParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(&text, offset, doc.encoding), + ), + new_name, + work_done_progress_params: Default::default(), + }); + let encoding = doc.encoding; + cx.spawn_in(window, async move |_, cx| { + let result = request.await; + let _ = cx.update(|window, cx| match result { + Ok(Some(edit)) => { + super::apply_workspace_edit(edit, encoding, Some(baseline), cx); + } + Ok(None) => {} + Err(e) => { + let context = t_fmt(L10nKey::LspRenameFailed, &[("name", &old_name)]); + window.push_notification( + crate::ui::host_ops::failure(context, &format!("{e:#}")), + cx, + ); + } + }); + }) + .detach(); + } + + /// ⌘.: the server's fixes and refactors at the cursor, in the editor's + /// own code-action menu. + pub(crate) fn lsp_code_actions(&mut self, window: &mut Window, cx: &mut Context) { + if self.editor_active_local_file().is_none() { + return; + } + window.dispatch_action(Box::new(gpui_component::input::ToggleCodeActions), cx); + } + + /// The language-server commands for the editor's right-click menu, once + /// the buffer's server is up. + pub(crate) fn lsp_menu_items( + &self, + menu: PopupMenu, + id: BufferId, + cx: &gpui::App, + ) -> PopupMenu { + use crate::core::actions::{ + EditorFindReferences, EditorFormatDocument, EditorGoToDefinition, EditorQuickFix, + EditorRenameSymbol, + }; + let Some(f) = self.editor_local_file(id) else { + return menu; + }; + if !matches!( + LspStore::status(&f.path, f.language, cx), + Some(LspStatus::Running { .. }) + ) { + return menu; + } + menu.separator() + .menu( + t(L10nKey::LspGoToDefinition), + Box::new(EditorGoToDefinition), + ) + .menu( + t(L10nKey::LspFindReferences), + Box::new(EditorFindReferences), + ) + .menu(t(L10nKey::LspQuickFix), Box::new(EditorQuickFix)) + .menu( + t(L10nKey::LspRenameSymbolAction), + Box::new(EditorRenameSymbol), + ) + .menu( + t(L10nKey::LspFormatDocument), + Box::new(EditorFormatDocument), + ) + } + + /// The status bar's word on the front file's server: its problem counts + /// once it runs, or why there are none. + pub(crate) fn render_lsp_status(&self, cx: &mut Context) -> Option { + if !Self::lsp_enabled(cx) { + return None; + } + let f = self.editor_active_local_file()?; + let status = LspStore::status(&f.path, f.language, cx)?; + let muted = cx.theme().muted_foreground; + let element = match status { + LspStatus::Missing(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerMissing, &[("name", name)])) + .into_any_element(), + LspStatus::Starting(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerStarting, &[("name", &name)])) + .into_any_element(), + LspStatus::Down(name) => div() + .flex_none() + .child(t_fmt(L10nKey::LspServerDown, &[("name", &name)])) + .into_any_element(), + LspStatus::Running { + name, + errors, + warnings, + } => { + let tip = format!( + "{name} — {}", + t_fmt( + L10nKey::LspProblemsTooltip, + &[ + ("errors", &errors.to_string()), + ("warnings", &warnings.to_string()) + ], + ) + ); + let count = |icon: IconName, n: usize, color: gpui::Hsla| { + h_flex() + .gap(px(3.)) + .items_center() + .child(Icon::new(icon).xsmall().text_color(if n > 0 { + color + } else { + muted + })) + .child(n.to_string()) + }; + h_flex() + .id("lsp-status") + .flex_none() + .gap_2() + .items_center() + .font_features(crate::ui::theme::tabular_figures()) + .child(count(IconName::CircleX, errors, cx.theme().danger)) + .child(count(IconName::TriangleAlert, warnings, cx.theme().warning)) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx) + }) + .into_any_element() + } + }; + Some(element) + } +} diff --git a/src/ui/lsp/locations.rs b/src/ui/lsp/locations.rs new file mode 100644 index 00000000..4d8a49f0 --- /dev/null +++ b/src/ui/lsp/locations.rs @@ -0,0 +1,508 @@ +//! Places a language server points at — every reference to a symbol, or a +//! definition with more than one answer — listed in the search's Locations +//! tab: arrowing through it previews a place in the file in front, Return +//! goes there. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use gpui::{App, AppContext as _, Context, Window}; +use gpui_component::{Rope, RopeExt as _}; +use lsp_types::Position; + +use super::convert::{self, Encoding}; +use super::{Freshen, LspStore}; +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::search::{CommandKind, Item, SearchTab}; + +/// One place, in the editor's terms: 0-based, the column counted in chars, +/// with the line it is on for the row to show. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Found { + pub(crate) path: PathBuf, + pub(crate) line: u32, + pub(crate) column: u32, + pub(crate) text: String, +} + +/// The longest line text a row shows. +const LINE_PREVIEW: usize = 160; + +/// Converts server positions to [`Found`]s. Files open in the editor are +/// measured against their buffer (`open`), the rest against the disk — so +/// this runs off the main thread. Sorted by file, then position, duplicates +/// dropped. +pub(crate) fn resolve( + places: Vec<(PathBuf, Position)>, + open: HashMap, + enc: Encoding, +) -> Vec { + let mut found: Vec = measure(places, &open, enc).into_iter().flatten().collect(); + found.sort_by(|a, b| (&a.path, a.line, a.column).cmp(&(&b.path, b.line, b.column))); + found.dedup(); + found +} + +/// [`resolve`] without the sorting: one answer per place, in order, `None` +/// for a place past the end of its file. +pub(crate) fn measure( + places: Vec<(PathBuf, Position)>, + open: &HashMap, + enc: Encoding, +) -> Vec> { + let mut read: HashMap>> = HashMap::new(); + places + .into_iter() + .map(|(path, pos)| { + let line = match open.get(&path) { + Some(rope) if (pos.line as usize) < rope.lines_len() => { + rope.slice_line(pos.line as usize).to_string() + } + Some(_) => return None, + None => read + .entry(path.clone()) + .or_insert_with(|| { + std::fs::read_to_string(&path) + .ok() + .map(|t| t.lines().map(str::to_owned).collect()) + }) + .as_ref() + .and_then(|lines| lines.get(pos.line as usize).cloned()) + .unwrap_or_default(), + }; + let byte = convert::byte_of_column(&line, pos.character, enc); + let column = line[..byte].chars().count() as u32; + let text: String = line.trim().chars().take(LINE_PREVIEW).collect(); + Some(Found { + path, + line: pos.line, + column, + text, + }) + }) + .collect() +} + +/// A search row for `found`, its path shown relative to `root`. +pub(crate) fn item(found: &Found, root: &Path) -> Item { + let shown = found.path.strip_prefix(root).unwrap_or(&found.path); + Item::new( + format!("{}:{}", shown.display(), found.line + 1), + CommandKind::GoToLocation { + path: found.path.clone(), + line: found.line, + column: found.column, + }, + ) + .with_subtitle(found.text.clone()) + .with_alias(found.path.display().to_string()) +} + +impl Tty7App { + /// The open local buffers' texts, for measuring places in them. + fn lsp_open_texts(&self, cx: &App) -> HashMap { + self.editor_local_files() + .into_iter() + .map(|f| (f.path, f.input.read(cx).text().clone())) + .collect() + } + + /// Shows what a server found: one place is gone to at once, several + /// are listed to pick from. + pub(crate) fn lsp_show_places( + &mut self, + heading: L10nKey, + places: Vec<(PathBuf, Position)>, + encoding: Encoding, + root: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if places.is_empty() { + return; + } + let open = self.lsp_open_texts(cx); + cx.spawn_in(window, async move |app, cx| { + let found = cx + .background_spawn(async move { resolve(places, open, encoding) }) + .await; + let _ = app.update_in(cx, |app, window, cx| match found.as_slice() { + [] => {} + [one] => app.open_file_in_editor_at( + &one.path, + Some(one.line + 1), + Some(one.column + 1), + window, + cx, + ), + many => { + let items = many.iter().map(|f| item(f, &root)).collect(); + app.open_search(SearchTab::Locations, "", window, cx); + app.editor_begin_preview(cx); + if let Some(view) = app.search.clone() { + view.update(cx, |view, cx| { + view.set_locations(Some(t(heading)), items, window, cx) + }); + } + } + }); + }) + .detach(); + } + + /// ⇧F12: every reference to the symbol under the cursor. + pub(crate) fn lsp_find_references(&mut self, window: &mut Window, cx: &mut Context) { + let Some(f) = self.editor_active_local_file() else { + return; + }; + let (text, offset) = { + let state = f.input.read(cx); + (state.text().clone(), state.cursor()) + }; + let Some(doc) = LspStore::context( + &f.path, + f.input.entity_id(), + Freshen::Text(&text), + Some(window.window_handle()), + cx, + ) else { + return; + }; + if doc.caps.references_provider.is_none() { + return; + } + let request = + doc.client + .request::(lsp_types::ReferenceParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(&text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: lsp_types::ReferenceContext { + include_declaration: true, + }, + }); + let (encoding, root) = (doc.encoding, doc.root.clone()); + cx.spawn_in(window, async move |app, cx| { + let locations = match request.await { + Ok(locations) => locations.unwrap_or_default(), + Err(e) => { + log::debug!("lsp: references: {e:#}"); + return; + } + }; + let places = locations + .into_iter() + .filter_map(|l| Some((super::uri_to_path(&l.uri)?, l.range.start))) + .collect(); + let _ = app.update_in(cx, |app, window, cx| { + app.lsp_show_places( + L10nKey::SearchHeadingReferences, + places, + encoding, + root, + window, + cx, + ) + }); + }) + .detach(); + } + + /// What the Symbols tab asks as its query changes: the front file's + /// language server, for symbols anywhere in the project. `None` when + /// there is no such server, and the tab lists the file's own alone. + pub(crate) fn lsp_project_symbol_query( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let f = self.editor_active_local_file()?; + let doc = LspStore::context(&f.path, f.input.entity_id(), Freshen::Skip, None, cx)?; + doc.caps.workspace_symbol_provider.as_ref()?; + let app = cx.entity().downgrade(); + let handle = window.window_handle(); + let path = f.path.clone(); + let requester = f.input.entity_id(); + Some(std::rc::Rc::new(move |query: &str, cx: &mut App| { + let (app, path, query) = (app.clone(), path.clone(), query.to_owned()); + // Deferred: the query arrives from inside the search's own + // update, and answering it updates the search. + cx.defer(move |cx| { + let _ = handle.update(cx, |_, window, cx| { + let _ = app.update(cx, |app, cx| { + app.lsp_workspace_query(&path, requester, query, window, cx) + }); + }); + }); + })) + } + + fn lsp_workspace_query( + &mut self, + path: &Path, + requester: gpui::EntityId, + query: String, + window: &mut Window, + cx: &mut Context, + ) { + use std::sync::atomic::{AtomicU64, Ordering}; + // The latest query wins: an answer to an older one is dropped. + static LATEST: AtomicU64 = AtomicU64::new(0); + let seq = LATEST.fetch_add(1, Ordering::Relaxed) + 1; + if query.trim().is_empty() { + if let Some(view) = self.search.clone() { + view.update(cx, |view, cx| { + view.set_project_symbols(Vec::new(), window, cx) + }); + } + return; + } + let Some(doc) = LspStore::context(path, requester, Freshen::Skip, None, cx) else { + return; + }; + let client = doc.client.clone(); + let (encoding, root) = (doc.encoding, doc.root.clone()); + let open = self.lsp_open_texts(cx); + let front = path.to_path_buf(); + cx.spawn_in(window, async move |app, cx| { + // Typing fast asks once, for what was typed last. + cx.background_executor() + .timer(std::time::Duration::from_millis(150)) + .await; + if LATEST.load(Ordering::Relaxed) != seq { + return; + } + let answer = client + .request::( + lsp_types::WorkspaceSymbolParams { + query, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }, + ) + .await; + // The front file's own symbols are listed above these already. + let symbols = match answer { + Ok(Some(symbols)) => workspace_symbols(symbols) + .into_iter() + .filter(|s| s.path != front) + .collect::>(), + Ok(None) => Vec::new(), + Err(e) => { + log::debug!("lsp: workspace/symbol: {e:#}"); + return; + } + }; + let items = cx + .background_spawn(async move { + let places = symbols + .iter() + .map(|s| (s.path.clone(), s.position)) + .collect(); + measure(places, &open, encoding) + .into_iter() + .zip(symbols) + .filter_map(|(found, symbol)| Some(symbol_item(&found?, &symbol, &root))) + .collect::>() + }) + .await; + if LATEST.load(Ordering::Relaxed) != seq { + return; + } + let _ = app.update_in(cx, |app, window, cx| { + if let Some(view) = app.search.clone() + && view.read(cx).tab() == SearchTab::Symbols + { + view.update(cx, |view, cx| view.set_project_symbols(items, window, cx)); + } + }); + }) + .detach(); + } + + /// The Locations list moved onto a place: shown at once when it is in + /// the file in front, where the preview runs. + pub(crate) fn lsp_preview_location( + &mut self, + path: &Path, + line: u32, + column: u32, + cx: &mut Context, + ) { + let in_front = self + .editor_preview_buffer() + .and_then(|id| self.editor_local_file(id)) + .is_some_and(|f| f.path == path); + if in_front { + self.editor_symbol_preview(line, column, cx); + } + } +} + +/// The most project symbols (LSP `workspace/symbol`) listed for one query. +const MAX_WORKSPACE_SYMBOLS: usize = 200; + +/// One project symbol, before its column is measured. "Workspace" is the +/// protocol's word for the server's project root — not a tty7 workspace. +pub(crate) struct WorkspaceHit { + pub(crate) name: String, + pub(crate) kind: lsp_types::SymbolKind, + pub(crate) container: Option, + pub(crate) path: PathBuf, + pub(crate) position: Position, +} + +/// A `workspace/symbol` answer in either of its shapes. A symbol known only +/// by its file (no range yet) lands on the file's first line. +#[allow(deprecated)] +pub(crate) fn workspace_symbols(answer: lsp_types::WorkspaceSymbolResponse) -> Vec { + use lsp_types::{OneOf, WorkspaceSymbolResponse}; + let hits: Vec = match answer { + WorkspaceSymbolResponse::Flat(symbols) => symbols + .into_iter() + .filter_map(|s| { + Some(WorkspaceHit { + path: super::uri_to_path(&s.location.uri)?, + position: s.location.range.start, + name: s.name, + kind: s.kind, + container: s.container_name, + }) + }) + .collect(), + WorkspaceSymbolResponse::Nested(symbols) => symbols + .into_iter() + .filter_map(|s| { + let (uri, position) = match s.location { + OneOf::Left(l) => (l.uri, l.range.start), + OneOf::Right(l) => (l.uri, Position::default()), + }; + Some(WorkspaceHit { + path: super::uri_to_path(&uri)?, + position, + name: s.name, + kind: s.kind, + container: s.container_name, + }) + }) + .collect(), + }; + hits.into_iter().take(MAX_WORKSPACE_SYMBOLS).collect() +} + +fn symbol_item(found: &Found, symbol: &WorkspaceHit, root: &Path) -> Item { + let kind = serde_json::to_value(symbol.kind) + .ok() + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let shown = found.path.strip_prefix(root).unwrap_or(&found.path); + let place = format!("{}:{}", shown.display(), found.line + 1); + let subtitle = match symbol.container.as_deref().filter(|c| !c.is_empty()) { + Some(container) => format!("{container} · {place}"), + None => place, + }; + Item::new( + symbol.name.clone(), + CommandKind::GoToLocation { + path: found.path.clone(), + line: found.line, + column: found.column, + }, + ) + .with_subtitle(subtitle) + .with_note(crate::ui::code_editor::outline::SymbolKind::from_lsp(kind as u32).tag()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn places_are_measured_against_open_buffers_and_the_disk() { + let dir = tempfile::TempDir::new().unwrap(); + let on_disk = dir.path().join("b.rs"); + std::fs::write(&on_disk, "fn b() {\n let 🎉 = a();\n}\n").unwrap(); + let open_path = dir.path().join("a.rs"); + let open = HashMap::from([(open_path.clone(), Rope::from("pub fn é_a() {}\n"))]); + let found = resolve( + vec![ + // `a` after the emoji: UTF-16 column 13, char column 12. + (on_disk.clone(), Position::new(1, 13)), + (open_path.clone(), Position::new(0, 7)), + (open_path.clone(), Position::new(0, 7)), + (open_path.clone(), Position::new(9, 0)), + ], + open, + Encoding::Utf16, + ); + assert_eq!( + found, + [ + Found { + path: open_path, + line: 0, + column: 7, + text: "pub fn é_a() {}".into(), + }, + Found { + path: on_disk, + line: 1, + column: 12, + text: "let 🎉 = a();".into(), + }, + ] + ); + } + + #[test] + #[allow(deprecated)] + fn workspace_symbols_become_rows_naming_where_they_live() { + let root = super::super::test_path("/p"); + let uri = super::super::path_to_uri(&root.join("src").join("net.rs")).unwrap(); + let hits = workspace_symbols(lsp_types::WorkspaceSymbolResponse::Flat(vec![ + lsp_types::SymbolInformation { + name: "Server".into(), + kind: lsp_types::SymbolKind::STRUCT, + tags: None, + deprecated: None, + location: lsp_types::Location::new( + uri, + lsp_types::Range::new(Position::new(3, 11), Position::new(3, 17)), + ), + container_name: Some("net".into()), + }, + ])); + assert_eq!(hits.len(), 1); + let found = Found { + path: hits[0].path.clone(), + line: 3, + column: 11, + text: String::new(), + }; + let row = symbol_item(&found, &hits[0], &root); + assert_eq!(row.title, "Server"); + let rel = Path::new("src").join("net.rs"); + assert_eq!( + row.subtitle.as_deref(), + Some(format!("net · {}:4", rel.display()).as_str()) + ); + assert_eq!(row.note.as_deref(), Some("struct")); + } + + #[test] + fn a_row_names_its_file_relative_to_the_project() { + let found = Found { + path: PathBuf::from("/p/src/lib.rs"), + line: 4, + column: 2, + text: "x".into(), + }; + let row = item(&found, Path::new("/p")); + assert_eq!(row.title, "src/lib.rs:5"); + assert_eq!(row.subtitle.as_deref(), Some("x")); + } +} diff --git a/src/ui/lsp/mod.rs b/src/ui/lsp/mod.rs new file mode 100644 index 00000000..699a1b9b --- /dev/null +++ b/src/ui/lsp/mod.rs @@ -0,0 +1,1479 @@ +//! Language servers for the code editor. +//! +//! One [`LspStore`] per process holds every running server and every open +//! document. A server is keyed by (server, project root), so every buffer +//! under one Cargo workspace shares one rust-analyzer, whichever window it is +//! open in. A document is keyed by its path and owned by the first buffer that +//! opened it; the same file open in a second window shows the first one's +//! diagnostics only if it is the owner, since two buffers can hold two +//! different texts and a server only ever knows one. +//! +//! The editor talks to this module in a handful of places (see `editor.rs`): +//! the set of open buffers changed, a buffer was edited, a buffer was saved. +//! Everything else — starting a server when a file of its language opens, +//! shutting it down once its last file has been closed for a while, restarting +//! it when it crashes — happens here. +//! +//! Only files on this machine are served: a server runs next to the files it +//! reads. A buffer from an SFTP or remote host is skipped in +//! [`LspStore::sync_window`]; serving those means running the server on that +//! host, behind the same `Host` boundary the editor already reads files +//! through. + +pub(crate) mod client; +pub(crate) mod convert; +mod editor; +mod locations; +mod providers; +pub(crate) mod rpc; +pub(crate) mod servers; +mod signature; +mod symbols; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui::{ + AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Entity, EntityId, Global, + Subscription, WeakEntity, +}; +use gpui_component::Rope; +use gpui_component::input::InputState; +use lsp_types::{ServerCapabilities, TextDocumentSyncKind, Uri}; +use serde_json::{Value, json}; + +use self::client::{LspClient, ServerEvent}; +use self::convert::Encoding; +use self::rpc::ResponseError; +use self::servers::ServerSpec; + +pub(crate) use self::editor::LspBuffer; + +/// How long typing may go on before the server hears about it. Requests +/// (completion, hover…) send any change still waiting first, so this only +/// decides how soon diagnostics catch up after a pause. +const CHANGE_DEBOUNCE: Duration = Duration::from_millis(150); + +/// How long typing must pause before the outline is asked for again. Longer +/// than [`CHANGE_DEBOUNCE`]: nobody reads the breadcrumbs mid-word. +const SYMBOLS_DEBOUNCE: Duration = Duration::from_millis(500); + +/// How long a server outlives its last open file. Closing a file and opening +/// its neighbour is common; restarting rust-analyzer for it is not cheap. +const IDLE_SHUTDOWN: Duration = Duration::from_secs(60); + +/// Crashes allowed within [`CRASH_WINDOW`] before a server is left down. +const MAX_CRASHES: usize = 3; +const CRASH_WINDOW: Duration = Duration::from_secs(180); + +/// How long a program that was not on `PATH` is believed to still be +/// missing — long enough that opening ten files does not scan `PATH` ten +/// times, short enough that installing it mid-session is noticed. +const MISSING_RECHECK: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ServerKey { + name: &'static str, + root: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Starting, + Running, + Stopping, + /// Crashed more than [`MAX_CRASHES`] times, or would not start. + Dead, +} + +struct Server { + spec: &'static ServerSpec, + /// The program that actually runs — `pylsp` when pyright was not found. + label: String, + generation: u64, + phase: Phase, + client: Option>, + caps: Rc, + encoding: Encoding, + crashes: Vec, + idle_seq: u64, + _tasks: Vec>, +} + +struct Doc { + uri: Uri, + language_id: &'static str, + server: ServerKey, + version: i32, + owner: WeakEntity, + owner_id: EntityId, + /// The window-level editor (`Tty7App`) that holds the owning buffer. + app_id: EntityId, + app: WeakEntity, + /// Where the owning buffer is drawn, once known — edits the server asks + /// for are applied through it. + window: Option, + /// The text the server last heard. + sent: Option, + /// An edit the server has not heard about yet. + dirty: bool, + change_seq: u64, + symbols_seq: u64, + diagnostics: Vec, + problems: (usize, usize), +} + +/// What the status bar says about a file's language server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum LspStatus { + /// The language has a server, and it is not installed. + Missing(&'static str), + Starting(String), + Running { + name: String, + errors: usize, + warnings: usize, + }, + /// Gave up after repeated crashes. + Down(String), +} + +/// How [`LspStore::context`] brings the server up to date before a request. +pub(crate) enum Freshen<'a> { + /// With this text — the caller's view of the buffer, from inside the + /// buffer's own update, where the buffer cannot be read. + Text(&'a Rope), + /// Not at all: the caller is inside the buffer's update and has no text + /// to offer, and the request does not depend on it. + Skip, +} + +/// Everything a request about one document needs, taken in one borrow. +#[derive(Clone)] +pub(crate) struct DocContext { + pub(crate) client: Arc, + pub(crate) uri: Uri, + pub(crate) encoding: Encoding, + pub(crate) caps: Rc, + /// The server's raw diagnostics for this document, in its own columns. + pub(crate) diagnostics: Vec, + /// The project the server was started on. + pub(crate) root: PathBuf, +} + +#[derive(Default)] +pub(crate) struct LspStore { + servers: HashMap, + docs: HashMap, + missing: HashMap<&'static str, Instant>, + next_generation: u64, + /// Each window editor's way to offer its buffers again, for when the + /// setting is turned back on. Returns `false` once the window is gone. + windows: HashMap, + /// `editor_lsp` as last seen, to tell a turn-on from any other change. + enabled: bool, + _subscriptions: Vec, +} + +pub(crate) type Resync = Rc bool>; + +impl Global for LspStore {} + +fn try_store_mut(cx: &mut App) -> Option<&mut LspStore> { + cx.has_global::() + .then(|| cx.global_mut::()) +} + +pub(crate) fn path_to_uri(path: &Path) -> Option { + url::Url::from_file_path(path).ok()?.as_str().parse().ok() +} + +/// An absolute path for a test, spelled for this platform: `/p/a.rs` as +/// given, `C:\p\a.rs` on Windows, where a leading slash alone is not +/// absolute and so has no `file://` URI. +#[cfg(test)] +pub(crate) fn test_path(unix: &str) -> PathBuf { + if cfg!(windows) { + PathBuf::from(format!("C:{}", unix.replace('/', "\\"))) + } else { + PathBuf::from(unix) + } +} + +pub(crate) fn uri_to_path(uri: &Uri) -> Option { + let url = url::Url::parse(uri.as_str()).ok()?; + if url.scheme() != "file" { + return None; + } + url.to_file_path().ok() +} + +impl LspStore { + fn ensure(cx: &mut App) { + if cx.try_global::().is_some() { + return; + } + let quit = cx.on_app_quit(|cx| LspStore::on_quit(cx)); + let config = cx.observe_global::(|cx| { + let enabled = cx.global::().editor_lsp; + if cx.try_global::().is_none() { + return; + } + cx.update_global::(|store, cx| store.set_enabled(enabled, cx)); + }); + let enabled = cx + .try_global::() + .is_some_and(|c| c.editor_lsp); + cx.set_global(LspStore { + enabled, + _subscriptions: vec![quit, config], + ..Default::default() + }); + } + + fn update(cx: &mut App, f: impl FnOnce(&mut LspStore, &mut App) -> R) -> R { + Self::ensure(cx); + cx.update_global::(f) + } + + // ---- The editor's side ---- + + /// Remembers how a window's editor offers its buffers, so turning the + /// setting back on can ask every window for them again. + pub(crate) fn register_window(app_id: EntityId, resync: Resync, cx: &mut App) { + Self::update(cx, |store, _| { + store.windows.entry(app_id).or_insert(resync); + }); + } + + /// The `editor_lsp` setting, as it changes. Off closes every document + /// and lets the servers go; on asks each window for its open buffers, + /// which starts them again — deferred, since the setting is changed from + /// inside some window's own update. + fn set_enabled(&mut self, enabled: bool, cx: &mut App) { + let was = std::mem::replace(&mut self.enabled, enabled); + if !enabled { + if !self.docs.is_empty() || !self.servers.is_empty() { + self.close_all(cx); + } + return; + } + if was { + return; + } + let windows: Vec<(EntityId, Resync)> = self + .windows + .iter() + .map(|(id, r)| (*id, r.clone())) + .collect(); + cx.defer(move |cx| { + let gone: Vec = windows + .into_iter() + .filter(|(_, resync)| !resync(cx)) + .map(|(id, _)| id) + .collect(); + if let Some(store) = try_store_mut(cx) { + for id in gone { + store.windows.remove(&id); + } + } + }); + } + + /// Brings the documents one window's editor owns in line with the + /// buffers it has: opens the new ones, closes the ones that went away or + /// moved, and leaves the rest alone. Cheap when nothing changed. + pub(crate) fn sync_window(app_id: EntityId, buffers: Vec, cx: &mut App) { + if buffers.is_empty() && cx.try_global::().is_none() { + return; + } + Self::update(cx, |store, cx| { + let stale: Vec = store + .docs + .iter() + .filter(|(path, doc)| { + doc.owner.upgrade().is_none() + || (doc.app_id == app_id + && !buffers + .iter() + .any(|b| b.input.entity_id() == doc.owner_id && &b.path == *path)) + }) + .map(|(path, _)| path.clone()) + .collect(); + for path in stale { + store.close_doc(&path, cx); + } + for buffer in buffers { + if !store.docs.contains_key(&buffer.path) { + store.open_doc(app_id, buffer, cx); + } + } + }); + } + + /// A buffer's text changed. The server hears about it after a pause, or + /// before the next request, whichever comes first. + pub(crate) fn edited(input_id: EntityId, path: &Path, cx: &mut App) { + let Some(store) = try_store_mut(cx) else { + return; + }; + let Some(doc) = store.docs.get_mut(path).filter(|d| d.owner_id == input_id) else { + return; + }; + doc.dirty = true; + doc.change_seq += 1; + let seq = doc.change_seq; + let path = path.to_path_buf(); + let current = move |cx: &App, path: &Path| { + cx.try_global::() + .and_then(|s| s.docs.get(path)) + .is_some_and(|d| d.change_seq == seq) + }; + cx.spawn(async move |cx| { + cx.background_executor().timer(CHANGE_DEBOUNCE).await; + if !cx.update(|cx| current(cx, &path)) { + return; + } + cx.update(|cx| Self::update(cx, |store, cx| store.flush_from_owner(&path, cx))); + cx.background_executor() + .timer(SYMBOLS_DEBOUNCE.saturating_sub(CHANGE_DEBOUNCE)) + .await; + cx.update(|cx| { + if current(cx, &path) { + Self::update(cx, |store, cx| store.refresh_symbols(&path, cx)); + } + }); + }) + .detach(); + } + + /// A buffer was written to disk. + pub(crate) fn saved(input_id: EntityId, path: &Path, cx: &mut App) { + if cx.try_global::().is_none() { + return; + } + Self::update(cx, |store, cx| { + if !store.docs.get(path).is_some_and(|d| d.owner_id == input_id) { + return; + } + store.flush_from_owner(path, cx); + let Some(doc) = store.docs.get(path) else { + return; + }; + let Some(server) = store.servers.get(&doc.server) else { + return; + }; + let Some(client) = server.client.as_ref() else { + return; + }; + let include_text = match &server.caps.text_document_sync { + Some(lsp_types::TextDocumentSyncCapability::Options(o)) => match &o.save { + Some(lsp_types::TextDocumentSyncSaveOptions::SaveOptions(s)) => { + s.include_text.unwrap_or(false) + } + _ => false, + }, + _ => false, + }; + client.notify::( + lsp_types::DidSaveTextDocumentParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + text: include_text + .then(|| doc.sent.as_ref().map(|t| t.to_string())) + .flatten(), + }, + ); + }); + } + + pub(crate) fn status(path: &Path, language: &str, cx: &App) -> Option { + let store = cx.try_global::()?; + if let Some(doc) = store.docs.get(path) { + let server = store.servers.get(&doc.server)?; + return Some(match server.phase { + Phase::Starting | Phase::Stopping => LspStatus::Starting(server.label.clone()), + Phase::Running => LspStatus::Running { + name: server.label.clone(), + errors: doc.problems.0, + warnings: doc.problems.1, + }, + Phase::Dead => LspStatus::Down(server.label.clone()), + }); + } + let spec = servers::spec_for_language(language)?; + store + .missing + .contains_key(spec.name) + .then_some(LspStatus::Missing(spec.name)) + } + + /// Every open document's diagnostics, for the editor's Problems list: in + /// editor columns where the text the server saw is known, its own + /// otherwise. + pub(crate) fn diagnostics_snapshot(cx: &App) -> Vec<(PathBuf, Vec)> { + let Some(store) = cx.try_global::() else { + return Vec::new(); + }; + store + .docs + .iter() + .filter(|(_, doc)| !doc.diagnostics.is_empty()) + .map(|(path, doc)| { + let encoding = store.servers.get(&doc.server).map(|s| s.encoding); + let diagnostics = match (doc.sent.as_ref(), encoding) { + (Some(text), Some(enc)) => { + convert::diagnostics_to_editor(text, &doc.diagnostics, enc) + } + _ => doc.diagnostics.clone(), + }; + (path.clone(), diagnostics) + }) + .collect() + } + + /// The server behind `path`, with any edit still waiting sent first (see + /// [`Freshen`]) so the request that follows sees the text the caller sees. + /// + /// Only the buffer that owns the document may ask (`requester` is its + /// `InputState`'s id). The same file open in a second window is a second + /// buffer whose text can differ from the owner's; letting it send that + /// text would swap the document under the owner, and letting it ask + /// against the owner's text would answer with positions for a text it + /// does not hold. So a non-owner is refused, quietly: that buffer simply + /// has no language features until the owner closes and it takes over. + pub(crate) fn context( + path: &Path, + requester: EntityId, + sync: Freshen<'_>, + window: Option, + cx: &mut App, + ) -> Option { + cx.try_global::()?; + Self::update(cx, |store, _| { + store.context_for(path, requester, sync, window) + }) + } + + fn context_for( + &mut self, + path: &Path, + requester: EntityId, + sync: Freshen<'_>, + window: Option, + ) -> Option { + // Checked before anything is sent: a non-owner must never sync. + if self.docs.get(path)?.owner_id != requester { + return None; + } + match sync { + Freshen::Text(text) => self.flush_text(path, text), + Freshen::Skip => {} + } + let doc = self.docs.get_mut(path)?; + if window.is_some() { + doc.window = window; + } + let server = self.servers.get(&doc.server)?; + if server.phase != Phase::Running { + return None; + } + Some(DocContext { + client: server.client.clone()?, + uri: doc.uri.clone(), + encoding: server.encoding, + caps: server.caps.clone(), + diagnostics: doc.diagnostics.clone(), + root: doc.server.root.clone(), + }) + } + + /// The server's trigger characters for completion in `path`. + pub(crate) fn completion_triggers(path: &Path, cx: &App) -> Vec { + let Some(store) = cx.try_global::() else { + return Vec::new(); + }; + store + .docs + .get(path) + .and_then(|d| store.servers.get(&d.server)) + .and_then(|s| s.caps.completion_provider.as_ref()) + .and_then(|c| c.trigger_characters.clone()) + .unwrap_or_default() + } + + // ---- Documents ---- + + fn open_doc(&mut self, app_id: EntityId, buffer: LspBuffer, cx: &mut App) { + let Some(spec) = servers::spec_for_language(buffer.language) else { + return; + }; + let Some(uri) = path_to_uri(&buffer.path) else { + return; + }; + let home = std::env::var_os("HOME").map(PathBuf::from); + let root = servers::find_root(&buffer.path, spec, home.as_deref()); + let key = ServerKey { + name: spec.name, + root, + }; + let needs_start = self + .servers + .get(&key) + .is_none_or(|s| s.phase == Phase::Stopping); + if needs_start && !self.start_server(key.clone(), spec, cx) { + return; + } + let text = buffer.input.read(cx).text().clone(); + let doc = Doc { + uri, + language_id: servers::language_id(&buffer.path, buffer.language), + server: key.clone(), + version: 0, + owner: buffer.input.downgrade(), + owner_id: buffer.input.entity_id(), + app_id, + app: buffer.app.clone(), + window: None, + sent: Some(text.clone()), + dirty: false, + change_seq: 0, + symbols_seq: 0, + diagnostics: Vec::new(), + problems: (0, 0), + }; + if let Some(server) = self.servers.get_mut(&key) { + // A file opening cancels a pending idle shutdown. + server.idle_seq += 1; + if let Some(client) = &server.client { + send_did_open(client, &doc, &text); + } + } + self.docs.insert(buffer.path.clone(), doc); + providers::install(&buffer.input, &buffer.path, buffer.app.clone(), cx); + self.refresh_symbols(&buffer.path, cx); + } + + fn close_doc(&mut self, path: &Path, cx: &mut App) { + let Some(doc) = self.docs.remove(path) else { + return; + }; + if let Some(input) = doc.owner.upgrade() { + providers::uninstall(&input, cx); + clear_diagnostics(&input, cx); + } + // Deferred: this runs while the editor that holds the buffer is + // itself being updated (it is what asked for the close). + let (app, buffer) = (doc.app.clone(), doc.owner_id); + cx.defer(move |cx| { + let _ = app.update(cx, |app, cx| { + app.editor_set_document_symbols(buffer, None, cx) + }); + }); + let key = doc.server.clone(); + let Some(server) = self.servers.get_mut(&key) else { + return; + }; + if let Some(client) = &server.client { + client.notify::( + lsp_types::DidCloseTextDocumentParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri), + }, + ); + } + if self.docs.values().any(|d| d.server == key) { + return; + } + server.idle_seq += 1; + let seq = server.idle_seq; + let generation = server.generation; + cx.spawn(async move |cx| { + cx.background_executor().timer(IDLE_SHUTDOWN).await; + cx.update(|cx| { + let Some(store) = try_store_mut(cx) else { + return; + }; + let still_idle = store + .servers + .get(&key) + .is_some_and(|s| s.idle_seq == seq && s.generation == generation) + && !store.docs.values().any(|d| d.server == key); + if still_idle { + LspStore::update(cx, |store, cx| store.shutdown_server(&key, cx)); + } + }); + }) + .detach(); + } + + fn close_all(&mut self, cx: &mut App) { + let paths: Vec = self.docs.keys().cloned().collect(); + for path in paths { + self.close_doc(&path, cx); + } + let keys: Vec = self.servers.keys().cloned().collect(); + for key in keys { + self.shutdown_server(&key, cx); + } + } + + fn flush_from_owner(&mut self, path: &Path, cx: &mut App) { + let Some(text) = self + .docs + .get(path) + .and_then(|d| d.owner.upgrade()) + .map(|input| input.read(cx).text().clone()) + else { + return; + }; + self.flush_text(path, &text); + } + + /// Sends `text` as the document's new content, if it is new. Full-text + /// sync: one change event without a range replaces the whole document, + /// which every server accepts whatever sync kind it asked for. + fn flush_text(&mut self, path: &Path, text: &Rope) { + let Some(doc) = self.docs.get_mut(path) else { + return; + }; + doc.dirty = false; + if doc.sent.as_ref() == Some(text) { + return; + } + let Some(server) = self.servers.get(&doc.server) else { + return; + }; + let wants_changes = !matches!( + &server.caps.text_document_sync, + Some(lsp_types::TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::NONE + )) + ); + doc.version += 1; + doc.sent = Some(text.clone()); + let (Some(client), true) = (&server.client, wants_changes) else { + return; + }; + client.notify::( + lsp_types::DidChangeTextDocumentParams { + text_document: lsp_types::VersionedTextDocumentIdentifier::new( + doc.uri.clone(), + doc.version, + ), + content_changes: vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: text.to_string(), + }], + }, + ); + } + + /// Asks the server for the document's outline, and hands it to the + /// editor if the text has not moved on by the time it answers. + fn refresh_symbols(&mut self, path: &Path, cx: &mut App) { + let Some(doc) = self.docs.get_mut(path) else { + return; + }; + let Some(server) = self.servers.get(&doc.server) else { + return; + }; + let supported = matches!( + &server.caps.document_symbol_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ); + let (Phase::Running, true, Some(client), Some(text)) = ( + server.phase, + supported, + server.client.clone(), + doc.sent.clone(), + ) else { + return; + }; + doc.symbols_seq += 1; + let (seq, version) = (doc.symbols_seq, doc.version); + let (app, buffer) = (doc.app.clone(), doc.owner_id); + let encoding = server.encoding; + let request = client.request::( + lsp_types::DocumentSymbolParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }, + ); + let path = path.to_path_buf(); + cx.spawn(async move |cx| { + let response = match request.await { + Ok(Some(response)) => response, + Ok(None) => return, + Err(e) => { + log::debug!("lsp: documentSymbol: {e:#}"); + return; + } + }; + let outline = symbols::outline(response, &text, encoding); + cx.update(|cx| { + let current = cx + .try_global::() + .and_then(|s| s.docs.get(&path)) + .is_some_and(|d| d.symbols_seq == seq && d.version == version && !d.dirty); + if current { + // Nothing yet — a server still indexing answers with an + // empty list — leaves the tree's outline in place. + let outline = (!outline.is_empty()).then_some(outline); + let _ = app.update(cx, |app, cx| { + app.editor_set_document_symbols(buffer, outline, cx) + }); + } + }); + }) + .detach(); + } + + // ---- Servers ---- + + fn start_server(&mut self, key: ServerKey, spec: &'static ServerSpec, cx: &mut App) -> bool { + if self + .missing + .get(spec.name) + .is_some_and(|at| at.elapsed() < MISSING_RECHECK) + { + return false; + } + let path = std::env::var_os("PATH").unwrap_or_default(); + let Some((program, args)) = servers::resolve(spec, &path) else { + self.missing.insert(spec.name, Instant::now()); + return false; + }; + self.missing.remove(spec.name); + let label = program + .file_name() + .map(|n| n.to_string_lossy().trim_end_matches(".exe").to_owned()) + .unwrap_or_else(|| spec.name.to_owned()); + let (client, events) = match LspClient::spawn(&label, &program, args, &key.root) { + Ok(started) => started, + Err(e) => { + log::warn!("lsp: could not start {label}: {e:#}"); + self.missing.insert(spec.name, Instant::now()); + return false; + } + }; + log::info!("lsp: started {label} for {}", key.root.display()); + self.next_generation += 1; + let generation = self.next_generation; + let crashes = self + .servers + .get(&key) + .map(|s| s.crashes.clone()) + .unwrap_or_default(); + + let init = client.initialize(initialize_params(&key.root, spec)); + let init_task = { + let key = key.clone(); + cx.spawn(async move |cx| { + let result = init.await; + cx.update(|cx| { + LspStore::update(cx, |store, cx| { + store.initialized(&key, generation, result, cx) + }) + }); + }) + }; + let events_task = { + let key = key.clone(); + cx.spawn(async move |cx| { + while let Ok(event) = events.recv().await { + let exited = matches!(event, ServerEvent::Exited); + cx.update(|cx| { + LspStore::update(cx, |store, cx| { + store.handle_event(&key, generation, event, cx) + }) + }); + if exited { + break; + } + } + }) + }; + // A server being replaced — one still shutting down — goes with its + // own tasks; its client is killed when the last handle drops. + self.servers.insert( + key, + Server { + spec, + label, + generation, + phase: Phase::Starting, + client: Some(client), + caps: Rc::new(ServerCapabilities::default()), + encoding: Encoding::Utf16, + crashes, + idle_seq: 0, + _tasks: vec![init_task, events_task], + }, + ); + true + } + + fn initialized( + &mut self, + key: &ServerKey, + generation: u64, + result: anyhow::Result, + cx: &mut App, + ) { + let Some(server) = self + .servers + .get_mut(key) + .filter(|s| s.generation == generation && s.phase == Phase::Starting) + else { + return; + }; + let Some(client) = server.client.clone() else { + return; + }; + let init = result.and_then(|v| { + serde_json::from_value::(v).map_err(Into::into) + }); + match init { + Ok(init) => { + server.encoding = Encoding::from_kind(init.capabilities.position_encoding.as_ref()); + server.caps = Rc::new(init.capabilities); + server.phase = Phase::Running; + client.notify_raw("initialized", json!({})); + // Servers that pull their settings (pyright) wait for this + // before asking; the rest take them as sent. + client.notify_raw( + "workspace/didChangeConfiguration", + json!({ "settings": server.spec.settings() }), + ); + client.open_gate(); + log::info!("lsp: {} is ready", server.label); + let paths: Vec = self + .docs + .iter() + .filter(|(_, d)| &d.server == key) + .map(|(p, _)| p.clone()) + .collect(); + for path in paths { + self.refresh_symbols(&path, cx); + } + } + Err(e) => { + log::warn!("lsp: {} failed to initialize: {e:#}", server.label); + // Treated as a crash: the exit that follows (or the kill) + // decides whether it is worth another try. + client.kill(); + } + } + self.notify_docs_of(key, cx); + } + + fn handle_event(&mut self, key: &ServerKey, generation: u64, event: ServerEvent, cx: &mut App) { + let Some(server) = self.servers.get(key).filter(|s| s.generation == generation) else { + return; + }; + let Some(client) = server.client.clone() else { + return; + }; + match event { + ServerEvent::Notification { method, params } => match method.as_str() { + "textDocument/publishDiagnostics" => { + if let Ok(params) = + serde_json::from_value::(params) + { + let encoding = server.encoding; + self.publish_diagnostics(params, encoding, cx); + } + } + "window/showMessage" | "window/logMessage" => { + let message = params.get("message").and_then(Value::as_str).unwrap_or(""); + let error = params.get("type").and_then(Value::as_i64) == Some(1); + if error { + log::warn!("lsp {}: {message}", server.label); + } else { + log::debug!("lsp {}: {message}", server.label); + } + } + _ => {} + }, + ServerEvent::Request { id, method, params } => { + let reply = match method.as_str() { + "workspace/configuration" => { + let settings = server.spec.settings(); + let items = params + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + let section = item.get("section").and_then(Value::as_str); + servers::configuration_item(&settings, section) + }) + .collect() + }) + .unwrap_or_default(); + Ok(Value::Array(items)) + } + "client/registerCapability" + | "client/unregisterCapability" + | "window/workDoneProgress/create" + | "window/showMessageRequest" + | "workspace/diagnostic/refresh" + | "workspace/semanticTokens/refresh" + | "workspace/inlayHint/refresh" + | "workspace/codeLens/refresh" => Ok(Value::Null), + "workspace/workspaceFolders" => Ok(json!([{ + "uri": path_to_uri(&key.root).map(|u| u.as_str().to_owned()), + "name": folder_name(&key.root), + }])), + "workspace/applyEdit" => { + let encoding = server.encoding; + match serde_json::from_value::(params) + { + Ok(params) => { + // Measured against what this server last + // heard: a buffer typed in since would take + // the edit at the wrong places. + let per_file = workspace_edit_files(params.edit); + match self.stale_target(&per_file, None, cx) { + Some(path) => Ok(json!({ + "applied": false, + "failureReason": format!( + "{} changed since the server last saw it", + path.display() + ), + })), + None => { + spawn_apply(per_file, encoding, cx); + Ok(json!({ "applied": true })) + } + } + } + Err(e) => { + Ok(json!({ "applied": false, "failureReason": e.to_string() })) + } + } + } + other => Err(ResponseError::method_not_found(other)), + }; + client.respond(id, reply); + } + ServerEvent::Exited => self.server_exited(key, cx), + } + } + + fn publish_diagnostics( + &mut self, + params: lsp_types::PublishDiagnosticsParams, + encoding: Encoding, + cx: &mut App, + ) { + let path = self + .docs + .iter() + .find(|(_, d)| d.uri == params.uri) + .map(|(p, _)| p.clone()) + .or_else(|| uri_to_path(¶ms.uri).filter(|p| self.docs.contains_key(p))); + let Some(doc) = path.and_then(|p| self.docs.get_mut(&p)) else { + return; + }; + doc.problems = convert::count_problems(¶ms.diagnostics); + doc.diagnostics = params.diagnostics; + // Positions for a text the buffer no longer holds would underline the + // wrong characters. The server publishes again once it has caught + // up, so these are counted and kept for code actions, not drawn; the + // underlines already shown have been moving with the edits. + let stale = doc.dirty || params.version.is_some_and(|v| v != doc.version); + let Some(input) = doc.owner.upgrade() else { + return; + }; + let diagnostics = doc.diagnostics.clone(); + let sent = doc.sent.clone(); + input.update(cx, |state, cx| { + let text = state.text().clone(); + if !stale && sent.as_ref() == Some(&text) { + let mapped = convert::diagnostics_to_editor(&text, &diagnostics, encoding); + if let Some(set) = state.diagnostics_mut() { + set.replace_all(&text, mapped); + } + } + cx.notify(); + }); + } + + fn server_exited(&mut self, key: &ServerKey, cx: &mut App) { + let Some(server) = self.servers.get_mut(key) else { + return; + }; + if let Some(client) = server.client.take() { + client.kill(); + } + if server.phase == Phase::Stopping { + self.servers.remove(key); + return; + } + if server.phase == Phase::Starting && server.crashes.is_empty() { + // Gone before it ever answered `initialize`, on its first try: + // not a crash a restart would fix, but a program that cannot run + // here — rustup's `rust-analyzer` proxy without the component + // installed exits exactly like this. Treated as missing, so the + // status bar says so and the next file tries again later. + log::info!("lsp: {} exited before initializing", server.label); + self.missing.insert(server.spec.name, Instant::now()); + self.servers.remove(key); + let orphans: Vec = self + .docs + .iter() + .filter(|(_, d)| &d.server == key) + .map(|(p, _)| p.clone()) + .collect(); + for path in orphans { + if let Some(input) = self.docs.remove(&path).and_then(|d| d.owner.upgrade()) { + providers::uninstall(&input, cx); + } + } + return; + } + let now = Instant::now(); + server + .crashes + .retain(|at| now.duration_since(*at) < CRASH_WINDOW); + server.crashes.push(now); + let has_docs = self.docs.values().any(|d| &d.server == key); + let server = self.servers.get_mut(key).expect("looked up above"); + log::warn!( + "lsp: {} exited unexpectedly ({} time(s) recently)", + server.label, + server.crashes.len() + ); + if !has_docs { + self.servers.remove(key); + return; + } + if server.crashes.len() >= MAX_CRASHES { + server.phase = Phase::Dead; + self.notify_docs_of(key, cx); + return; + } + server.phase = Phase::Starting; + let spec = server.spec; + let delay = Duration::from_secs(server.crashes.len() as u64); + let key = key.clone(); + self.notify_docs_of(&key, cx); + cx.spawn(async move |cx| { + cx.background_executor().timer(delay).await; + cx.update(|cx| LspStore::update(cx, |store, cx| store.restart(&key, spec, cx))); + }) + .detach(); + } + + fn restart(&mut self, key: &ServerKey, spec: &'static ServerSpec, cx: &mut App) { + if !self.docs.values().any(|d| &d.server == key) { + self.servers.remove(key); + return; + } + // Forget the grace period for a program that vanished: the restart + // is the check. + self.missing.remove(spec.name); + if !self.start_server(key.clone(), spec, cx) { + if let Some(server) = self.servers.get_mut(key) { + server.phase = Phase::Dead; + } + self.notify_docs_of(key, cx); + return; + } + let Some(client) = self.servers.get(key).and_then(|s| s.client.clone()) else { + return; + }; + for doc in self.docs.values_mut().filter(|d| &d.server == key) { + let Some(input) = doc.owner.upgrade() else { + continue; + }; + let text = input.read(cx).text().clone(); + doc.version = 0; + doc.dirty = false; + doc.sent = Some(text.clone()); + send_did_open(&client, doc, &text); + } + } + + fn shutdown_server(&mut self, key: &ServerKey, cx: &mut App) { + let Some(server) = self.servers.get_mut(key) else { + return; + }; + let Some(client) = server.client.clone() else { + self.servers.remove(key); + return; + }; + server.phase = Phase::Stopping; + log::info!( + "lsp: shutting down {} for {}", + server.label, + key.root.display() + ); + let generation = server.generation; + let key = key.clone(); + cx.spawn(async move |cx| { + let executor = cx.background_executor().clone(); + // Two seconds to say it is done; it is told to exit either way. + let _ = client + .request_raw_within("shutdown", Value::Null, Duration::from_secs(2)) + .await; + client.notify_raw("exit", Value::Null); + for _ in 0..20 { + if client.reap_if_exited() { + break; + } + executor.timer(Duration::from_millis(50)).await; + } + client.kill(); + cx.update(|cx| { + if let Some(store) = try_store_mut(cx) + && store + .servers + .get(&key) + .is_some_and(|s| s.generation == generation && s.phase == Phase::Stopping) + { + store.servers.remove(&key); + } + }); + }) + .detach(); + } + + /// The app is quitting: every server is told to exit and, whatever it + /// does with that, is gone before tty7 is. + fn on_quit(cx: &mut App) -> impl std::future::Future + use<> { + let clients: Vec> = try_store_mut(cx) + .map(|store| { + store + .servers + .drain() + .filter_map(|(_, s)| s.client) + .collect() + }) + .unwrap_or_default(); + for client in &clients { + client.notify_raw("exit", Value::Null); + } + let executor = cx.background_executor().clone(); + async move { + // gpui gives quit handlers a fraction of a second; most of it goes + // to letting servers leave on their own. + for _ in 0..3 { + if clients.iter().all(|c| c.has_exited()) { + break; + } + executor.timer(Duration::from_millis(40)).await; + } + for client in &clients { + client.kill(); + } + } + } + + fn notify_docs_of(&self, key: &ServerKey, cx: &mut App) { + let inputs: Vec> = self + .docs + .values() + .filter(|d| &d.server == key) + .filter_map(|d| d.owner.upgrade()) + .collect(); + for input in inputs { + input.update(cx, |_, cx| cx.notify()); + } + } +} + +fn send_did_open(client: &LspClient, doc: &Doc, text: &Rope) { + client.notify::( + lsp_types::DidOpenTextDocumentParams { + text_document: lsp_types::TextDocumentItem::new( + doc.uri.clone(), + doc.language_id.to_owned(), + doc.version, + text.to_string(), + ), + }, + ); +} + +fn clear_diagnostics(input: &Entity, cx: &mut App) { + input.update(cx, |state, cx| { + if let Some(set) = state.diagnostics_mut() { + set.clear(); + } + cx.notify(); + }); +} + +fn folder_name(root: &Path) -> String { + root.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| root.display().to_string()) +} + +/// What tty7 tells a server it can do. Snippets are declined — the editor +/// has no snippet engine — so servers send plain insert text where they can; +/// the few that send snippets anyway are flattened by +/// [`convert::snippet_to_plain`]. +fn initialize_params(root: &Path, spec: &ServerSpec) -> Value { + let uri = path_to_uri(root).map(|u| u.as_str().to_owned()); + json!({ + "processId": std::process::id(), + "clientInfo": { "name": "tty7", "version": env!("CARGO_PKG_VERSION") }, + "rootUri": uri, + "rootPath": root.display().to_string(), + "workspaceFolders": [{ "uri": uri, "name": folder_name(root) }], + "initializationOptions": spec.initialization_options(), + "capabilities": { + "general": { "positionEncodings": ["utf-16"] }, + "workspace": { + "applyEdit": true, + "workspaceEdit": { "documentChanges": true }, + "configuration": true, + "workspaceFolders": true, + }, + "textDocument": { + "synchronization": { "didSave": true, "willSave": false, "dynamicRegistration": false }, + "completion": { + "completionItem": { + "snippetSupport": false, + "documentationFormat": ["markdown", "plaintext"], + "insertReplaceSupport": false, + "resolveSupport": { + "properties": ["documentation", "detail", "additionalTextEdits"], + }, + }, + "contextSupport": true, + }, + "hover": { "contentFormat": ["markdown", "plaintext"] }, + "signatureHelp": { + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": { "labelOffsetSupport": true }, + "activeParameterSupport": true, + }, + }, + "documentSymbol": { "hierarchicalDocumentSymbolSupport": true }, + "definition": { "linkSupport": true }, + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", "quickfix", "refactor", "refactor.extract", + "refactor.inline", "refactor.rewrite", "source", + "source.organizeImports", "source.fixAll", + ], + }, + }, + "dataSupport": true, + "resolveSupport": { "properties": ["edit"] }, + }, + "formatting": { "dynamicRegistration": false }, + "rename": { "prepareSupport": false }, + "publishDiagnostics": { "versionSupport": true, "relatedInformation": false }, + }, + "window": { "workDoneProgress": false }, + }, + }) +} + +/// Applies a server's workspace edit: to the buffer holding a file when one +/// does, straight to disk otherwise. Run on a later turn of the event loop, +/// never inside the update that asked for it — a buffer's own window may be +/// the one being updated. +/// +/// `baseline` is what every open document held when the request that +/// produced the edit went out ([`LspStore::snapshot`]). If any buffer the +/// edit touches holds something else now — typed in, opened or closed since +/// — nothing at all is applied: half a rename is worse than none. Without a +/// baseline, the text the server last heard is the measure. Returns whether +/// the edit was applied. +pub(crate) fn apply_workspace_edit( + edit: lsp_types::WorkspaceEdit, + encoding: Encoding, + baseline: Option>, + cx: &mut App, +) -> bool { + let per_file = workspace_edit_files(edit); + let stale = cx + .try_global::() + .and_then(|store| store.stale_target(&per_file, baseline.as_ref(), cx)); + if let Some(path) = stale { + log::info!( + "lsp: not applying an edit: {} changed since it was asked for", + path.display() + ); + return false; + } + spawn_apply(per_file, encoding, cx); + true +} + +/// Whether every text in `now` is the one in `baseline`, for the files an +/// edit touches: the first that is not. A file open in only one of the two +/// was opened or closed in between, which counts as changed. +pub(crate) fn first_stale( + targets: &[PathBuf], + now: &HashMap, + baseline: &HashMap, +) -> Option { + targets + .iter() + .find(|path| match (now.get(*path), baseline.get(*path)) { + (Some(now), Some(then)) => now != then, + (None, None) => false, + _ => true, + }) + .cloned() +} + +impl LspStore { + /// What every open document's buffer holds right now — the baseline a + /// request that answers with edits is checked against. + pub(crate) fn snapshot(cx: &App) -> HashMap { + let Some(store) = cx.try_global::() else { + return HashMap::new(); + }; + store + .docs + .iter() + .filter_map(|(path, doc)| { + Some((path.clone(), doc.owner.upgrade()?.read(cx).text().clone())) + }) + .collect() + } + + fn stale_target( + &self, + per_file: &[(Uri, Vec)], + baseline: Option<&HashMap>, + cx: &App, + ) -> Option { + let targets: Vec = per_file + .iter() + .filter_map(|(u, _)| uri_to_path(u)) + .collect(); + let open = |path: &PathBuf| { + let doc = self.docs.get(path)?; + Some((doc, doc.owner.upgrade()?)) + }; + let now: HashMap = targets + .iter() + .filter_map(|p| Some((p.clone(), open(p)?.1.read(cx).text().clone()))) + .collect(); + let server_heard: HashMap; + let baseline = match baseline { + Some(baseline) => baseline, + None => { + server_heard = targets + .iter() + .filter_map(|p| { + let (doc, _) = open(p)?; + Some((p.clone(), doc.sent.clone().filter(|_| !doc.dirty)?)) + }) + .collect(); + &server_heard + } + }; + first_stale(&targets, &now, baseline) + } +} + +fn spawn_apply(per_file: Vec<(Uri, Vec)>, encoding: Encoding, cx: &mut App) { + cx.spawn(async move |cx| { + for (uri, edits) in per_file { + let Some(path) = uri_to_path(&uri) else { + continue; + }; + let target = cx.update(|cx| { + let store = cx.try_global::()?; + let doc = store.docs.get(&path)?; + let input = doc.owner.upgrade()?; + let window = doc + .window + .or_else(|| cx.active_window()) + .or_else(|| cx.windows().first().copied())?; + Some((input, window)) + }); + match target { + Some((input, window)) => { + let _ = window.update(cx, |_, window, cx| { + input.update(cx, |state, cx| { + let text = state.text().clone(); + let edits = convert::edits_to_editor(&text, &edits, encoding); + state.apply_lsp_edits(&edits, window, cx); + }); + }); + } + None => { + let written = cx + .background_spawn(async move { + let text = std::fs::read_to_string(&path)?; + let new = convert::apply_edits(&text, &edits, encoding); + crate::core::config::write_atomic(&path, new.as_bytes())?; + anyhow::Ok(()) + }) + .await; + if let Err(e) = written { + log::warn!("lsp: could not apply an edit to {}: {e:#}", uri.as_str()); + } + } + } + } + }) + .detach(); +} + +/// A workspace edit's text edits, file by file. Creating, renaming and +/// deleting files are not supported (tty7 does not advertise them). +pub(crate) fn workspace_edit_files( + edit: lsp_types::WorkspaceEdit, +) -> Vec<(Uri, Vec)> { + use lsp_types::{DocumentChangeOperation, DocumentChanges, OneOf}; + let mut out: Vec<(Uri, Vec)> = Vec::new(); + let mut push = + |uri: Uri, edits: Vec| match out.iter_mut().find(|(u, _)| *u == uri) { + Some((_, existing)) => existing.extend(edits), + None => out.push((uri, edits)), + }; + let text_edits = |edits: Vec>| { + edits + .into_iter() + .map(|e| match e { + OneOf::Left(e) => e, + OneOf::Right(a) => a.text_edit, + }) + .collect::>() + }; + match edit.document_changes { + Some(DocumentChanges::Edits(edits)) => { + for e in edits { + push(e.text_document.uri, text_edits(e.edits)); + } + } + Some(DocumentChanges::Operations(ops)) => { + for op in ops { + if let DocumentChangeOperation::Edit(e) = op { + push(e.text_document.uri, text_edits(e.edits)); + } + } + } + None => { + let mut changes: Vec<_> = edit.changes.unwrap_or_default().into_iter().collect(); + changes.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + for (uri, edits) in changes { + push(uri, edits); + } + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/lsp/providers.rs b/src/ui/lsp/providers.rs new file mode 100644 index 00000000..fe19bf72 --- /dev/null +++ b/src/ui/lsp/providers.rs @@ -0,0 +1,648 @@ +//! gpui-component's editor hooks, answered by a language server. +//! +//! One [`LspProvider`] per buffer, installed into the buffer's +//! `InputState::lsp` while the buffer has a server and taken out when it +//! stops having one. Every request carries positions through +//! [`super::convert`]: gpui-component counts columns in chars, a server in +//! UTF-16 units. +//! +//! The completion, hover and definition hooks run inside the buffer's own +//! update, so they must not read the buffer: the text they need is handed in. + +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use anyhow::Result; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity, Window}; +use gpui_component::input::{ + CodeActionProvider, CompletionProvider, DefinitionProvider, HoverProvider, InputState, +}; +use gpui_component::{Rope, RopeExt as _}; +use lsp_types::{ + CodeAction, CodeActionOrCommand, CompletionContext, CompletionResponse, CompletionTextEdit, + CompletionTriggerKind, InsertTextFormat, LocationLink, +}; + +use super::convert::{self, Encoding}; +use super::{DocContext, Freshen, LspStore}; +use crate::ui::app::Tty7App; + +/// The most completion items handed to the menu. A server asked at the top +/// of a file can offer thousands; the menu filters as you type, and the +/// server is asked again on the next keystroke anyway. +const MAX_COMPLETIONS: usize = 300; + +pub(crate) struct LspProvider { + /// Every open buffer's text when the code-action menu was last filled: + /// an action picked from it is applied only if what it edits is + /// unchanged since. + actions_baseline: std::cell::RefCell>>, + path: PathBuf, + /// The buffer this provider serves — always the document's owner. + input: gpui::EntityId, + app: WeakEntity, +} + +pub(super) fn install( + input: &Entity, + path: &Path, + app: WeakEntity, + cx: &mut App, +) { + let provider = Rc::new(LspProvider { + actions_baseline: Default::default(), + path: path.to_path_buf(), + input: input.entity_id(), + app, + }); + input.update(cx, |state, cx| { + state.lsp.completion_provider = Some(provider.clone()); + state.lsp.hover_provider = Some(provider.clone()); + state.lsp.definition_provider = Some(provider.clone()); + state.lsp.code_action_providers = vec![provider]; + cx.notify(); + }); +} + +pub(super) fn uninstall(input: &Entity, cx: &mut App) { + input.update(cx, |state, cx| { + state.lsp.completion_provider = None; + state.lsp.hover_provider = None; + state.lsp.definition_provider = None; + state.lsp.code_action_providers.clear(); + cx.notify(); + }); +} + +impl LspProvider { + fn context(&self, sync: Freshen<'_>, window: &Window, cx: &mut App) -> Option { + LspStore::context( + &self.path, + self.input, + sync, + Some(window.window_handle()), + cx, + ) + } +} + +fn not_available() -> Task> { + Task::ready(Ok(T::default())) +} + +impl CompletionProvider for LspProvider { + fn completions( + &self, + text: &Rope, + offset: usize, + _trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return Task::ready(Ok(CompletionResponse::Array(Vec::new()))); + }; + if doc.caps.completion_provider.is_none() { + return Task::ready(Ok(CompletionResponse::Array(Vec::new()))); + } + // gpui-component reports the word typed so far as the "trigger + // character"; a server wants the character that actually triggered. + let point = text.offset_to_point(offset); + let line = text.slice_line(point.row).to_string(); + let before = line.get(..point.column).and_then(|l| l.chars().last()); + let triggers = LspStore::completion_triggers(&self.path, cx); + let context = match before.map(String::from) { + Some(c) if triggers.contains(&c) => CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(c), + }, + _ => CompletionContext { + trigger_kind: CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + }; + let params = lsp_types::CompletionParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: Some(context), + }; + let request = doc.client.request::(params); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let items = match request.await? { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + }; + let items = items + .into_iter() + .take(MAX_COMPLETIONS) + .map(|item| completion_to_editor(item, &text, encoding)) + .collect(); + Ok(CompletionResponse::Array(items)) + }) + } + + fn resolve_completion( + &self, + item: lsp_types::CompletionItem, + text: &Rope, + cx: &mut App, + ) -> Task> { + // Resolving asks nothing of the text, which the caller may be in the + // middle of changing: nothing is sent first. + let Some(doc) = LspStore::context(&self.path, self.input, Freshen::Skip, None, cx) else { + return Task::ready(Ok(item)); + }; + let resolvable = doc + .caps + .completion_provider + .as_ref() + .is_some_and(|c| c.resolve_provider == Some(true)); + if !resolvable { + return Task::ready(Ok(item)); + } + let request = doc + .client + .request::(item.clone()); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + Ok(match request.await { + Ok(resolved) => merge_resolved(item, resolved, &text, encoding), + Err(e) => { + log::debug!("lsp: completionItem/resolve: {e:#}"); + item + } + }) + }) + } + + fn is_completion_trigger( + &self, + _offset: usize, + new_text: &str, + cx: &mut Context, + ) -> bool { + let Some(last) = new_text.chars().last() else { + return false; + }; + if last.is_alphanumeric() || last == '_' { + return true; + } + LspStore::completion_triggers(&self.path, cx).contains(&last.to_string()) + } +} + +/// A completion item as the editor's menu can insert it: ranges in editor +/// columns, snippets flattened to plain text. +pub(crate) fn completion_to_editor( + mut item: lsp_types::CompletionItem, + text: &Rope, + encoding: Encoding, +) -> lsp_types::CompletionItem { + let snippet = item.insert_text_format == Some(InsertTextFormat::SNIPPET); + if snippet { + item.insert_text = item.insert_text.map(|t| convert::snippet_to_plain(&t)); + item.insert_text_format = Some(InsertTextFormat::PLAIN_TEXT); + } + item.text_edit = item.text_edit.map(|edit| match edit { + CompletionTextEdit::Edit(mut e) => { + e.range = convert::range_to_editor(text, e.range, encoding); + if snippet { + e.new_text = convert::snippet_to_plain(&e.new_text); + } + CompletionTextEdit::Edit(e) + } + // Declined in the client capabilities, but a server may send one + // anyway: replacing is what accepting a completion does here. + CompletionTextEdit::InsertAndReplace(e) => CompletionTextEdit::Edit(lsp_types::TextEdit { + range: convert::range_to_editor(text, e.replace, encoding), + new_text: if snippet { + convert::snippet_to_plain(&e.new_text) + } else { + e.new_text + }, + }), + }); + item.additional_text_edits = item + .additional_text_edits + .map(|edits| additional_edits_to_editor(edits, text, encoding)); + item +} + +fn additional_edits_to_editor( + edits: Vec, + text: &Rope, + encoding: Encoding, +) -> Vec { + edits + .into_iter() + .map(|e| lsp_types::TextEdit { + range: convert::range_to_editor(text, e.range, encoding), + new_text: e.new_text, + }) + .collect() +} + +/// What `completionItem/resolve` adds to an item the menu already holds. +/// +/// Only the fields resolving is for are taken: the item the server echoes +/// back carries the editor-column ranges it was sent, which converting again +/// would shift. An item resolved without imports says so with an empty list, +/// so accepting it does not ask again. +pub(crate) fn merge_resolved( + mut item: lsp_types::CompletionItem, + resolved: lsp_types::CompletionItem, + text: &Rope, + encoding: Encoding, +) -> lsp_types::CompletionItem { + item.documentation = resolved.documentation.or(item.documentation); + item.detail = resolved.detail.or(item.detail); + item.command = resolved.command.or(item.command); + item.additional_text_edits = Some( + resolved + .additional_text_edits + .map(|edits| additional_edits_to_editor(edits, text, encoding)) + .or(item.additional_text_edits) + .unwrap_or_default(), + ); + item +} + +impl HoverProvider for LspProvider { + fn hover( + &self, + text: &Rope, + offset: usize, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return not_available(); + }; + if doc.caps.hover_provider.is_none() { + return not_available(); + } + let params = lsp_types::HoverParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + }; + let request = doc + .client + .request::(params); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let Some(mut hover) = request.await? else { + return Ok(None); + }; + if hover_is_empty(&hover.contents) { + return Ok(None); + } + hover.range = hover + .range + .map(|r| convert::range_to_editor(&text, r, encoding)); + hover.contents = fence_language_strings(hover.contents); + Ok(Some(hover)) + }) + } +} + +fn hover_is_empty(contents: &lsp_types::HoverContents) -> bool { + use lsp_types::{HoverContents, MarkedString}; + let blank = |m: &MarkedString| match m { + MarkedString::String(s) => s.trim().is_empty(), + MarkedString::LanguageString(l) => l.value.trim().is_empty(), + }; + match contents { + HoverContents::Scalar(m) => blank(m), + HoverContents::Array(a) => a.iter().all(blank), + HoverContents::Markup(m) => m.value.trim().is_empty(), + } +} + +/// The popover renders Markdown and shows a bare `{ language, value }` pair as +/// plain text; as a fenced block it gets its code styling. +fn fence_language_strings(contents: lsp_types::HoverContents) -> lsp_types::HoverContents { + use lsp_types::{HoverContents, MarkedString}; + let fence = |m: MarkedString| match m { + MarkedString::LanguageString(l) => { + MarkedString::String(format!("```{}\n{}\n```", l.language, l.value)) + } + other => other, + }; + match contents { + HoverContents::Scalar(m) => HoverContents::Scalar(fence(m)), + HoverContents::Array(a) => HoverContents::Array(a.into_iter().map(fence).collect()), + markup => markup, + } +} + +impl DefinitionProvider for LspProvider { + fn definitions( + &self, + text: &Rope, + offset: usize, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(doc) = self.context(Freshen::Text(text), window, cx) else { + return not_available(); + }; + let request = definition_request(&doc, text, offset); + let text = text.clone(); + let encoding = doc.encoding; + cx.background_spawn(async move { + let mut links = request.await?; + // Only the origin is in this buffer; the target is converted when + // it is followed, against the file it is in. + for link in &mut links { + link.origin_selection_range = link + .origin_selection_range + .map(|r| convert::range_to_editor(&text, r, encoding)); + } + Ok(links) + }) + } + + fn open_definition(&self, location: &LocationLink, window: &mut Window, cx: &mut App) -> bool { + let Some(path) = super::uri_to_path(&location.target_uri) else { + // An `http:` target and the like: gpui-component opens it. + return false; + }; + let position = location.target_selection_range.start; + let encoding = LspStore::context(&self.path, self.input, Freshen::Skip, None, cx) + .map(|d| d.encoding) + .unwrap_or_default(); + let app = self.app.clone(); + // Deferred: this runs inside the buffer's update, and opening a file + // — even this same one — updates buffers. + window.defer(cx, move |window, cx| { + let _ = app.update(cx, |app, cx| { + app.lsp_open_location(path, position, encoding, window, cx) + }); + }); + true + } +} + +/// `textDocument/definition` at `offset`, answered as location links +/// whatever shape the server replied in. +pub(crate) fn definition_request( + doc: &DocContext, + text: &Rope, + offset: usize, +) -> impl std::future::Future>> + use<> { + let params = lsp_types::GotoDefinitionParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(text, offset, doc.encoding), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let supported = doc.caps.definition_provider.is_some(); + let request = doc + .client + .request::(params); + async move { + if !supported { + return Ok(Vec::new()); + } + use lsp_types::GotoDefinitionResponse as R; + let to_link = |l: lsp_types::Location| LocationLink { + origin_selection_range: None, + target_uri: l.uri, + target_range: l.range, + target_selection_range: l.range, + }; + Ok(match request.await? { + Some(R::Scalar(l)) => vec![to_link(l)], + Some(R::Array(ls)) => ls.into_iter().map(to_link).collect(), + Some(R::Link(links)) => links, + None => Vec::new(), + }) + } +} + +impl CodeActionProvider for LspProvider { + fn id(&self) -> SharedString { + "lsp".into() + } + + fn code_actions( + &self, + state: Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let text = state.read(cx).text().clone(); + let Some(doc) = self.context(Freshen::Text(&text), window, cx) else { + return not_available(); + }; + if doc.caps.code_action_provider.is_none() { + return not_available(); + } + *self.actions_baseline.borrow_mut() = Some(LspStore::snapshot(cx)); + let lsp_range = convert::offsets_to_lsp_range(&text, range, doc.encoding); + // The diagnostics the range touches, which is what turns a server's + // quick fixes on. + let diagnostics = doc + .diagnostics + .iter() + .filter(|d| { + d.range.start.line <= lsp_range.end.line && d.range.end.line >= lsp_range.start.line + }) + .cloned() + .collect(); + let params = lsp_types::CodeActionParams { + text_document: lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + range: lsp_range, + context: lsp_types::CodeActionContext { + diagnostics, + only: None, + trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + let request = doc + .client + .request::(params); + cx.background_spawn(async move { + Ok(request + .await? + .unwrap_or_default() + .into_iter() + .map(|a| match a { + CodeActionOrCommand::CodeAction(a) => a, + CodeActionOrCommand::Command(command) => CodeAction { + title: command.title.clone(), + command: Some(command), + ..Default::default() + }, + }) + .filter(|a| a.disabled.is_none()) + .collect()) + }) + } + + fn perform_code_action( + &self, + _state: Entity, + action: CodeAction, + _push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task> { + // Inside the buffer's update: it cannot be read, and the actions on + // offer were computed against text that was already sent. + let Some(doc) = self.context(Freshen::Skip, window, cx) else { + return Task::ready(Ok(())); + }; + let resolvable = matches!( + &doc.caps.code_action_provider, + Some(lsp_types::CodeActionProviderCapability::Options(o)) + if o.resolve_provider == Some(true) + ); + // Taken when the menu was filled, which is also when an action that + // came with its edit was computed. Without one there is nothing to + // vouch for the open buffers, and an edit to any of them is refused. + let baseline = self + .actions_baseline + .borrow_mut() + .take() + .unwrap_or_default(); + let client = doc.client.clone(); + let encoding = doc.encoding; + window.spawn(cx, async move |cx| { + let action = if action.edit.is_none() && action.data.is_some() && resolvable { + client + .request::(action.clone()) + .await + .unwrap_or(action) + } else { + action + }; + if let Some(edit) = action.edit { + let applied = cx.update(|_, cx| { + super::apply_workspace_edit(edit, encoding, Some(baseline), cx) + })?; + if !applied { + // Its command would act on the same stale ground. + return Ok(()); + } + } + if let Some(command) = action.command { + // Whatever this changes comes back as `workspace/applyEdit`. + client + .request::( + lsp_types::ExecuteCommandParams { + command: command.command, + arguments: command.arguments.unwrap_or_default(), + work_done_progress_params: Default::default(), + }, + ) + .await?; + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{CompletionItem, Position, Range as LspRange, TextEdit}; + + #[test] + fn a_snippet_completion_arrives_as_plain_text_in_editor_columns() { + let text = Rope::from("let 🎉 = fo\n"); + let item = CompletionItem { + label: "foo".into(), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + // UTF-16: `let ` 4, emoji 2, ` = ` 3 → `fo` at 9..11. + range: LspRange::new(Position::new(0, 9), Position::new(0, 11)), + new_text: "foo(${1:x})$0".into(), + })), + ..Default::default() + }; + let item = completion_to_editor(item, &text, Encoding::Utf16); + let Some(CompletionTextEdit::Edit(edit)) = item.text_edit else { + panic!("expected a plain edit"); + }; + assert_eq!(edit.new_text, "foo(x)"); + assert_eq!( + edit.range, + LspRange::new(Position::new(0, 8), Position::new(0, 10)) + ); + assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT)); + } + + #[test] + fn resolving_adds_imports_in_editor_columns_and_keeps_the_edit() { + let text = Rope::from("use é;\nfoo\n"); + let item = CompletionItem { + label: "foo".into(), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: LspRange::new(Position::new(1, 0), Position::new(1, 3)), + new_text: "foo".into(), + })), + ..Default::default() + }; + let resolved = CompletionItem { + label: "foo".into(), + documentation: Some(lsp_types::Documentation::String("Does foo.".into())), + // Echoed back as sent; must not be converted a second time. + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: LspRange::new(Position::new(9, 9), Position::new(9, 9)), + new_text: "wrong".into(), + })), + additional_text_edits: Some(vec![TextEdit { + // After `use é;` — UTF-16 column 6 is char column 6 here. + range: LspRange::new(Position::new(0, 6), Position::new(0, 6)), + new_text: "\nuse bar::foo;".into(), + }]), + ..Default::default() + }; + let merged = merge_resolved(item.clone(), resolved, &text, Encoding::Utf16); + assert_eq!(merged.text_edit, item.text_edit); + assert!(merged.documentation.is_some()); + assert_eq!( + merged.additional_text_edits.unwrap()[0].new_text, + "\nuse bar::foo;" + ); + // Nothing to add: said so, so accepting does not ask again. + let bare = merge_resolved(item.clone(), item, &text, Encoding::Utf16); + assert_eq!(bare.additional_text_edits, Some(vec![])); + } + + #[test] + fn language_strings_in_a_hover_become_fenced_code() { + let contents = lsp_types::HoverContents::Scalar(lsp_types::MarkedString::LanguageString( + lsp_types::LanguageString { + language: "rust".into(), + value: "fn main()".into(), + }, + )); + let lsp_types::HoverContents::Scalar(lsp_types::MarkedString::String(s)) = + fence_language_strings(contents) + else { + panic!("expected a markdown string"); + }; + assert_eq!(s, "```rust\nfn main()\n```"); + assert!(hover_is_empty(&lsp_types::HoverContents::Array(vec![]))); + } +} diff --git a/src/ui/lsp/rpc.rs b/src/ui/lsp/rpc.rs new file mode 100644 index 00000000..2d641a1b --- /dev/null +++ b/src/ui/lsp/rpc.rs @@ -0,0 +1,294 @@ +//! JSON-RPC 2.0 as a language server speaks it over stdio: every message is a +//! `Content-Length` header block followed by exactly that many bytes of JSON. +//! +//! Nothing here touches a process or a thread, so the framing — the part a +//! pipe hands over in arbitrary pieces — can be tested byte by byte. + +use serde_json::{Value, json}; + +/// The id tty7 gives its own requests. A server's requests to us may carry a +/// string id instead; those are echoed back as the raw [`Value`]. +pub(crate) type RequestId = i64; + +/// Frames one message for the wire. +pub(crate) fn encode(message: &Value) -> Vec { + let body = serde_json::to_vec(message).unwrap_or_default(); + let mut out = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + out.extend_from_slice(&body); + out +} + +pub(crate) fn request(id: RequestId, method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) +} + +pub(crate) fn notification(method: &str, params: Value) -> Value { + json!({ "jsonrpc": "2.0", "method": method, "params": params }) +} + +pub(crate) fn response(id: Value, result: Result) -> Value { + match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(e) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": e.code, "message": e.message }, + }), + } +} + +/// The `error` member of a response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResponseError { + pub(crate) code: i64, + pub(crate) message: String, +} + +impl ResponseError { + pub(crate) const METHOD_NOT_FOUND: i64 = -32601; + + pub(crate) fn method_not_found(method: &str) -> Self { + Self { + code: Self::METHOD_NOT_FOUND, + message: format!("tty7 does not handle {method}"), + } + } +} + +impl std::fmt::Display for ResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.message, self.code) + } +} + +impl std::error::Error for ResponseError {} + +/// One message from the server, sorted by what it asks of us. +#[derive(Debug, PartialEq)] +pub(crate) enum Incoming { + /// The answer to one of our requests. + Response { + id: RequestId, + result: Result, + }, + /// The server asking us something; it waits for a [`response`]. + Request { + id: Value, + method: String, + params: Value, + }, + Notification { + method: String, + params: Value, + }, +} + +/// Sorts a decoded body. `None` for anything that is not a message this +/// client can act on — a response to an id it never sent, a batch, garbage. +pub(crate) fn classify(body: &[u8]) -> Option { + let mut value: Value = serde_json::from_slice(body).ok()?; + let obj = value.as_object_mut()?; + let method = obj.get("method").and_then(Value::as_str).map(str::to_owned); + let params = obj.remove("params").unwrap_or(Value::Null); + match (method, obj.remove("id")) { + (Some(method), Some(id)) if !id.is_null() => Some(Incoming::Request { id, method, params }), + (Some(method), _) => Some(Incoming::Notification { method, params }), + (None, Some(id)) => { + let id = id.as_i64()?; + let result = match obj.remove("error") { + Some(Value::Object(err)) => Err(ResponseError { + code: err.get("code").and_then(Value::as_i64).unwrap_or(0), + message: err + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + }), + _ => Ok(obj.remove("result").unwrap_or(Value::Null)), + }; + Some(Incoming::Response { id, result }) + } + (None, None) => None, + } +} + +/// The longest header block believed. A server that sends more than this +/// without a blank line is not speaking LSP, and the buffer must not grow +/// without bound waiting for one. +const MAX_HEADER: usize = 8 * 1024; + +/// Reassembles messages from whatever pieces a pipe delivers. +#[derive(Default)] +pub(crate) struct FrameReader { + buf: Vec, +} + +impl FrameReader { + pub(crate) fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// The next complete body, if one has arrived. A header block without a + /// usable `Content-Length` is dropped and reading carries on after it — + /// one garbled message costs that message, not the session. + pub(crate) fn next_frame(&mut self) -> Option> { + loop { + let Some(header_end) = find(&self.buf, b"\r\n\r\n") else { + if self.buf.len() > MAX_HEADER { + // No header terminator in sight: keep only a tail that + // could still be the start of one. + let keep = self.buf.len() - 3; + self.buf.drain(..keep); + } + return None; + }; + let header = String::from_utf8_lossy(&self.buf[..header_end]); + let length = header.split("\r\n").find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }); + let body_start = header_end + 4; + let Some(length) = length else { + log::warn!("lsp: dropping a message without Content-Length: {header:?}"); + self.buf.drain(..body_start); + continue; + }; + if self.buf.len() < body_start + length { + return None; + } + let body = self.buf[body_start..body_start + length].to_vec(); + self.buf.drain(..body_start + length); + return Some(body); + } + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_message_round_trips_through_the_framing() { + let msg = request(7, "textDocument/hover", json!({ "x": "é🎉" })); + let bytes = encode(&msg); + let mut reader = FrameReader::default(); + reader.push(&bytes); + let body = reader.next_frame().expect("a whole frame"); + assert_eq!(serde_json::from_slice::(&body).unwrap(), msg); + assert!(reader.next_frame().is_none()); + } + + #[test] + fn the_length_counts_bytes_not_characters() { + let bytes = encode(&json!("é🎉")); + let text = String::from_utf8(bytes).unwrap(); + // `"é🎉"` is 2 quotes + 2 + 4 bytes. + assert!(text.starts_with("Content-Length: 8\r\n\r\n"), "{text:?}"); + } + + #[test] + fn frames_split_at_every_byte_still_come_out_whole() { + let a = notification("a", json!({ "n": 1 })); + let b = notification("b", json!({ "text": "π".repeat(50) })); + let mut stream = encode(&a); + stream.extend(encode(&b)); + let mut reader = FrameReader::default(); + let mut got = Vec::new(); + for byte in &stream { + reader.push(std::slice::from_ref(byte)); + while let Some(body) = reader.next_frame() { + got.push(serde_json::from_slice::(&body).unwrap()); + } + } + assert_eq!(got, vec![a, b]); + } + + #[test] + fn two_frames_in_one_read_both_come_out() { + let mut stream = encode(&json!(1)); + stream.extend(encode(&json!(2))); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&b"1"[..])); + assert_eq!(reader.next_frame().as_deref(), Some(&b"2"[..])); + assert_eq!(reader.next_frame(), None); + } + + #[test] + fn extra_headers_and_odd_case_are_accepted() { + let body = br#"{"jsonrpc":"2.0","method":"x"}"#; + let mut stream = format!( + "content-type: application/vscode-jsonrpc; charset=utf-8\r\nCONTENT-LENGTH:{}\r\n\r\n", + body.len() + ) + .into_bytes(); + stream.extend_from_slice(body); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&body[..])); + } + + #[test] + fn a_header_without_a_length_is_skipped() { + let mut stream = b"X-Nothing: 1\r\n\r\n".to_vec(); + stream.extend(encode(&json!(3))); + let mut reader = FrameReader::default(); + reader.push(&stream); + assert_eq!(reader.next_frame().as_deref(), Some(&b"3"[..])); + } + + #[test] + fn messages_are_sorted_by_what_they_ask() { + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":3,"result":{"a":1}}"#), + Some(Incoming::Response { + id: 3, + result: Ok(json!({ "a": 1 })) + }) + ); + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"nope"}}"#), + Some(Incoming::Response { + id: 4, + result: Err(ResponseError { + code: -32601, + message: "nope".into() + }) + }) + ); + assert_eq!( + classify( + br#"{"jsonrpc":"2.0","id":"abc","method":"workspace/configuration","params":{}}"# + ), + Some(Incoming::Request { + id: json!("abc"), + method: "workspace/configuration".into(), + params: json!({}) + }) + ); + assert_eq!( + classify(br#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics"}"#), + Some(Incoming::Notification { + method: "textDocument/publishDiagnostics".into(), + params: Value::Null + }) + ); + // A `null` result is still a result. + assert_eq!( + classify(br#"{"jsonrpc":"2.0","id":5,"result":null}"#), + Some(Incoming::Response { + id: 5, + result: Ok(Value::Null) + }) + ); + assert_eq!(classify(b"not json"), None); + assert_eq!(classify(br#"{"id":"x","result":1}"#), None); + } +} diff --git a/src/ui/lsp/servers.rs b/src/ui/lsp/servers.rs new file mode 100644 index 00000000..d06010b6 --- /dev/null +++ b/src/ui/lsp/servers.rs @@ -0,0 +1,397 @@ +//! Which language server serves which files, where to find it, and which +//! directory it should treat as the project. +//! +//! [`SERVERS`] is the whole registry: one row per server. Adding a language +//! is adding a row — its editor language names, the commands to try in order +//! (the first one found on `PATH` wins), and the files that mark a project's +//! root. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(crate) struct ServerSpec { + /// Shown to people, and the key a server is known by. + pub(crate) name: &'static str, + /// The editor's language names (`code_editor::language_for_path`) this + /// server handles. + pub(crate) languages: &'static [&'static str], + /// Programs to try, in order, with their arguments. + pub(crate) commands: &'static [(&'static str, &'static [&'static str])], + /// Files or directories whose presence marks a project root, nearest + /// first. See [`find_root`] for how they are weighed. + pub(crate) root_markers: &'static [&'static str], + /// Markers that, when found further up, win over a nearer one — a Cargo + /// or Go workspace above the crate or module the file is in. + pub(crate) outer_markers: &'static [&'static str], + /// The settings tty7 gives the server, as JSON keyed by configuration + /// section — what `workspace/configuration` answers from. + pub(crate) settings: &'static str, + /// The section, if any, that also goes as `initializationOptions`: some + /// servers read their settings only from there. + pub(crate) init_section: Option<&'static str>, +} + +pub(crate) const SERVERS: &[ServerSpec] = &[ + ServerSpec { + name: "rust-analyzer", + languages: &["rust"], + commands: &[("rust-analyzer", &[])], + root_markers: &["Cargo.toml"], + outer_markers: &["Cargo.toml#workspace"], + // `cargo check` on save is where rust-analyzer's type errors and + // lints come from; without it only its own syntax-level + // diagnostics arrive. + settings: r#"{ "rust-analyzer": { "checkOnSave": true, "check": { "command": "check" } } }"#, + init_section: Some("rust-analyzer"), + }, + ServerSpec { + name: "typescript-language-server", + languages: &["typescript", "tsx", "javascript"], + commands: &[("typescript-language-server", &["--stdio"])], + root_markers: &["tsconfig.json", "jsconfig.json", "package.json"], + outer_markers: &[], + settings: "{}", + init_section: None, + }, + ServerSpec { + name: "pyright", + languages: &["python"], + commands: &[ + ("pyright-langserver", &["--stdio"]), + ("basedpyright-langserver", &["--stdio"]), + ("pylsp", &[]), + ], + root_markers: &[ + "pyproject.toml", + "pyrightconfig.json", + "setup.py", + "setup.cfg", + "requirements.txt", + ], + outer_markers: &[], + settings: r#"{ "python": { "analysis": { "autoSearchPaths": true, "useLibraryCodeForTypes": true } } }"#, + init_section: None, + }, + ServerSpec { + name: "gopls", + languages: &["go"], + commands: &[("gopls", &[])], + root_markers: &["go.mod"], + outer_markers: &["go.work"], + settings: r#"{ "gopls": {} }"#, + init_section: Some("gopls"), + }, + ServerSpec { + name: "clangd", + languages: &["c", "cpp"], + commands: &[("clangd", &[])], + root_markers: &["compile_commands.json", "compile_flags.txt", ".clangd"], + outer_markers: &[], + settings: "{}", + init_section: None, + }, +]; + +impl ServerSpec { + pub(crate) fn settings(&self) -> serde_json::Value { + serde_json::from_str(self.settings).unwrap_or_else(|_| serde_json::json!({})) + } + + pub(crate) fn initialization_options(&self) -> serde_json::Value { + match self.init_section { + Some(section) => configuration_item(&self.settings(), Some(section)), + None => serde_json::Value::Null, + } + } +} + +/// One item of a `workspace/configuration` answer: the part of `settings` +/// under the dotted `section`, or all of it when none is named. A section +/// tty7 sets nothing for gets an empty object rather than `null` — several +/// servers read fields off the answer and fail on a null. +pub(crate) fn configuration_item( + settings: &serde_json::Value, + section: Option<&str>, +) -> serde_json::Value { + let Some(section) = section.filter(|s| !s.is_empty()) else { + return settings.clone(); + }; + section + .split('.') + .try_fold(settings, |value, key| value.get(key)) + .cloned() + .unwrap_or_else(|| serde_json::json!({})) +} + +/// Markers every server falls back on when its own are nowhere to be found. +const FALLBACK_MARKERS: &[&str] = &[".git", ".hg", ".jj"]; + +pub(crate) fn spec_for_language(language: &str) -> Option<&'static ServerSpec> { + SERVERS.iter().find(|s| s.languages.contains(&language)) +} + +/// The `languageId` a server expects in `didOpen`, which is finer-grained +/// than the editor's highlighting language: JSX and TSX are their own ids. +pub(crate) fn language_id(path: &Path, language: &str) -> &'static str { + let ext = path + .extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + match (language, ext.as_str()) { + ("javascript", "jsx") => "javascriptreact", + ("tsx", _) => "typescriptreact", + ("typescript", _) => "typescript", + ("javascript", _) => "javascript", + ("rust", _) => "rust", + ("python", _) => "python", + ("go", _) => "go", + ("c", _) => "c", + ("cpp", _) => "cpp", + _ => "plaintext", + } +} + +/// The first of `spec`'s commands that exists on `path` (a `PATH`-style +/// list), as a full path to run and its arguments. +/// +/// tty7 fills its own `PATH` in from the login shell at startup +/// (`enrich_path_from_login_shell` in `main.rs`), so a GUI launched from the +/// Dock still finds what `~/.cargo/bin` or `nvm` put on the terminal's. +pub(crate) fn resolve( + spec: &ServerSpec, + path: &OsStr, +) -> Option<(PathBuf, &'static [&'static str])> { + spec.commands + .iter() + .find_map(|(program, args)| Some((find_program(program, path)?, *args))) +} + +fn find_program(program: &str, path: &OsStr) -> Option { + let exts: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ".bat", ""] + } else { + &[""] + }; + std::env::split_paths(path).find_map(|dir| { + exts.iter() + .map(|ext| dir.join(format!("{program}{ext}"))) + .find(|candidate| is_executable(candidate)) + }) +} + +fn is_executable(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// The directory a server should open as the project that contains `file`. +/// +/// The nearest ancestor holding one of the server's own markers wins; above +/// it, the furthest ancestor holding an outer marker wins over that (a +/// `Cargo.toml` with a `[workspace]` above a member crate's). Without any +/// marker a version-control root will do, and without that the file's own +/// directory. The home directory and the filesystem root are never chosen: +/// pointing a server at either makes it index everything a person owns. +pub(crate) fn find_root(file: &Path, spec: &ServerSpec, home: Option<&Path>) -> PathBuf { + let dir = file.parent().unwrap_or(file).to_path_buf(); + let too_broad = |p: &Path| p.parent().is_none() || home.is_some_and(|h| h == p); + let ancestors: Vec<&Path> = dir.ancestors().filter(|p| !too_broad(p)).collect(); + + let nearest = ancestors + .iter() + .find(|p| spec.root_markers.iter().any(|m| has_marker(p, m))); + let outer = ancestors + .iter() + .rev() + .find(|p| spec.outer_markers.iter().any(|m| has_marker(p, m))); + let chosen = match (nearest, outer) { + // Only an outer marker that actually encloses the nearest root counts. + (Some(near), Some(out)) if near.starts_with(out) => Some(out), + (Some(near), _) => Some(near), + (None, Some(out)) => Some(out), + (None, None) => ancestors + .iter() + .find(|p| FALLBACK_MARKERS.iter().any(|m| p.join(m).exists())), + }; + chosen.map(|p| p.to_path_buf()).unwrap_or(dir) +} + +/// `name` exists in `dir`. `Cargo.toml#workspace` asks for a `Cargo.toml` +/// with a `[workspace]` table in it. +fn has_marker(dir: &Path, marker: &str) -> bool { + match marker.split_once('#') { + Some((file, table)) => std::fs::read_to_string(dir.join(file)) + .is_ok_and(|text| text.lines().any(|line| line.trim() == format!("[{table}]"))), + None => dir.join(marker).exists(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + for f in files { + let p = dir.path().join(f); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + if f.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + let body = if f.ends_with("ws/Cargo.toml") { + "[workspace]\nmembers = [\"crates/*\"]\n" + } else { + "[package]\nname = \"x\"\n" + }; + std::fs::write(&p, body).unwrap(); + } + } + dir + } + + fn rust() -> &'static ServerSpec { + spec_for_language("rust").unwrap() + } + + #[test] + fn a_member_crate_opens_as_its_cargo_workspace() { + let t = tree(&[ + "ws/Cargo.toml", + "ws/crates/a/Cargo.toml", + "ws/crates/a/src/lib.rs", + ]); + let root = find_root(&t.path().join("ws/crates/a/src/lib.rs"), rust(), None); + assert_eq!(root, t.path().join("ws")); + } + + #[test] + fn a_crate_outside_any_workspace_is_its_own_root() { + let t = tree(&["solo/Cargo.toml", "solo/src/main.rs"]); + let root = find_root(&t.path().join("solo/src/main.rs"), rust(), None); + assert_eq!(root, t.path().join("solo")); + } + + #[test] + fn the_nearest_package_json_wins_for_typescript() { + let t = tree(&[ + "repo/package.json", + "repo/web/package.json", + "repo/web/src/a.ts", + ]); + let ts = spec_for_language("typescript").unwrap(); + let root = find_root(&t.path().join("repo/web/src/a.ts"), ts, None); + assert_eq!(root, t.path().join("repo/web")); + } + + #[test] + fn a_go_work_above_the_module_wins() { + let t = tree(&["w/go.work", "w/svc/go.mod", "w/svc/main.go"]); + let go = spec_for_language("go").unwrap(); + assert_eq!( + find_root(&t.path().join("w/svc/main.go"), go, None), + t.path().join("w") + ); + } + + #[test] + fn without_markers_the_repository_and_then_the_folder_are_used() { + let t = tree(&["repo/.git/", "repo/tools/x.py", "loose/y.py"]); + let py = spec_for_language("python").unwrap(); + assert_eq!( + find_root(&t.path().join("repo/tools/x.py"), py, None), + t.path().join("repo") + ); + assert_eq!( + find_root(&t.path().join("loose/y.py"), py, None), + t.path().join("loose") + ); + } + + #[test] + fn home_is_never_a_root_even_with_a_dotfiles_repository() { + let t = tree(&["home/.git/", "home/notes/n.py"]); + let home = t.path().join("home"); + let py = spec_for_language("python").unwrap(); + assert_eq!( + find_root(&home.join("notes/n.py"), py, Some(&home)), + home.join("notes") + ); + } + + #[test] + fn language_ids_distinguish_jsx_and_tsx() { + assert_eq!( + language_id(Path::new("a.jsx"), "javascript"), + "javascriptreact" + ); + assert_eq!(language_id(Path::new("a.js"), "javascript"), "javascript"); + assert_eq!(language_id(Path::new("a.tsx"), "tsx"), "typescriptreact"); + assert_eq!(language_id(Path::new("a.rs"), "rust"), "rust"); + } + + #[test] + fn configuration_answers_by_section_and_never_with_null() { + let ra = rust().settings(); + assert_eq!( + configuration_item(&ra, Some("rust-analyzer"))["checkOnSave"], + serde_json::json!(true) + ); + assert_eq!( + configuration_item(&ra, Some("rust-analyzer.check.command")), + serde_json::json!("check") + ); + assert_eq!( + configuration_item(&ra, Some("editor")), + serde_json::json!({}) + ); + assert_eq!(configuration_item(&ra, None), ra); + assert_eq!( + rust().initialization_options()["checkOnSave"], + serde_json::json!(true) + ); + for spec in SERVERS { + assert!(spec.settings().is_object(), "{} settings parse", spec.name); + } + } + + #[test] + fn every_language_has_at_most_one_server() { + let mut seen = std::collections::HashSet::new(); + for spec in SERVERS { + for lang in spec.languages { + assert!(seen.insert(*lang), "{lang} is served twice"); + } + } + } + + #[cfg(unix)] + #[test] + fn the_first_command_found_on_path_is_used() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::TempDir::new().unwrap(); + let pylsp = dir.path().join("pylsp"); + std::fs::write(&pylsp, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&pylsp, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Not executable: not a candidate. + std::fs::write(dir.path().join("pyright-langserver"), "").unwrap(); + let py = spec_for_language("python").unwrap(); + let (program, args) = resolve(py, dir.path().as_os_str()).unwrap(); + assert_eq!(program, pylsp); + assert!(args.is_empty()); + assert!(resolve(rust(), dir.path().as_os_str()).is_none()); + } +} diff --git a/src/ui/lsp/signature.rs b/src/ui/lsp/signature.rs new file mode 100644 index 00000000..9034d110 --- /dev/null +++ b/src/ui/lsp/signature.rs @@ -0,0 +1,241 @@ +//! Signature help: the parameters of the call the cursor is in, shown in a +//! popover once `(` or `,` is typed and kept current while typing goes on +//! inside the call. The server decides when the cursor has left it (it +//! answers with nothing), and Escape puts the popover away. + +use gpui::Context; +use gpui_component::RopeExt as _; +use lsp_types::{Documentation, ParameterLabel, SignatureHelp}; + +use super::convert::{self, Encoding}; +use super::{Freshen, LspStore}; +use crate::ui::app::Tty7App; +use crate::ui::code_editor::LocalFile; + +/// Characters that open signature help when the server names none. +const DEFAULT_TRIGGERS: [&str; 2] = ["(", ","]; + +fn documentation(doc: &Documentation) -> &str { + match doc { + Documentation::String(s) => s, + Documentation::MarkupContent(m) => &m.value, + } +} + +/// Markdown's own punctuation, escaped so a signature reads as written — +/// `*const T` is not emphasis. +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + if matches!( + c, + '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '#' | '|' + ) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// The popover's text: the active signature with the active parameter in +/// bold, then what the server says about that parameter and the function. +pub(crate) fn markdown(help: &SignatureHelp) -> Option { + let index = help.active_signature.unwrap_or(0) as usize; + let signature = help + .signatures + .get(index) + .or_else(|| help.signatures.first())?; + let label = signature.label.as_str(); + let active = signature.active_parameter.or(help.active_parameter); + let span = active + .and_then(|ix| signature.parameters.as_ref()?.get(ix as usize)) + .and_then(|param| match ¶m.label { + ParameterLabel::Simple(name) => { + let start = label.find(name.as_str())?; + Some((start, start + name.len(), param)) + } + ParameterLabel::LabelOffsets([start, end]) => { + // Offsets into the label, in UTF-16 units whatever the + // position encoding. + let start = convert::byte_of_column(label, *start, Encoding::Utf16); + let end = convert::byte_of_column(label, *end, Encoding::Utf16).max(start); + Some((start, end, param)) + } + }); + let mut out = String::new(); + if help.signatures.len() > 1 { + out.push_str(&format!("{}/{} ", index + 1, help.signatures.len())); + } + match span { + Some((start, end, _)) if start < end => { + out.push_str(&escape(&label[..start])); + out.push_str("**"); + out.push_str(&escape(&label[start..end])); + out.push_str("**"); + out.push_str(&escape(&label[end..])); + } + _ => out.push_str(&escape(label)), + } + if let Some(doc) = span.and_then(|(_, _, p)| p.documentation.as_ref()) { + let doc = documentation(doc).trim(); + if !doc.is_empty() { + out.push_str("\n\n"); + out.push_str(doc); + } + } + if let Some(doc) = signature.documentation.as_ref() { + let doc = documentation(doc).trim(); + if !doc.is_empty() { + out.push_str("\n\n"); + out.push_str(doc); + } + } + Some(out) +} + +impl Tty7App { + /// After an edit: opens signature help on a trigger character, and + /// keeps an open one current. + pub(crate) fn lsp_signature_help_after_edit(&self, f: &LocalFile, cx: &mut Context) { + let (text, cursor, visible) = { + let state = f.input.read(cx); + ( + state.text().clone(), + state.cursor(), + state.is_signature_help_visible(), + ) + }; + let point = text.offset_to_point(cursor); + let line = text.slice_line(point.row).to_string(); + let Some(before) = line.get(..point.column).and_then(|l| l.chars().next_back()) else { + return; + }; + // Looked at without sending the edit: most keystrokes stop here, and + // the server hears about them on the usual debounce. + let Some(doc) = LspStore::context(&f.path, f.input.entity_id(), Freshen::Skip, None, cx) + else { + return; + }; + let Some(options) = doc.caps.signature_help_provider.as_ref() else { + return; + }; + let typed = before.to_string(); + let is_trigger = match &options.trigger_characters { + Some(chars) => chars.contains(&typed), + None => DEFAULT_TRIGGERS.contains(&typed.as_str()), + }; + if !is_trigger && !visible { + return; + } + let Some(doc) = + LspStore::context(&f.path, f.input.entity_id(), Freshen::Text(&text), None, cx) + else { + return; + }; + let context = lsp_types::SignatureHelpContext { + trigger_kind: if is_trigger { + lsp_types::SignatureHelpTriggerKind::TRIGGER_CHARACTER + } else { + lsp_types::SignatureHelpTriggerKind::CONTENT_CHANGE + }, + trigger_character: is_trigger.then_some(typed), + is_retrigger: visible, + active_signature_help: None, + }; + let request = doc + .client + .request::(lsp_types::SignatureHelpParams { + context: Some(context), + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(doc.uri.clone()), + convert::offset_to_lsp(&text, cursor, doc.encoding), + ), + work_done_progress_params: Default::default(), + }); + let input = f.input.clone(); + let anchor = cursor - before.len_utf8()..cursor; + cx.spawn(async move |_, cx| { + let shown = match request.await { + Ok(Some(help)) => markdown(&help), + Ok(None) => None, + Err(e) => { + log::debug!("lsp: signatureHelp: {e:#}"); + return; + } + }; + input.update(cx, |state, cx| { + // Typed on since: the next edit asks again. + if *state.text() != text { + return; + } + match shown { + Some(markdown) => state.show_signature_help(anchor, markdown, cx), + None => state.hide_signature_help(cx), + } + }); + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{ParameterInformation, SignatureInformation}; + + fn help(params: Vec, active: Option) -> SignatureHelp { + SignatureHelp { + signatures: vec![SignatureInformation { + label: "fn copy(src: *const u8, n: usize)".into(), + documentation: Some(Documentation::String("Copies.".into())), + parameters: Some( + params + .into_iter() + .map(|label| ParameterInformation { + label, + documentation: None, + }) + .collect(), + ), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: active, + } + } + + #[test] + fn the_active_parameter_is_bold_and_markdown_is_escaped() { + let by_name = help( + vec![ + ParameterLabel::Simple("src: *const u8".into()), + ParameterLabel::Simple("n: usize".into()), + ], + Some(1), + ); + assert_eq!( + markdown(&by_name).unwrap(), + "fn copy(src: \\*const u8, **n: usize**)\n\nCopies." + ); + let by_offset = help( + vec![ + ParameterLabel::LabelOffsets([8, 22]), + ParameterLabel::LabelOffsets([24, 32]), + ], + Some(0), + ); + assert_eq!( + markdown(&by_offset).unwrap(), + "fn copy(**src: \\*const u8**, n: usize)\n\nCopies." + ); + assert!( + markdown(&SignatureHelp { + signatures: vec![], + active_signature: None, + active_parameter: None + }) + .is_none() + ); + } +} diff --git a/src/ui/lsp/symbols.rs b/src/ui/lsp/symbols.rs new file mode 100644 index 00000000..09a2dd5d --- /dev/null +++ b/src/ui/lsp/symbols.rs @@ -0,0 +1,184 @@ +//! A server's `textDocument/documentSymbol`, as the editor's outline. +//! +//! The editor already builds an outline from its own parse tree; a server's +//! is better where there is one — it knows `impl` blocks from macros, and +//! languages the tree queries do not cover. It replaces the tree's through +//! `Tty7App::editor_set_document_symbols`, refreshed a moment after the +//! server hears about each edit. + +use gpui_component::Rope; +use lsp_types::{DocumentSymbol, DocumentSymbolResponse, SymbolInformation}; + +use super::convert::{self, Encoding}; +use crate::ui::code_editor::outline::{Outline, SymbolKind, SymbolNode}; + +fn kind(kind: lsp_types::SymbolKind) -> SymbolKind { + let n = serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_u64()) + .unwrap_or(0); + SymbolKind::from_lsp(n as u32) +} + +/// The answer as an outline, its positions converted to the editor's +/// columns against `text` — the text the server answered about. +pub(crate) fn outline(response: DocumentSymbolResponse, text: &Rope, enc: Encoding) -> Outline { + let nodes = match response { + DocumentSymbolResponse::Nested(symbols) => { + symbols.into_iter().map(|s| node(s, text, enc)).collect() + } + DocumentSymbolResponse::Flat(symbols) => nest_flat(symbols, text, enc), + }; + Outline::from_nodes(nodes) +} + +fn node(symbol: DocumentSymbol, text: &Rope, enc: Encoding) -> SymbolNode { + let range = convert::range_to_editor(text, symbol.range, enc); + SymbolNode { + name: symbol.name, + kind: kind(symbol.kind), + range: range.start..range.end, + selection: convert::lsp_to_editor(text, symbol.selection_range.start, enc), + children: symbol + .children + .unwrap_or_default() + .into_iter() + .map(|c| node(c, text, enc)) + .collect(), + } +} + +/// The older, flat answer: nested by which range holds which, since the +/// `containerName` it carries is a name, not a reference. +fn nest_flat(mut symbols: Vec, text: &Rope, enc: Encoding) -> Vec { + let key = |p: lsp_types::Position| (p.line, p.character); + // Outer before inner: by start, then the longer range first. + symbols.sort_by(|a, b| { + key(a.location.range.start) + .cmp(&key(b.location.range.start)) + .then(key(b.location.range.end).cmp(&key(a.location.range.end))) + }); + let contains = |outer: &lsp_types::Range, inner: &lsp_types::Range| { + key(outer.start) <= key(inner.start) && key(inner.end) <= key(outer.end) + }; + // A stack of open containers: (their server range, the node). + let mut stack: Vec<(lsp_types::Range, SymbolNode)> = Vec::new(); + let mut roots = Vec::new(); + fn pop_into(stack: &mut Vec<(lsp_types::Range, SymbolNode)>, roots: &mut Vec) { + if let Some((_, done)) = stack.pop() { + match stack.last_mut() { + Some((_, parent)) => parent.children.push(done), + None => roots.push(done), + } + } + } + for s in symbols { + let range = s.location.range; + while stack + .last() + .is_some_and(|(outer, _)| !contains(outer, &range)) + { + pop_into(&mut stack, &mut roots); + } + let editor = convert::range_to_editor(text, range, enc); + stack.push(( + range, + SymbolNode { + name: s.name, + kind: kind(s.kind), + range: editor.start..editor.end, + selection: editor.start, + children: Vec::new(), + }, + )); + } + while !stack.is_empty() { + pop_into(&mut stack, &mut roots); + } + roots +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Location, Position, Range}; + + fn range(a: u32, b: u32, c: u32, d: u32) -> Range { + Range::new(Position::new(a, b), Position::new(c, d)) + } + + #[test] + #[allow(deprecated)] + fn a_flat_answer_is_nested_by_range() { + let text = Rope::from("struct A {\n f: u8,\n}\nfn g() {}\n"); + let uri: lsp_types::Uri = "file:///a.rs".parse().unwrap(); + let info = |name: &str, kind, r| SymbolInformation { + name: name.into(), + kind, + tags: None, + deprecated: None, + location: Location::new(uri.clone(), r), + container_name: None, + }; + let outline = outline( + DocumentSymbolResponse::Flat(vec![ + info("g", lsp_types::SymbolKind::FUNCTION, range(3, 0, 3, 9)), + info("f", lsp_types::SymbolKind::FIELD, range(1, 2, 1, 7)), + info("A", lsp_types::SymbolKind::STRUCT, range(0, 0, 2, 1)), + ]), + &text, + Encoding::Utf16, + ); + let shape: Vec<(&str, usize, SymbolKind)> = outline + .symbols + .iter() + .map(|s| (s.name.as_str(), s.depth, s.kind)) + .collect(); + assert_eq!( + shape, + [ + ("A", 0, SymbolKind::Struct), + ("f", 1, SymbolKind::Field), + ("g", 0, SymbolKind::Function), + ] + ); + } + + #[test] + #[allow(deprecated)] + fn a_nested_answer_keeps_its_shape_in_editor_columns() { + let text = Rope::from("mod 🎉m {\n fn f() {}\n}\n"); + let sym = |name: &str, kind, r: Range, sel: Range, children| DocumentSymbol { + name: name.into(), + detail: None, + kind, + tags: None, + deprecated: None, + range: r, + selection_range: sel, + children, + }; + let outline = outline( + DocumentSymbolResponse::Nested(vec![sym( + "m", + lsp_types::SymbolKind::MODULE, + range(0, 0, 2, 1), + // `m` after the emoji: UTF-16 column 6, char column 5. + range(0, 6, 0, 7), + Some(vec![sym( + "f", + lsp_types::SymbolKind::FUNCTION, + range(1, 4, 1, 14), + range(1, 7, 1, 8), + None, + )]), + )]), + &text, + Encoding::Utf16, + ); + assert_eq!(outline.symbols.len(), 2); + assert_eq!(outline.symbols[0].selection, Position::new(0, 5)); + assert_eq!(outline.symbols[1].parent, Some(0)); + assert_eq!(outline.symbols[1].kind, SymbolKind::Function); + } +} diff --git a/src/ui/lsp/tests.rs b/src/ui/lsp/tests.rs new file mode 100644 index 00000000..4f976c6e --- /dev/null +++ b/src/ui/lsp/tests.rs @@ -0,0 +1,544 @@ +use std::collections::HashMap; + +use lsp_types::{ + AnnotatedTextEdit, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, Position, Range, ResourceOp, TextDocumentEdit, + TextEdit, WorkspaceEdit, +}; + +use super::*; + +/// A `file://` URI spelled out, not built from a path: these tests are +/// about grouping URIs, and `/a.rs` is not an absolute path on Windows. +fn uri(path: &str) -> Uri { + format!("file://{path}").parse().unwrap() +} + +fn edit(line: u32, text: &str) -> TextEdit { + TextEdit { + range: Range::new(Position::new(line, 0), Position::new(line, 1)), + new_text: text.into(), + } +} + +#[cfg(unix)] +#[test] +fn file_uris_round_trip_including_spaces_and_non_ascii() { + let path = Path::new("/tmp/a dir/ü.rs"); + let u = path_to_uri(path).unwrap(); + assert_eq!(u.as_str(), "file:///tmp/a%20dir/%C3%BC.rs"); + assert_eq!(uri_to_path(&u).as_deref(), Some(path)); + let web: Uri = "https://docs.rs/x".parse().unwrap(); + assert_eq!(uri_to_path(&web), None); +} + +#[test] +fn a_workspace_edit_is_grouped_by_file_from_either_shape() { + let mut changes = HashMap::new(); + changes.insert(uri("/b.rs"), vec![edit(1, "b")]); + changes.insert(uri("/a.rs"), vec![edit(0, "a")]); + let files = workspace_edit_files(WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }); + let names: Vec<&str> = files.iter().map(|(u, _)| u.as_str()).collect(); + assert_eq!(names, ["file:///a.rs", "file:///b.rs"]); + + let doc_edit = |path: &str, edits: Vec>| TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: uri(path), + version: None, + }, + edits, + }; + let files = workspace_edit_files(WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(vec![ + DocumentChangeOperation::Edit(doc_edit("/a.rs", vec![OneOf::Left(edit(0, "x"))])), + DocumentChangeOperation::Op(ResourceOp::Create(lsp_types::CreateFile { + uri: uri("/new.rs"), + options: None, + annotation_id: None, + })), + DocumentChangeOperation::Edit(doc_edit( + "/a.rs", + vec![OneOf::Right(AnnotatedTextEdit { + text_edit: edit(2, "y"), + annotation_id: "rename".into(), + })], + )), + ])), + ..Default::default() + }); + assert_eq!( + files.len(), + 1, + "edits to one file are merged; resource ops skipped" + ); + let texts: Vec<&str> = files[0].1.iter().map(|e| e.new_text.as_str()).collect(); + assert_eq!(texts, ["x", "y"]); +} + +#[test] +fn a_rename_across_a_file_applies_every_occurrence() { + // What a server sends for renaming `foo` to `renamed`, applied to a file + // with no buffer open. + let text = "fn foo() {}\nfn main() { foo(); /* 🎉 */ foo(); }\n"; + let at = |line, start, end| TextEdit { + range: Range::new(Position::new(line, start), Position::new(line, end)), + new_text: "renamed".into(), + }; + let edits = vec![at(1, 12, 15), at(0, 3, 6), at(1, 28, 31)]; + assert_eq!( + convert::apply_edits(text, &edits, Encoding::Utf16), + "fn renamed() {}\nfn main() { renamed(); /* 🎉 */ renamed(); }\n" + ); +} + +/// Drives a real language server through the whole client: spawn, +/// `initialize` behind the gate, `didOpen`, diagnostics arriving as an +/// event, a hover request, and a clean `shutdown`/`exit` with the process +/// reaped. clangd, because it ships with Xcode's command line tools and needs +/// no project to index; the file's type error is what it has to find. +/// +/// `cargo test -p tty7 --bin tty7-app lsp_smoke -- --ignored` +#[test] +#[ignore = "needs clangd on PATH"] +fn lsp_smoke_a_real_server_publishes_diagnostics_and_shuts_down() { + use std::time::Duration; + + let spec = servers::spec_for_language("c").unwrap(); + let Some((program, args)) = + servers::resolve(spec, &std::env::var_os("PATH").unwrap_or_default()) + else { + eprintln!("clangd not on PATH; skipping"); + return; + }; + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let file = root.join("main.c"); + let text = "int main(void) {\n int x = \"not a number\";\n return x;\n}\n"; + std::fs::write(&file, text).unwrap(); + + let (client, events) = LspClient::spawn("clangd", &program, args, &root).unwrap(); + let within = + |secs: u64, fut: std::pin::Pin>>>| { + smol::block_on(smol::future::or(fut, async move { + smol::Timer::after(Duration::from_secs(secs)).await; + None + })) + }; + + let init = client.initialize(initialize_params(&root, spec)); + let result = within(30, Box::pin(async move { init.await.ok() })).expect("initialize answered"); + let init: lsp_types::InitializeResult = serde_json::from_value(result).unwrap(); + assert!(init.capabilities.hover_provider.is_some()); + client.notify_raw("initialized", json!({})); + client.open_gate(); + + let uri = path_to_uri(&file).unwrap(); + client.notify::( + lsp_types::DidOpenTextDocumentParams { + text_document: lsp_types::TextDocumentItem::new( + uri.clone(), + "c".into(), + 0, + text.into(), + ), + }, + ); + + let events2 = events.clone(); + let published = within( + 60, + Box::pin(async move { + while let Ok(event) = events2.recv().await { + match event { + ServerEvent::Notification { method, params } + if method == "textDocument/publishDiagnostics" => + { + return Some(params); + } + _ => {} + } + } + None + }), + ) + .expect("diagnostics published"); + let params: lsp_types::PublishDiagnosticsParams = serde_json::from_value(published).unwrap(); + assert_eq!(params.uri, uri); + let (errors, _) = convert::count_problems(¶ms.diagnostics); + assert!(errors >= 1, "{:?}", params.diagnostics); + assert!(params.diagnostics.iter().any(|d| d.range.start.line == 1)); + + let hover = client.request::(lsp_types::HoverParams { + text_document_position_params: lsp_types::TextDocumentPositionParams::new( + lsp_types::TextDocumentIdentifier::new(uri), + Position::new(2, 11), + ), + work_done_progress_params: Default::default(), + }); + let hovered = within( + 30, + Box::pin(async move { hover.await.ok().map(|h| serde_json::to_value(h).unwrap()) }), + ); + assert!( + hovered.is_some_and(|h| !h.is_null()), + "hover over `x` says something" + ); + + // The outline, the references to `x`, and signature help inside a call + // all come back in shapes the editor reads. + let doc_id = || lsp_types::TextDocumentIdentifier::new(path_to_uri(&file).unwrap()); + let symbols = client.request::( + lsp_types::DocumentSymbolParams { + text_document: doc_id(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }, + ); + let outline = within( + 30, + Box::pin(async move { + symbols + .await + .ok() + .flatten() + .map(|s| serde_json::to_value(s).unwrap()) + }), + ) + .map(|v| { + symbols::outline( + serde_json::from_value(v).unwrap(), + &gpui_component::Rope::from(text), + Encoding::Utf16, + ) + }) + .expect("an outline"); + assert!( + outline.symbols.iter().any(|s| s.name == "main"), + "{outline:?}" + ); + + let references = client.request::(lsp_types::ReferenceParams { + text_document_position: lsp_types::TextDocumentPositionParams::new( + doc_id(), + Position::new(2, 11), + ), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: lsp_types::ReferenceContext { + include_declaration: true, + }, + }); + let found = within( + 30, + Box::pin(async move { + references + .await + .ok() + .flatten() + .map(|r| serde_json::to_value(r).unwrap()) + }), + ) + .expect("references"); + let found: Vec = serde_json::from_value(found).unwrap(); + assert!( + found.len() >= 2, + "the declaration and the use of x: {found:?}" + ); + + let shutdown = client.request_raw("shutdown", Value::Null); + within(10, Box::pin(async move { shutdown.await.ok() })); + client.notify_raw("exit", Value::Null); + let mut exited = false; + for _ in 0..50 { + if client.reap_if_exited() { + exited = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + client.kill(); + assert!(exited, "clangd exits on `exit`"); +} + +/// A store with one running server on the far end of two pipes, and one +/// document owned by `owner`. Returns the path and what the server reads. +mod harness { + use std::io::Read as _; + + use gpui::{Entity, TestAppContext, WindowHandle}; + use gpui_component::input::InputState; + + use super::super::*; + use crate::ui::lsp::rpc::FrameReader; + + pub(super) struct FakeServer { + frames: FrameReader, + from_client: std::io::PipeReader, + _to_client: std::io::PipeWriter, + } + + impl FakeServer { + pub(super) fn next(&mut self) -> Value { + let mut chunk = [0u8; 4096]; + loop { + if let Some(body) = self.frames.next_frame() { + return serde_json::from_slice(&body).unwrap(); + } + let n = self.from_client.read(&mut chunk).unwrap(); + assert!(n > 0, "the client closed its end"); + self.frames.push(&chunk[..n]); + } + } + } + + pub(super) struct Setup { + pub(super) window: WindowHandle, + pub(super) owner: Entity, + pub(super) other: Entity, + pub(super) path: PathBuf, + pub(super) server: FakeServer, + } + + pub(super) fn setup(cx: &mut TestAppContext) -> Setup { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::core::config::Config::default()); + }); + let window = cx.add_window(|window, cx| { + InputState::new(window, cx) + .code_editor("rust") + .multi_line(true) + .default_value("fn foo() {}\n") + }); + let owner = window.root(cx).unwrap(); + let other = window + .update(cx, |_, window, cx| { + cx.new(|cx| { + InputState::new(window, cx) + .code_editor("rust") + .multi_line(true) + .default_value("fn foo() { /* the other window's edit */ }\n") + }) + }) + .unwrap(); + let path = test_path("/tmp/tty7-lsp-test/src/lib.rs"); + let (client_reads, server_writes) = std::io::pipe().unwrap(); + let (server_reads, client_writes) = std::io::pipe().unwrap(); + let (client, _events) = + LspClient::from_streams("fake", client_reads, client_writes).unwrap(); + client.open_gate(); + let key = ServerKey { + name: "rust-analyzer", + root: test_path("/tmp/tty7-lsp-test"), + }; + cx.update(|cx| { + LspStore::ensure(cx); + let text = owner.read(cx).text().clone(); + let store = cx.global_mut::(); + store.servers.insert( + key.clone(), + Server { + spec: servers::spec_for_language("rust").unwrap(), + label: "fake".into(), + generation: 1, + phase: Phase::Running, + client: Some(client), + caps: Rc::new(lsp_types::ServerCapabilities::default()), + encoding: Encoding::Utf16, + crashes: Vec::new(), + idle_seq: 0, + _tasks: Vec::new(), + }, + ); + store.docs.insert( + path.clone(), + Doc { + uri: path_to_uri(&path).unwrap(), + language_id: "rust", + server: key, + version: 0, + owner: owner.downgrade(), + owner_id: owner.entity_id(), + app_id: owner.entity_id(), + app: WeakEntity::new_invalid(), + window: Some(window.into()), + sent: Some(text), + dirty: false, + change_seq: 0, + symbols_seq: 0, + diagnostics: Vec::new(), + problems: (0, 0), + }, + ); + }); + Setup { + window, + owner, + other, + path, + server: FakeServer { + frames: FrameReader::default(), + from_client: server_reads, + _to_client: server_writes, + }, + } + } +} + +/// The same file open in a second window is a second buffer, maybe with +/// other text. Its requests are refused before anything is sent, so the +/// server's copy stays the owner's. +#[gpui::test] +fn a_buffer_that_does_not_own_the_document_never_syncs(cx: &mut gpui::TestAppContext) { + let mut s = harness::setup(cx); + cx.update(|cx| { + let other_text = s.other.read(cx).text().clone(); + let refused = LspStore::context( + &s.path, + s.other.entity_id(), + Freshen::Text(&other_text), + None, + cx, + ); + assert!(refused.is_none(), "a non-owner gets no server"); + + let owner_text = gpui_component::Rope::from("fn foo() { owner(); }\n"); + let allowed = LspStore::context( + &s.path, + s.owner.entity_id(), + Freshen::Text(&owner_text), + None, + cx, + ); + assert!(allowed.is_some()); + }); + // The first thing the server hears is the owner's change: the other + // buffer's text never went out. + let change = s.server.next(); + assert_eq!(change["method"], "textDocument/didChange"); + assert_eq!( + change["params"]["contentChanges"][0]["text"], + "fn foo() { owner(); }\n" + ); +} + +#[test] +fn an_edit_is_stale_when_a_file_it_touches_changed_opened_or_closed() { + let rope = gpui_component::Rope::from; + let (a, b, c) = ( + PathBuf::from("/a"), + PathBuf::from("/b"), + PathBuf::from("/c"), + ); + let baseline = HashMap::from([(a.clone(), rope("x")), (b.clone(), rope("y"))]); + let targets = vec![a.clone(), c.clone()]; + // `a` unchanged, `c` on disk then and now. + let now = HashMap::from([(a.clone(), rope("x")), (b.clone(), rope("typed"))]); + assert_eq!( + first_stale(&targets, &now, &baseline), + None, + "only targets count" + ); + // `a` typed in. + let now = HashMap::from([(a.clone(), rope("x!"))]); + assert_eq!(first_stale(&targets, &now, &baseline), Some(a.clone())); + // `c` opened since. + let now = HashMap::from([(a.clone(), rope("x")), (c.clone(), rope("z"))]); + assert_eq!(first_stale(&targets, &now, &baseline), Some(c.clone())); + // `a` closed since. + assert_eq!(first_stale(&targets, &HashMap::new(), &baseline), Some(a)); +} + +/// A rename or code action's edit, answered against a text the buffer has +/// since moved on from, is dropped whole; answered against the text it +/// still holds, it is applied. +#[gpui::test] +fn a_workspace_edit_for_a_changed_buffer_is_not_applied(cx: &mut gpui::TestAppContext) { + let s = harness::setup(cx); + let rename = |cx: &mut gpui::App, baseline| { + let mut changes = HashMap::new(); + changes.insert( + path_to_uri(&s.path).unwrap(), + vec![TextEdit { + range: Range::new(Position::new(0, 3), Position::new(0, 6)), + new_text: "bar".into(), + }], + ); + let edit = WorkspaceEdit { + changes: Some(changes), + ..Default::default() + }; + apply_workspace_edit(edit, Encoding::Utf16, Some(baseline), cx) + }; + + let baseline = cx.update(|cx| LspStore::snapshot(cx)); + // Typed in after the request went out. + s.window + .update(cx, |state, window, cx| { + state.set_value("fn foo() { typed }\n", window, cx) + }) + .unwrap(); + assert!(!cx.update(|cx| rename(cx, baseline))); + cx.run_until_parked(); + assert_eq!( + cx.update(|cx| s.owner.read(cx).text().to_string()), + "fn foo() { typed }\n" + ); + + let baseline = cx.update(|cx| LspStore::snapshot(cx)); + assert!(cx.update(|cx| rename(cx, baseline))); + cx.run_until_parked(); + assert_eq!( + cx.update(|cx| s.owner.read(cx).text().to_string()), + "fn bar() { typed }\n" + ); +} + +/// Turning the setting back on asks every window for its open files again, +/// which is what starts their servers. +#[gpui::test] +fn turning_lsp_back_on_offers_every_window_again(cx: &mut gpui::TestAppContext) { + use std::cell::Cell; + cx.update(|cx| cx.set_global(crate::core::config::Config::default())); + let asked = Rc::new(Cell::new(0)); + let window_gone = Rc::new(Cell::new(false)); + cx.update(|cx| { + let asked = asked.clone(); + LspStore::register_window( + EntityId::from(1u64), + Rc::new(move |_| { + asked.set(asked.get() + 1); + true + }), + cx, + ); + let gone = window_gone.clone(); + LspStore::register_window( + EntityId::from(2u64), + Rc::new(move |_| { + gone.set(true); + false + }), + cx, + ); + }); + let set = |cx: &mut gpui::TestAppContext, on: bool| { + cx.update(|cx| { + cx.update_global::(|c, _| c.editor_lsp = on) + }); + cx.run_until_parked(); + }; + set(cx, false); + assert_eq!(asked.get(), 0, "turning it off asks nobody"); + set(cx, true); + assert_eq!(asked.get(), 1); + assert!(window_gone.get()); + // A closed window is forgotten; one still open is asked on every turn-on. + set(cx, false); + set(cx, true); + assert_eq!(asked.get(), 2); + assert_eq!(cx.update(|cx| cx.global::().windows.len()), 1); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8719f01c..04eb9af6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,6 +22,7 @@ pub mod host_registry; pub mod i18n; pub mod keymap; pub mod local_link; +pub mod lsp; pub mod machine_mirror; pub mod notice; pub mod pane; diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 9214b07b..c7c07b8d 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -90,6 +90,13 @@ pub enum CommandKind { DocumentWidthTwoThirds, ToggleDocumentPreview, ToggleDocumentWrap, + /// The code editor's text commands, run on the active buffer. + EditorTransformUppercase, + EditorTransformLowercase, + EditorTransformTitleCase, + EditorTrimTrailingWhitespace, + EditorJoinLines, + EditorRemoveSurroundingBrackets, RestartSshSession, ScmCommit, ScmStageAll, @@ -116,6 +123,22 @@ pub enum CommandKind { line: Option, column: Option, }, + /// The code editor's navigation, from the palette. + EditorGoToSymbol, + EditorNavigateBack, + EditorNavigateForward, + EditorSplitRight, + /// A Go to Symbol row: this 0-based place in the file in front. + GoToSymbol { + line: u32, + column: u32, + }, + /// A place a language server found: 0-based, the column in chars. + GoToLocation { + path: std::path::PathBuf, + line: u32, + column: u32, + }, /// Connect with a typed `ssh` command line (`-p`, `-J`, an alias…). OpenSshConnect(String), SetTheme(usize), @@ -247,6 +270,16 @@ impl CommandKind { DocumentWidthTwoThirds => "document-width-two-thirds", ToggleDocumentPreview => "document-preview", ToggleDocumentWrap => "document-wrap", + EditorTransformUppercase => "editor-uppercase", + EditorTransformLowercase => "editor-lowercase", + EditorTransformTitleCase => "editor-title-case", + EditorTrimTrailingWhitespace => "editor-trim-trailing-whitespace", + EditorJoinLines => "editor-join-lines", + EditorRemoveSurroundingBrackets => "editor-remove-brackets", + EditorGoToSymbol => "editor-go-to-symbol", + EditorNavigateBack => "editor-go-back", + EditorNavigateForward => "editor-go-forward", + EditorSplitRight => "editor-split-right", RestartSshSession => "ssh-reconnect", ScmCommit => "git-commit", ScmStageAll => "git-stage-all", @@ -270,6 +303,8 @@ impl CommandKind { | SetTheme(_) | GoToTab { .. } | OpenFile { .. } + | GoToSymbol { .. } + | GoToLocation { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) @@ -292,6 +327,8 @@ impl CommandKind { CutText => return inline("secondary-x"), PasteText => return inline("secondary-v"), SelectAllText => return inline("secondary-a"), + // A fixed `CodeEditor` binding, not one of the keymap's slots. + EditorJoinLines => return inline("ctrl-j"), LaunchAgent(agent) => { return crate::ui::keymap::effective_key( crate::ui::agent_launch::launch_action_name(*agent), @@ -375,6 +412,16 @@ impl CommandKind { DocumentWidthTwoThirds => "DocumentWidthTwoThirds", ToggleDocumentPreview => "ToggleDocumentPreview", ToggleDocumentWrap => "ToggleDocumentWrap", + EditorTransformUppercase => "EditorTransformUppercase", + EditorTransformLowercase => "EditorTransformLowercase", + EditorTransformTitleCase => "EditorTransformTitleCase", + EditorTrimTrailingWhitespace => "EditorTrimTrailingWhitespace", + EditorJoinLines => "EditorJoinLines", + EditorRemoveSurroundingBrackets => "EditorRemoveSurroundingBrackets", + EditorGoToSymbol => "EditorGoToSymbol", + EditorNavigateBack => "EditorNavigateBack", + EditorNavigateForward => "EditorNavigateForward", + EditorSplitRight => "EditorSplitRight", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", ScmCommit => "ScmCommit", @@ -406,6 +453,8 @@ impl CommandKind { | SetTheme(_) | GoToTab { .. } | OpenFile { .. } + | GoToSymbol { .. } + | GoToLocation { .. } | ResumeSession { .. } | ForkSession { .. } | CopySessionId(_) diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index 19dbaeb5..9007d4ce 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -17,7 +17,7 @@ mod view; pub(crate) use command::{Avatar, ChromeState, CommandGroup, CommandKind, Item}; pub(crate) use files::{FileIndexStore, FileList}; pub(crate) use score::fuzzy_score; -pub(crate) use sources::{Catalog, host_items}; +pub(crate) use sources::{Catalog, LiveQuery, host_items}; pub(crate) use view::{KEY_CONTEXT, SearchEvent, SearchView}; use crate::ui::i18n::{L10nKey, t}; @@ -30,6 +30,12 @@ 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, + /// Places a language server found — the references to a symbol, or its + /// several definitions (`ui::lsp`). Reached only by those commands. + Locations, } impl SearchTab { @@ -49,6 +55,17 @@ impl SearchTab { SearchTab::Actions, ]; + /// The editor's row: finding your way around the code, as opposed to + /// around the window. Go to File and Go to Symbol open on it, and Tab + /// walks it the way it walks the window's row. Symbols shows only with a + /// file in front; once something is typed it also lists what the file's + /// language server finds across the project. + pub(crate) const EDITOR_ORDER: [SearchTab; 2] = [SearchTab::Files, SearchTab::Symbols]; + + pub(crate) fn in_editor_row(self) -> bool { + Self::EDITOR_ORDER.contains(&self) + } + pub(crate) fn title(self) -> &'static str { t(match self { SearchTab::All => L10nKey::SearchTabAll, @@ -57,9 +74,19 @@ impl SearchTab { SearchTab::Terminals => L10nKey::SearchTabTerminals, SearchTab::Sessions => L10nKey::SearchTabSessions, SearchTab::Hosts => L10nKey::SearchTabHosts, + SearchTab::Symbols => L10nKey::SearchTabSymbols, + SearchTab::Locations => L10nKey::SearchTabLocations, }) } + /// A tab outside the window's row. It never sits under that row with + /// nothing lit, and Tab never trades it for the terminals: the editor's + /// tabs get their own row (`EDITOR_ORDER`), and a language server's places + /// stand alone under their name. + pub(crate) fn stands_alone(self) -> bool { + !Self::ORDER.contains(&self) + } + pub(crate) fn placeholder(self) -> &'static str { t(match self { SearchTab::All => L10nKey::SearchPlaceholderAll, @@ -68,6 +95,8 @@ impl SearchTab { SearchTab::Terminals => L10nKey::SearchPlaceholderTerminals, SearchTab::Sessions => L10nKey::SearchPlaceholderSessions, SearchTab::Hosts => L10nKey::SearchPlaceholderHosts, + SearchTab::Symbols => L10nKey::SearchPlaceholderSymbols, + SearchTab::Locations => L10nKey::SearchPlaceholderLocations, }) } @@ -100,7 +129,12 @@ mod tests { #[test] fn files_is_reached_by_its_chord_not_by_the_row() { 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)); + // Reached by a chord, they stand alone rather than sit under a row + // with nothing in it lit. + for tab in [SearchTab::Files, SearchTab::Symbols, SearchTab::Locations] { + assert!(tab.stands_alone()); + } + assert!(SearchTab::ORDER.iter().all(|tab| !tab.stands_alone())); } } diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index 0652cbd2..c7226e6c 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -90,8 +90,24 @@ 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)>, + /// What a language server found for Find References or a definition + /// with more than one answer (`ui::lsp`), in the order to list them. + pub locations: Vec, + /// What the front file's language server found across the project for + /// the Symbols tab's query (`ui::lsp`), listed after the file's own. + pub project_symbols: Vec, + /// Asked with every query the Symbols tab is given; its answer comes + /// back through `SearchView::set_project_symbols`. + pub live_query: Option, } +/// Something that answers a query later — a language server. +pub(crate) type LiveQuery = std::rc::Rc; + impl Catalog { pub(crate) fn new(mut actions: Vec, terminals: Vec, hosts: Vec) -> Self { // Stable, so a group keeps the order its rows were listed in. The @@ -109,6 +125,10 @@ impl Catalog { sessions: Vec::new(), sessions_here: 0, files: FileList::default(), + symbols: Vec::new(), + locations: Vec::new(), + project_symbols: Vec::new(), + live_query: None, } } @@ -123,12 +143,17 @@ 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))), + SearchTab::Locations => Some(Box::new(Locations(&self.locations))), } } /// The sections `tab` shows for `query`. pub(crate) fn sections(&self, tab: SearchTab, query: &str, cx: &App) -> Vec
{ let query = query.trim(); + if tab == SearchTab::Symbols && !query.is_empty() && !self.project_symbols.is_empty() { + return self.symbols_here_and_in_the_project(query, cx); + } if let Some(source) = self.source(tab) { return match query.is_empty() { true => source.browse(cx), @@ -138,6 +163,29 @@ impl Catalog { self.all(query, cx) } + /// A Symbols search once the language server has answered: the file's + /// own symbols first, since the one in front is the likeliest meant, then + /// the project's — each under a heading only when both have rows. + fn symbols_here_and_in_the_project(&self, query: &str, cx: &App) -> Vec
{ + let rows = |hits: Vec<(i32, Item)>| -> Vec { + hits.into_iter().map(|(_, item)| Row::Item(item)).collect() + }; + let here = rows(Symbols(&self.symbols).search(query, cx)); + let everywhere = rows(rank(&self.project_symbols, query, |_| 0)); + let both = !here.is_empty() && !everywhere.is_empty(); + [ + (L10nKey::SearchSectionThisFile, here), + (L10nKey::SearchSectionProject, everywhere), + ] + .into_iter() + .filter(|(_, rows)| !rows.is_empty()) + .map(|(title, rows)| Section { + title: both.then(|| t(title).into()), + rows, + }) + .collect() + } + /// Every tab's top rows, each under the tab's name. /// /// Tabs are ordered by their best row, not fixed: the Return key takes the @@ -421,6 +469,83 @@ impl Source for Sessions<'_> { /// A typed address outranks any saved host: typing one is saying where to go. const TYPED_ADDRESS_SCORE: i32 = 10_000; +/// Go to Symbol's rows. Browsing shows the file's shape, each symbol +/// indented under what holds it; a search ranks them flat, each naming what +/// holds it instead. +struct Symbols<'a>(&'a [(usize, Item)]); + +/// How far one level of nesting indents a symbol while browsing. +const SYMBOL_INDENT: &str = " "; + +impl Source for Symbols<'_> { + fn tab(&self) -> SearchTab { + SearchTab::Symbols + } + + fn browse(&self, _cx: &App) -> Vec
{ + if self.0.is_empty() { + return Vec::new(); + } + vec![Section { + title: None, + rows: self + .0 + .iter() + .map(|(depth, item)| { + let mut item = item.clone(); + item.title = format!("{}{}", SYMBOL_INDENT.repeat(*depth), item.title); + item.subtitle = None; + Row::Item(item) + }) + .collect(), + }] + } + + fn highlights(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn on_the_empty_all_tab(&self) -> bool { + false + } + + fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> { + let items: Vec = self.0.iter().map(|(_, item)| item.clone()).collect(); + rank(&items, query, |_| 0) + } +} + +/// Find References' rows: listed as found, filtered by path and line text. +struct Locations<'a>(&'a [Item]); + +impl Source for Locations<'_> { + fn tab(&self) -> SearchTab { + SearchTab::Locations + } + + fn browse(&self, _cx: &App) -> Vec
{ + if self.0.is_empty() { + return Vec::new(); + } + vec![Section { + title: None, + rows: self.0.iter().cloned().map(Row::Item).collect(), + }] + } + + fn highlights(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn on_the_empty_all_tab(&self) -> bool { + false + } + + fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> { + rank(self.0, query, |_| 0) + } +} + struct Hosts<'a>(&'a [Item]); impl Source for Hosts<'_> { @@ -602,6 +727,71 @@ mod tests { .collect() } + /// Symbols lists the file's own first; what the language server found + /// across the project follows, and headings appear only when both do. + #[gpui::test] + fn symbols_lists_the_file_then_the_project(cx: &mut TestAppContext) { + with_config(cx); + let here = |name: &str, line| { + ( + 0, + Item::new(name, CommandKind::GoToSymbol { line, column: 0 }), + ) + }; + let there = |name: &str| { + Item::new( + name, + CommandKind::GoToLocation { + path: "/p/other.rs".into(), + line: 0, + column: 0, + }, + ) + }; + let mut catalog = Catalog::new(Vec::new(), Vec::new(), Vec::new()); + catalog.symbols = vec![here("render", 3), here("Config", 9)]; + let shape = |catalog: &Catalog, query: &str, cx: &mut TestAppContext| { + cx.update(|cx| { + catalog + .sections(SearchTab::Symbols, query, cx) + .into_iter() + .map(|s| { + let rows: Vec = s + .rows + .iter() + .filter_map(|r| r.item().map(|i| i.title.clone())) + .collect(); + (s.title.map(|t| t.to_string()), rows) + }) + .collect::>() + }) + }; + // Before the server answers: the file alone, untitled. + assert_eq!( + shape(&catalog, "render", cx), + vec![(None, vec!["render".to_string()])] + ); + catalog.project_symbols = vec![there("render_row"), there("unrelated")]; + assert_eq!( + shape(&catalog, "render", cx), + vec![ + (Some("In This File".into()), vec!["render".to_string()]), + (Some("Project".into()), vec!["render_row".to_string()]), + ] + ); + // Nothing here: the project's rows need no heading. + assert_eq!( + shape(&catalog, "unrelated", cx), + vec![(None, vec!["unrelated".to_string()])] + ); + // Nothing typed: the file's outline, never the project. + assert!( + shape(&catalog, "", cx) + .iter() + .all(|(_, rows)| !rows.contains(&"render_row".to_string())) + ); + } + #[gpui::test] fn actions_are_filed_under_their_group_whatever_order_they_came_in(cx: &mut TestAppContext) { with_config(cx); diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index 178cc2c6..193b4caf 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -80,6 +80,15 @@ impl SearchDelegate { }) } + /// How many rows the filter left, for a standalone tab's heading. + fn row_count(&self) -> usize { + self.sections + .iter() + .flat_map(|s| s.rows.iter()) + .filter(|r| r.item().is_some()) + .count() + } + fn first_row(&self) -> Option { let section = self.sections.iter().position(|s| !s.rows.is_empty())?; Some(IndexPath::new(0).section(section)) @@ -213,6 +222,11 @@ impl ListDelegate for SearchDelegate { cx: &mut Context>, ) -> Task<()> { self.query = query.to_string(); + if self.scope == Scope::Tab(SearchTab::Symbols) + && let Some(live) = self.catalog.live_query.clone() + { + live(query, cx); + } self.refresh(cx); // Through `set_selected_index`, not by hand: the row index may not have // moved, but the row under it has, and the theme picker previews the @@ -271,6 +285,13 @@ impl ListDelegate for SearchDelegate { Scope::Tab(SearchTab::Sessions) if self.query.trim().is_empty() => { t(L10nKey::SearchSessionsEmptyHint) } + Scope::Tab(SearchTab::Locations) if self.catalog.locations.is_empty() => { + t(L10nKey::SearchLocationsNone) + } + 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 +394,22 @@ 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, + }, + /// Move to another tab on the editor's row, which the app sets up. + SwitchEditorTab(SearchTab), + /// The same for a place a language server found (`ui::lsp`), which may + /// be in another file. + PreviewLocation { + path: std::path::PathBuf, + line: u32, + column: u32, + }, } pub struct SearchView { @@ -389,6 +426,18 @@ pub struct SearchView { /// How many session lists have arrived since the search opened. A test /// waits on it: the scan runs on a real thread and lands when it lands. sessions_landed: usize, + /// The symbol Go to Symbol opened on — the one around the caret — which + /// is not previewed until the highlight has moved: opening the picker + /// must not move the caret. + symbol_initial: Option, + symbol_moved: bool, + /// What a language server's list is of — References, Definitions — shown + /// where the scope row would be. The other standalone tabs are named by + /// their title. + heading: Option<&'static str>, + /// The editor's row as this window can fill it (`SearchTab::EDITOR_ORDER` + /// less what cannot answer), set by whoever opened an editor tab. + editor_tabs: Vec, _sub: Subscription, } @@ -420,6 +469,10 @@ impl SearchView { parked_query: None, previewing: None, sessions_landed: 0, + symbol_initial: None, + symbol_moved: false, + heading: None, + editor_tabs: Vec::new(), _sub, } } @@ -526,6 +579,96 @@ impl SearchView { self.update_catalog(|catalog| catalog.files = files, window, cx); } + /// Go to Symbol's rows, with the highlight on `selected` — the symbol the + /// caret is in — when it is one of them. + pub(crate) fn set_symbols( + &mut self, + symbols: Vec<(usize, Item)>, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.symbol_moved = false; + self.symbol_initial = selected.clone(); + self.update_catalog(|catalog| catalog.symbols = symbols, window, cx); + if let Some(kind) = selected { + self.list.update(cx, |state, cx| { + if let Some(ix) = state.delegate().position_of(&kind) { + state.set_selected_index(Some(ix), window, cx); + state.scroll_to_selected_item(window, cx); + } + }); + } + } + + /// Find References' rows (`ui::lsp`). The first is highlighted and not + /// previewed — opening the list must not move the caret — until the + /// highlight moves. + pub(crate) fn set_locations( + &mut self, + heading: Option<&'static str>, + locations: Vec, + window: &mut Window, + cx: &mut Context, + ) { + self.heading = heading; + self.symbol_moved = false; + self.symbol_initial = locations.first().map(|item| item.kind.clone()); + self.update_catalog(|catalog| catalog.locations = locations, window, cx); + } + + /// The editor tabs this window can offer, for the editor's row. + pub(crate) fn set_editor_tabs(&mut self, tabs: Vec, cx: &mut Context) { + self.editor_tabs = tabs; + cx.notify(); + } + + /// The row over the list: the window's, the editor's (when it has more + /// than one tab to offer), or none — the tab then stands alone under its + /// name. + fn scope_row(&self) -> Option> { + if !self.tab.stands_alone() { + return Some(SearchTab::ORDER.to_vec()); + } + (self.tab.in_editor_row() && self.editor_tabs.len() > 1).then(|| self.editor_tabs.clone()) + } + + /// Moves to `tab` on whichever row it is on. The editor's tabs each need + /// the app to set them up (an outline, a server to ask), so those go by + /// way of it. + fn go_to_tab(&mut self, tab: SearchTab, window: &mut Window, cx: &mut Context) { + if tab == self.tab { + return; + } + if tab.in_editor_row() { + cx.emit(SearchEvent::SwitchEditorTab(tab)); + } else { + self.set_tab(tab, None, window, cx); + } + } + + /// What the language server found across the project for the Symbols + /// tab's current query, listed after the file's own symbols. + pub(crate) fn set_project_symbols( + &mut self, + symbols: Vec, + window: &mut Window, + cx: &mut Context, + ) { + self.update_catalog(|catalog| catalog.project_symbols = symbols, window, cx); + } + + /// Sets what the Symbols tab asks the language server as the query + /// changes. + pub(crate) fn set_live_query( + &mut self, + live: crate::ui::search::LiveQuery, + window: &mut Window, + cx: &mut Context, + ) { + self.update_catalog(|catalog| catalog.live_query = Some(live), 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,13 +697,26 @@ 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 } fn step_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { - self.set_tab(self.tab.step(forward), None, window, cx); + let Some(row) = self.scope_row() else { return }; + let n = row.len(); + let i = row.iter().position(|t| *t == self.tab).unwrap_or(0); + let next = row[if forward { + (i + 1) % n + } else { + (i + n - 1) % n + }]; + self.go_to_tab(next, window, cx); } fn open_themes(&mut self, window: &mut Window, cx: &mut Context) { @@ -689,6 +845,32 @@ 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() + && matches!(self.tab, SearchTab::Locations | SearchTab::Symbols) + && let Some(Row::Item(item)) = list.read(cx).delegate().row_at(*ix) + && let CommandKind::GoToLocation { path, line, column } = &item.kind + { + if self.symbol_moved || self.symbol_initial.as_ref() != Some(&item.kind) { + self.symbol_moved = true; + cx.emit(SearchEvent::PreviewLocation { + path: path.clone(), + line: *line, + column: *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 @@ -718,32 +900,68 @@ impl SearchView { .bg(theme.popover) .border_b_1() .border_color(theme.border) - .children(SearchTab::ORDER.into_iter().enumerate().map(|(i, tab)| { - let active = tab == self.tab; - div() - .id(("search-tab", i)) - .h(px(24.)) - .px(px(9.)) - .flex() - .items_center() - .rounded(px(6.)) - .text_size(rems(SCOPE_TEXT)) - .cursor_pointer() - .map(|d| match active { - true => d - .bg(active_bg) - .text_color(fg) - .font_weight(FontWeight::MEDIUM), - false => d + .when(self.scope_row().is_none(), |row| { + // Go to File's rows are a capped sample of the project, so a + // count there would read as the size of something it is not. + let count = match self.tab { + SearchTab::Files => 0, + _ => self.list.read(cx).delegate().row_count(), + }; + let heading = match self.tab { + SearchTab::Locations => self.heading.unwrap_or_else(|| self.tab.title()), + _ => self.tab.title(), + }; + row.child( + div() + .h(px(24.)) + .px(px(9.)) + .flex() + .items_center() + .rounded(px(6.)) + .bg(active_bg) + .text_size(rems(SCOPE_TEXT)) + .text_color(fg) + .font_weight(FontWeight::MEDIUM) + .child(heading), + ) + .when(count > 0, |row| { + row.child( + div() + .px(px(6.)) + .text_size(rems(SCOPE_TEXT)) .text_color(muted) - .hover(move |d| d.bg(hover_bg).text_color(fg)), - }) - .child(tab.title()) - .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - this.set_tab(tab, None, window, cx); - this.list.update(cx, |state, cx| state.focus(window, cx)); - })) - })) + .child(count.to_string()), + ) + }) + }) + .when_some(self.scope_row(), |row, tabs| { + row.children(tabs.into_iter().enumerate().map(|(i, tab)| { + let active = tab == self.tab; + div() + .id(("search-tab", i)) + .h(px(24.)) + .px(px(9.)) + .flex() + .items_center() + .rounded(px(6.)) + .text_size(rems(SCOPE_TEXT)) + .cursor_pointer() + .map(|d| match active { + true => d + .bg(active_bg) + .text_color(fg) + .font_weight(FontWeight::MEDIUM), + false => d + .text_color(muted) + .hover(move |d| d.bg(hover_bg).text_color(fg)), + }) + .child(tab.title()) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + this.go_to_tab(tab, window, cx); + this.list.update(cx, |state, cx| state.focus(window, cx)); + })) + })) + }) } /// The switcher's footer, minus its New workspace button: the keys that @@ -757,7 +975,7 @@ impl SearchView { .children(keys) .child(label) }; - let tabs = !self.in_sub_list(); + let tabs = !self.in_sub_list() && self.scope_row().is_some(); h_flex() .flex_none() .items_center() @@ -973,6 +1191,17 @@ impl Render for SearchView { .on_action( cx.listener(|this, _: &SearchPrevTab, window, cx| this.step_tab(false, window, cx)), ) + // Go to Symbol's chord puts Go to Symbol away, as Go to File's + // does; anywhere else in the search it is the switcher's. + .on_action(cx.listener( + |this, _: &crate::core::actions::EditorGoToSymbol, _window, cx| { + if this.tab == SearchTab::Symbols && !this.in_sub_list() { + cx.emit(SearchEvent::Dismiss); + } else { + cx.propagate(); + } + }, + )) .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if is_edit_gesture(&ev.keystroke) && this.on_edit_gesture(window, cx) { cx.stop_propagation(); @@ -1165,6 +1394,28 @@ mod tests { assert!(app.read_with(&vcx, |app, _| app.search.is_none())); } + /// A tab reached by its own chord stands alone: Tab does not swap Go to + /// File or the places a language server found for the terminals. + #[gpui::test] + fn a_standalone_tab_keeps_its_list_on_tab(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + for tab in [SearchTab::Files, SearchTab::Symbols, SearchTab::Locations] { + app.update_in(&mut vcx, |app, window, cx| { + app.open_search(tab, "", window, cx) + }); + vcx.run_until_parked(); + let view = open(&app, &mut vcx); + view.update_in(&mut vcx, |view, window, cx| { + view.step_tab(true, window, cx); + view.step_tab(false, window, cx); + }); + view.read_with(&vcx, |view, _| assert_eq!(view.tab, tab)); + app.update_in(&mut vcx, |app, window, cx| app.close_search(window, cx)); + vcx.run_until_parked(); + } + } + /// The Actions tab's "Go to File…" row moves the search to its Files tab /// rather than closing it, with whatever found the row cleared away. #[gpui::test] diff --git a/src/ui/settings.rs b/src/ui/settings.rs index cda90751..cbf19367 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -31,6 +31,7 @@ use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::presets; mod agents; +mod editor; mod hosts; pub(crate) mod kit; mod pages; @@ -676,6 +677,26 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsSidebarGrouping, keywords: SettingsSearchSidebarGroupingKeywords, }, + SearchEntry { + section: General, + title: SettingsEditorGitGutter, + keywords: SettingsSearchEditorGitGutterKeywords, + }, + SearchEntry { + section: General, + title: SettingsEditorLsp, + keywords: SettingsSearchEditorLspKeywords, + }, + SearchEntry { + section: General, + title: SettingsEditorSoftWrap, + keywords: SettingsSearchEditorSoftWrapKeywords, + }, + SearchEntry { + section: General, + title: SettingsEditorMarkdownPreview, + keywords: SettingsSearchEditorMarkdownPreviewKeywords, + }, SearchEntry { section: General, title: SettingsNotifyOnCommandFinish, @@ -762,6 +783,10 @@ impl SearchEntry { L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping", + L10nKey::SettingsEditorGitGutter => "editor_git_gutter", + L10nKey::SettingsEditorLsp => "editor_lsp", + L10nKey::SettingsEditorSoftWrap => "editor_soft_wrap", + L10nKey::SettingsEditorMarkdownPreview => "editor_markdown_preview", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", L10nKey::SettingsNotifyThreshold => "notify_threshold_secs", L10nKey::SettingsTerminalBell => "bell", @@ -871,6 +896,10 @@ impl SearchEntry { L10nKey::SettingsNewTabPosition => t(L10nKey::SettingsNewTabPositionDesc), L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc), L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc), + L10nKey::SettingsEditorGitGutter => t(L10nKey::SettingsEditorGitGutterDesc), + L10nKey::SettingsEditorLsp => t(L10nKey::SettingsEditorLspDesc), + L10nKey::SettingsEditorSoftWrap => t(L10nKey::SettingsEditorSoftWrapDesc), + L10nKey::SettingsEditorMarkdownPreview => t(L10nKey::SettingsEditorMarkdownPreviewDesc), L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc), L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc), L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc), @@ -916,6 +945,12 @@ impl SearchEntry { L10nKey::SettingsSidebarGrouping => { cfg.sidebar_auto_grouping != defaults.sidebar_auto_grouping } + L10nKey::SettingsEditorGitGutter => cfg.editor_git_gutter != defaults.editor_git_gutter, + L10nKey::SettingsEditorLsp => cfg.editor_lsp != defaults.editor_lsp, + L10nKey::SettingsEditorSoftWrap => cfg.editor_soft_wrap != defaults.editor_soft_wrap, + L10nKey::SettingsEditorMarkdownPreview => { + cfg.editor_markdown_preview != defaults.editor_markdown_preview + } L10nKey::SettingsNotifyOnCommandFinish => { cfg.notify_on_command_finish != defaults.notify_on_command_finish } diff --git a/src/ui/settings/editor.rs b/src/ui/settings/editor.rs new file mode 100644 index 00000000..bcb49057 --- /dev/null +++ b/src/ui/settings/editor.rs @@ -0,0 +1,81 @@ +//! The Editor group on the General page: the code editor's switches that +//! used to live only in config.json or in a status-bar toggle. + +use super::*; + +impl Tty7App { + pub(super) fn render_editor_group(&self, cx: &mut Context) -> AnyElement { + let cfg = cx.global::(); + let (git_gutter, lsp, soft_wrap, markdown) = ( + cfg.editor_git_gutter, + cfg.editor_lsp, + cfg.editor_soft_wrap, + cfg.editor_markdown_preview, + ); + let git_gutter = + self.settings_switch("wt-editor-git-gutter", git_gutter, cx, |this, on, _, cx| { + this.set_editor_git_gutter(on, cx) + }); + let lsp = self.settings_switch("wt-editor-lsp", lsp, cx, |this, on, _, cx| { + this.set_editor_lsp(on, cx) + }); + let soft_wrap = + self.settings_switch("wt-editor-soft-wrap", soft_wrap, cx, |this, on, _, cx| { + this.set_editor_soft_wrap(on, cx) + }); + let markdown = + self.settings_switch("wt-editor-markdown", markdown, cx, |this, on, _, cx| { + this.set_editor_markdown_preview(on, cx) + }); + self.settings_group( + Some(t(L10nKey::SettingsEditor)), + None, + [ + self.settings_row( + t(L10nKey::SettingsEditorGitGutter), + t(L10nKey::SettingsEditorGitGutterDesc), + git_gutter, + cx, + ), + self.settings_row( + t(L10nKey::SettingsEditorLsp), + t(L10nKey::SettingsEditorLspDesc), + lsp, + cx, + ), + self.settings_row( + t(L10nKey::SettingsEditorSoftWrap), + t(L10nKey::SettingsEditorSoftWrapDesc), + soft_wrap, + cx, + ), + self.settings_row( + t(L10nKey::SettingsEditorMarkdownPreview), + t(L10nKey::SettingsEditorMarkdownPreviewDesc), + markdown, + cx, + ), + ] + .map(IntoElement::into_any_element), + cx, + ) + } + + /// `LspStore` watches the config: off closes every document and lets + /// its server go; on asks every window for the files it has open, which + /// starts their servers again. + pub(crate) fn set_editor_lsp(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.editor_lsp = on); + cx.notify(); + } + + /// What new files open with; files already open keep their own Wrap. + pub(crate) fn set_editor_soft_wrap(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.editor_soft_wrap = on); + } + + /// What Markdown files open as; files already open keep their own mode. + pub(crate) fn set_editor_markdown_preview(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.editor_markdown_preview = on); + } +} diff --git a/src/ui/settings/pages.rs b/src/ui/settings/pages.rs index 42e903a9..daea548d 100644 --- a/src/ui/settings/pages.rs +++ b/src/ui/settings/pages.rs @@ -194,6 +194,7 @@ impl Tty7App { cx, ), self.render_tabs_group(cx), + self.render_editor_group(cx), self.settings_group( Some(t(L10nKey::SettingsNotifications)), None, diff --git a/src/ui/theme.rs b/src/ui/theme.rs index c9834ff1..1652df26 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -86,6 +86,10 @@ pub(crate) fn set_menus(cx: &mut App) { Menu::new(t(L10nKey::AppMenuView)).items([ MenuItem::action(t(L10nKey::AppMenuSearchEverywhere), TogglePalette), MenuItem::action(t(L10nKey::CmdGoToFile), QuickOpenFile), + MenuItem::action(t(L10nKey::EditorGoToSymbolAction), EditorGoToSymbol), + MenuItem::action(t(L10nKey::EditorNavigateBack), EditorNavigateBack), + MenuItem::action(t(L10nKey::EditorNavigateForward), EditorNavigateForward), + MenuItem::action(t(L10nKey::EditorSplitRight), EditorSplitRight), MenuItem::separator(), MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize), MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),