feat(editor): IDE-level code editor — multi-cursor, LSP, git gutter, symbols, split (#1002)

* fix(keymap): let the code editor's multi-cursor chords beat the pane keys

gpui-component now binds secondary-d, secondary-shift-l and
secondary-alt-up/down in the Input context for multiple cursors. A
context-free binding ranks as deep as the focused context and ties go to
the one added last, so tty7's SplitRight and FocusPaneUp/Down took those
keys inside the editor. Re-add the editor bindings after tty7's table; other
text fields have no handler for them and fall through to the pane keys.

* feat(editor): show the selection count in the status bar

With several cursors the Ln/Col readout (the primary caret's) is followed
by "(N selections)".

* feat(editor): git change markers in the gutter

Diff each buffer against its file's index version (what VS Code's quick
diff and the SCM panel's Changes compare with) and hand the hunks to the
editor as gutter markers: added, modified, and a wedge where lines were
deleted. The base is read with `git show :./name` through the buffer's own
host, so remote workspaces work too; SFTP buffers and untracked files get
no markers. It is re-read when the repository's SCM epoch moves, on save
and when the path changes; the diff itself is a line-level Myers diff run
off the UI thread shortly after each edit.

Next/previous change and Revert Change (one undo step, also in the
right-click menu) are commands; clicking a marker opens a peek of the
staged lines with a Revert button. The editor watch now also follows the
repository the gutter found, so a stage or checkout refreshes the markers.
The `editor_git_gutter` config (default on) is flipped by the
ToggleEditorGitGutter command.

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

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

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

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

Back and forward (Ctrl-- / Ctrl-Shift-- on macOS, Alt-Left/Right elsewhere)
walk a per-tab history. Jumps are noticed by sampling the caret whenever the
editor draws: a change of file, or a move of ten lines or more without an
edit, records where the caret was. Quick open, go to line, file links and
anything that goes through open_file_in_editor_at are therefore captured
without hooks of their own; a place in a closed file reopens it.

* feat(editor): bind next/previous change to Alt+F5 in the code editor

Bind EditorNextChange / EditorPrevChange to alt-f5 / shift-alt-f5 in the
Input context only, after tty7's own table, so a terminal never loses the
function key. Mark the hunks stale right after a revert, before the
input's Change event lands, and cover the whole loop (markers, stepping,
revert as one undo) with a window test.

* feat(editor): line commands in the editor's right-click menu

Toggle Comment, Move Line Up/Down, Duplicate Line, Delete Line and Go to
Matching Bracket, dispatched to gpui-component's new editing actions so
each row shows its chord. Strings in en, zh and ja.

* feat(editor): language servers for the code editor

A new ui::lsp module runs language servers for local files the editor
opens: rust-analyzer, typescript-language-server, pyright (or pylsp),
gopls and clangd, from a registry table. Each server is keyed by
(server, project root), found from markers such as a Cargo workspace,
package.json, go.work/go.mod or pyproject.toml, and shared by every buffer
under that root. A server missing from PATH (which tty7 already fills from
the login shell) means no LSP for that language and a one-line hint in the
status bar.

The JSON-RPC client frames Content-Length messages over the server's stdio
on dedicated threads, holds everything back until initialize is answered,
cancels requests nobody waits for any more, and answers the server's own
requests (configuration, capability registration, applyEdit). Documents
use full-text sync, debounced, and flushed before every request. A server
whose last file closed shuts down after a grace period, every server exits
with the app, and one that crashes is restarted a few times before it is
left down.

Features: diagnostics as underlines with hover messages and an error and
warning count in the status bar; completion (snippets flattened to plain
text); hover; go to definition by secondary-click or F12, across files
through open_file_in_editor_at; code actions on the editor's own menu
(cmd-.), resolved and executed as the server needs; Format Document
(shift-alt-f); and Rename Symbol (F2) in the editor's bar, applied to open
buffers and to files on disk. The key bindings sit in the Input context so
F2 and F12 stay with terminal programs.

Positions are converted at the boundary: servers count UTF-16 units,
gpui-component counts chars, and the rope counts bytes.

The new editor_lsp setting (default on) turns it all off.

* fix(editor): clear the change markers when the file loses its base

A file that becomes untracked or leaves its repository kept the markers
of its last diff; clear them when the base goes away. Guard the base read
against a panic, which would otherwise leave the fetch flagged forever,
and test the read against a real repository: the base is the staged
version, and untracked files have none.

* test(keymap): the editor's navigation chords win inside the editor, not in a terminal

* fix(keymap): let the code editor's line commands beat the app's chords

The editor binds toggle comment, move/copy/delete line, insert line, select
line and go-to-bracket on its CodeEditor key context. A context-free app
binding ranks as deep as the focused context and wins the tie by being
added later, so ⌘/ (shortcut sheet), ⌘↵ (fullscreen), ⌘⇧↵ (maximize) and,
off macOS, ⌥↑/⌥↓ (pane focus) took those keys inside the editor. Re-add
them in fixed_bindings on CodeEditor, which only a multi-line code editor
declares, so plain text fields and the terminal keep the app's keys.

* fix(keymap): route cmd-K cmd-D to the code editor's skip-occurrence

The chord starts with ClearScrollback's key on macOS, and gpui drops a
pending chord that ranks below a complete match, so the fork's binding
never got its second key. Re-add it after tty7's table, in the CodeEditor
context only, so other text fields don't wait on cmd-K.

* feat(editor): history follows edits, and paging is not a jump

Places in the back/forward history now move with lines inserted or deleted
above them, read from the editor's line-edit log on every change. The caret
move a Page Up/Down, paste, undo or redo makes is heard through the
keystroke that caused it and is not recorded as a jump.

* feat(editor): Problems list, keyboard change peek, Editor settings

- Problems: every error, warning and note the language servers published
  for the open files, grouped by file, at the foot of the code panel.
  The status bar's counts and ToggleEditorProblems (Cmd/Ctrl+Shift+M)
  open it; a row opens its file at the line and column. Read through a
  new LspStore::diagnostics_snapshot, mapped to editor columns.
- EditorPeekChange (Alt+F3 in the editor) peeks the change under the
  caret, or goes to the next one. The peek now takes the keyboard from
  a click too: Enter reverts, Escape closes and hands focus back.
- Settings > General gains an Editor group: git change markers,
  language servers, soft wrap and rendered Markdown, searchable and
  resettable like every other row.

* feat(lsp): outline, references, workspace symbols, signature help

- documentSymbol feeds the editor's outline (breadcrumbs, Go to Symbol)
  through editor_set_document_symbols, refreshed 500 ms after typing
  pauses. Flat answers are nested by range; an empty answer from a server
  still indexing leaves the tree's outline in place.
- Find All References (shift-F12) and a definition with several answers
  open a Locations tab in the search. Arrowing through it previews a place
  in the file in front, and Return goes there, into any file.
- Go to Symbol in Workspace (secondary-T inside the editor) asks the
  server's workspace/symbol as the query is typed, into the same list.
- Completion items are resolved for their documentation and auto-import
  edits, which are applied on accept (with the gpui-component fork).
- Signature help opens on the server's trigger characters (or on '(' and
  ','), stays current while typing in the call, and closes when the server
  says the cursor has left it or on Escape.
- Diagnostics are replaced wholesale on publish; between publishes the
  fork now carries them through edits instead of dropping them.
- workspace/configuration answers from per-server settings, with an empty
  object for a section tty7 sets nothing for. rust-analyzer gets
  checkOnSave with cargo check, also as initializationOptions, and every
  server gets didChangeConfiguration after initialized.

* feat(editor): text commands in the palette and keymap

Transform to Upper/Lower/Title Case, Trim Trailing Whitespace, Join Lines
and Remove Surrounding Brackets, as tty7 actions (bindable on the
Keybindings page, unbound by default) and as palette rows offered while a
buffer is open. Both run gpui-component's editing action on the active
buffer. Join Lines gets VS Code's ctrl-j as a fixed CodeEditor binding on
macOS, where the terminal keeps it as a line feed. Strings in en, zh, ja.

* feat(editor): split the editor into two groups

Cmd-\ (Ctrl-\ off macOS, bound only inside the editor so the terminal keeps
SIGQUIT) opens the file in front in a second group on the right. Each group
has its own file in front and, for different files, its own caret and
scroll; Cmd-Alt-Left/Right (Ctrl-Alt off macOS) or a click moves the focus
between them, and a group whose last file closes goes away. The split is
saved with the tab's other editor state.

The focused group is always TabCode's own files/active and the other waits
beside it, so everything that acts on the file in front - saving, go to
line, language servers, change markers, multiple cursors, history - follows
the focus unchanged. A file shown in both groups is drawn once, in the
focused one; the other shows a placeholder that brings the focus over. A
second InputState kept in sync would have needed every hook attached twice.

Also: palette rows and View menu items for Go to Symbol, Back, Forward and
Split; Cmd-Shift-O closes Go to Symbol when it is open; the status bar no
longer repeats the path the breadcrumbs show (a rendered Markdown file keeps
its breadcrumb path); change markers are kept current in both groups.

* test(keymap): the editor group chords win inside the editor

* fix(lsp): close a window's documents when the window closes

Documents were only closed by sync_window, which runs when a window's
buffer set changes. Closing a window never ran it, so no didClose was
sent, the idle shutdown never started, and a language server lived on
until quit (forever, with the app retired to the tray).

* fix(editor): restore a split whose left group had no recorded files

The recorder writes files: [] with a split when the left group held only
untitled or remote buffers, but restore returned early on an empty left
list and dropped the right group too.

* fix(editor): forget a swept orphan buffer's navigation state

The orphan sweep dropped clean buffers without calling forget_buffer, so
each one's outline cache (a full copy of its text), LSP symbols and
edit-log cursor stayed in EditorNav for the life of the window.

* fix(lsp): owner-only sync, stale-edit checks, request timeouts, re-enable

- Only the buffer that owns a document may use its server. The same file
  open in another window used to send its own text as didChange on F12,
  rename, references or signature help, swapping the document under the
  owner. LspStore::context now takes the requester and refuses a
  non-owner quietly, before anything is sent.
- apply_workspace_edit checks every open buffer an edit touches against
  a baseline: the texts when a rename was asked for or the code-action
  menu was filled, or the text the server last heard for its own
  workspace/applyEdit (which then answers applied: false). A buffer typed
  in, opened or closed since means nothing is applied.
- A request made after the server's output closed fails at once instead
  of waiting forever. Requests time out after 10 s (initialize after 60 s,
  shutdown after 2 s) and cancel themselves on the server.
- Turning editor_lsp back on asks every window for its open files again,
  which starts their servers. Windows register how to be asked; closed
  ones are forgotten.

* test(nav): spell out LineEdit's new at_line_start field

gpui-component's LineEdit now records an insertion at column 0, which
moves the whole line down. The literal edits in this test are mid-line.

* fix(editor): a restore merges into the tab, and commands follow the group focus

Session restore used to assign the recorded groups over whatever the tab
held when its files finished loading, so a file opened or a split made in
the meantime was lost. TabCode::restore_groups keeps a split made meanwhile
as it is, and otherwise lays out the recorded groups and puts the files
opened meanwhile back into the focused group, the last of them in front. A
restore the reader has moved on from no longer takes the keyboard, and never
hides a panel they opened.

The group focus only followed the keyboard when the editor drew. Keys are
safe - gpui draws a window whose focus moved before dispatching the next
key - but a command from the menu bar or a context menu is dispatched
without a draw, and acted on the group that had the focus before. A
capture-phase listener for every editor command on the editor's element now
settles the group first, wherever the command's handler sits.

* fix(editor): leave cmd-T to New Tab; offer workspace symbols from the palette

Inside the code editor cmd-T was rebound to Go to Symbol in Workspace,
which made tty7's most-used chord mean two things depending on focus.
It is New Tab everywhere again. Workspace symbols stay reachable as an
Editor palette row and as a rebindable, unbound action.

* build: pin gpui-component to the fork's editor work (0e7541fb)

* fix(search): let the editor's pickers stand alone in Search Everywhere

Go to Symbol, a language server's places and Go to Symbol in Workspace
opened on hidden tabs, so the window-wide scope row (All, Terminals,
Sessions, Hosts, Commands) sat over them with nothing selected, and Tab
swapped the list for the terminals. They now show a heading in place of
the row (References / Definitions / Symbols / Workspace Symbols, with a
count), Tab stays put, and the footer drops the scope hint.

* fix(search): Go to File stands alone like the editor's pickers

Go to File (cmd-O) is reached only by its chord, yet it sat under the
scope row with nothing lit and offered Tab to leave for the terminals.
Every tab outside the row now stands alone the same way: its name in
place of the row, Tab stays put, no scope hint in the footer.

* feat(search): give the editor its own scope row — Files, Symbols, Workspace Symbols

Go to File, Go to Symbol and Go to Symbol in Workspace were three
separate pickers, each on its own chord. They now share a second scope
row, the editor's, next to the window's: cmd-O and cmd-shift-O open on
it and Tab walks it. Only tabs that can answer show: Symbols needs a
file in front, Workspace Symbols a language server that searches the
project. A row of one shows as a heading. Each tab is opened the way its
chord opens it, so it arrives set up; references and definitions still
stand alone.

* feat(search): fold Workspace Symbols into Symbols

Symbols and Workspace Symbols answered the same question at two scopes.
Symbols now does both: with nothing typed it is the file's outline; once
a query is typed, the front file's language server is asked across the
project and its answers are listed after the file's own, each under a
heading when both have rows (the front file's own hits are dropped from
the project's). The separate tab, action and palette row are gone.

* fix(search): call the language server's project results Project, not Workspace

LSP's workspace/symbol searches the server's project root, which has
nothing to do with a tty7 workspace (a group of tabs). The Symbols tab's
second section said Workspace, reading as if it searched those. It says
Project now, and the tty7-facing names follow (project_symbols,
set_project_symbols, lsp_project_symbol_query); only code that speaks
the protocol keeps its word.

* ci(host-boundary): allow the language servers' local reads

ui::lsp only ever holds local buffers (OpenFile::local refuses any other
host) and runs its servers on this machine, so the files it reads to
measure a column, apply a rename to disk or find a project root are on
this disk. Each call is allowlisted with that reason.

* test(editor): spell test paths so they hold on Windows

The language-server tests used /p/... paths, which are not absolute on
Windows and so have no file:// URI; they now build platform paths
(lsp::test_path) or spell the URI out. The gutter and split tests
canonicalized their temp dirs to get past macOS's /private symlink,
which on Windows yields the \\?\ form no editor path is ever in; they
share a helper that canonicalizes everywhere but Windows.

* test(editor): resolve temp dirs the way the editor does

On the Windows runner the temp dir is an 8.3 short name (RUNNER~1),
which the editor's load expands through Host::canonicalize. The split
tests now resolve their fixtures through that same call instead of
guessing per platform.
This commit is contained in:
l0ng-ai
2026-09-28 20:47:07 +08:00
committed by GitHub
parent 3ef692b353
commit 7016fdb7ed
39 changed files with 12884 additions and 174 deletions
+17
View File
@@ -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
)
Generated
+4 -3
View File
@@ -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",
+3
View File
@@ -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
+14
View File
@@ -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,
+23
View File
@@ -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,
+6 -2
View File
@@ -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::<crate::terminal::git_status::GitStatusCache>()?
.repo_root_for(host, path.parent()?)?;
.try_global::<crate::terminal::git_status::GitStatusCache>()
.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()))
}
+188 -2
View File
@@ -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());
+449 -136
View File
@@ -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<LocalFile> {
(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<InputState>,
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<PathBuf>,
pub(crate) expanded: std::collections::HashSet<PathBuf>,
pub(crate) selected: Option<PathBuf>,
/// 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<split::OtherGroup>,
}
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<PathBuf>,
watched_files: HashSet<PathBuf>,
events_tx: smol::channel::Sender<Vec<PathBuf>>,
/// Back/forward history, outlines and Go to Symbol's preview.
nav: nav::EditorNav,
/// 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<LocalFile> {
self.editor
.buffers
.iter()
.filter_map(OpenFile::local)
.collect()
}
pub(crate) fn editor_local_file(&self, id: BufferId) -> Option<LocalFile> {
self.buffer(id).and_then(OpenFile::local)
}
pub(crate) fn editor_active_local_file(&self) -> Option<LocalFile> {
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>,
) {
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<Self>) {
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<Self>,
) -> 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<gpui::Div>,
cx: &mut Context<Self>,
) -> gpui::Stateful<gpui::Div> {
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<Self>) -> 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<PathBuf> = 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::<Vec<_>>()
},
move |app, loaded: Vec<Option<Loaded>>, 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<BufferId>, usize) {
let front = paths.get(active);
let mut ids: Vec<BufferId> = 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<BufferId> = 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<PathBuf>, 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>) {
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<BufferId>,
slot: usize,
cx: &mut Context<Self>,
) -> 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<Self>) -> 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<SharedString> = 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<SharedString> = 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<bool> = 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)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+778
View File
@@ -0,0 +1,778 @@
//! A file's symbols — what Go to Symbol lists and the breadcrumbs walk.
//!
//! Built from the tree the editor already parsed for highlighting
//! ([`gpui_component::input::InputState::syntax_tree`]), with one small query
//! per language saying which nodes are symbols and where their names are. No
//! second parse, and nothing to install.
//!
//! An [`Outline`] is plain data, so it need not come from tree-sitter at all:
//! [`Outline::from_nodes`] takes the same nested shape a language server's
//! `textDocument/documentSymbol` answers with, and a buffer given one through
//! `Tty7App::editor_set_document_symbols` shows that instead of the tree's.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::{Arc, LazyLock, Mutex};
use gpui_component::Rope;
use gpui_component::highlighter::LanguageRegistry;
use gpui_component::highlighter::tree_sitter::{self, Query, QueryCursor, StreamingIterator as _};
use gpui_component::input::{Position, RopeExt as _};
/// What a symbol is — the shapes the picker and the breadcrumbs tell apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum SymbolKind {
Module,
Namespace,
Class,
Struct,
Enum,
Interface,
Trait,
Impl,
Function,
Method,
Constructor,
Constant,
Variable,
Field,
Type,
Macro,
Heading,
}
impl SymbolKind {
/// The capture suffix a query names this kind by (`@item.function`).
fn from_capture(suffix: &str) -> Option<Self> {
Some(match suffix {
"module" => Self::Module,
"namespace" => Self::Namespace,
"class" => Self::Class,
"struct" => Self::Struct,
"enum" => Self::Enum,
"interface" => Self::Interface,
"trait" => Self::Trait,
"impl" => Self::Impl,
"function" => Self::Function,
"method" => Self::Method,
"constructor" => Self::Constructor,
"constant" => Self::Constant,
"variable" => Self::Variable,
"field" => Self::Field,
"type" => Self::Type,
"macro" => Self::Macro,
"heading" => Self::Heading,
_ => return None,
})
}
/// LSP's `SymbolKind` number, for a language server's answer. Kinds with
/// no counterpart here (a file, a package, an event…) fold into the
/// nearest one that draws the same.
pub(crate) fn from_lsp(kind: u32) -> Self {
match kind {
1 | 2 | 4 => Self::Module, // File, Module, Package
3 => Self::Namespace,
5 => Self::Class,
6 => Self::Method,
7 | 8 => Self::Field, // Property, Field
9 => Self::Constructor,
10 => Self::Enum,
11 => Self::Interface,
12 => Self::Function,
13 => Self::Variable,
14 | 22 => Self::Constant, // Constant, EnumMember
23 => Self::Struct,
26 => Self::Type, // TypeParameter
_ => Self::Variable,
}
}
/// Whether a function directly inside this is a method of it.
fn holds_methods(self) -> bool {
matches!(
self,
Self::Class | Self::Struct | Self::Enum | Self::Interface | Self::Trait | Self::Impl
)
}
/// The short tag the picker shows beside a name. Code's own words, the
/// same in every locale, like the keywords they stand for.
pub(crate) fn tag(self) -> &'static str {
match self {
Self::Module => "mod",
Self::Namespace => "namespace",
Self::Class => "class",
Self::Struct => "struct",
Self::Enum => "enum",
Self::Interface => "interface",
Self::Trait => "trait",
Self::Impl => "impl",
Self::Function => "fn",
Self::Method => "method",
Self::Constructor => "ctor",
Self::Constant => "const",
Self::Variable => "var",
Self::Field => "field",
Self::Type => "type",
Self::Macro => "macro",
Self::Heading => "#",
}
}
}
/// One symbol, flattened into its outline.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Symbol {
pub name: String,
pub kind: SymbolKind,
/// The whole item, 0-based, in the editor's own [`Position`]s.
pub range: Range<Position>,
/// Where a jump to it lands: its name.
pub selection: Position,
/// How many symbols it sits inside.
pub depth: usize,
/// The symbol it sits directly inside, as an index into the outline.
pub parent: Option<usize>,
}
/// A nested symbol, the shape an LSP `DocumentSymbol` has — what an outline
/// from anywhere other than the tree is built out of.
#[derive(Clone, Debug)]
pub(crate) struct SymbolNode {
pub name: String,
pub kind: SymbolKind,
pub range: Range<Position>,
pub selection: Position,
pub children: Vec<SymbolNode>,
}
/// A file's symbols in document order, every container before what it holds.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Outline {
pub symbols: Vec<Symbol>,
}
fn key(p: Position) -> (u32, u32) {
(p.line, p.character)
}
impl Outline {
/// Flattens a nested answer (a language server's `documentSymbol`).
pub(crate) fn from_nodes(nodes: Vec<SymbolNode>) -> Self {
fn walk(
nodes: Vec<SymbolNode>,
parent: Option<usize>,
depth: usize,
out: &mut Vec<Symbol>,
) {
let mut nodes = nodes;
nodes.sort_by_key(|n| key(n.range.start));
for node in nodes {
let ix = out.len();
out.push(Symbol {
name: node.name,
kind: node.kind,
range: node.range,
selection: node.selection,
depth,
parent,
});
walk(node.children, Some(ix), depth + 1, out);
}
}
let mut symbols = Vec::new();
walk(nodes, None, 0, &mut symbols);
Self { symbols }
}
pub(crate) fn is_empty(&self) -> bool {
self.symbols.is_empty()
}
/// The symbols `pos` is inside, outermost first — the breadcrumbs' trail.
pub(crate) fn chain_at(&self, pos: Position) -> Vec<usize> {
let at = key(pos);
// Children follow their parent and siblings never overlap, so the
// last symbol that holds the position is the innermost one.
let Some(innermost) = self
.symbols
.iter()
.rposition(|s| key(s.range.start) <= at && at <= key(s.range.end))
else {
return Vec::new();
};
let mut chain = vec![innermost];
let mut cur = innermost;
while let Some(parent) = self.symbols[cur].parent {
chain.push(parent);
cur = parent;
}
chain.reverse();
chain
}
/// The names of what symbol `ix` sits inside, outermost first.
pub(crate) fn container_path(&self, ix: usize) -> Vec<&str> {
let mut names = Vec::new();
let mut cur = self.symbols.get(ix).and_then(|s| s.parent);
while let Some(p) = cur {
names.push(self.symbols[p].name.as_str());
cur = self.symbols[p].parent;
}
names.reverse();
names
}
}
// ---- tree-sitter ----
//
// Captures: `@item.<kind>` on the node that is the symbol, `@name` on its name
// (several are joined with a space), `@body` on an impl's block — which names
// the symbol by everything before it, `impl<T> Display for Wrapper<T>` — and
// `@receiver` on a Go method's receiver list, which goes in front of the name
// as `(*Server).Serve`. When two patterns claim one node the earlier wins, so
// a catch-all belongs last.
const RUST: &str = r#"
(mod_item name: (identifier) @name) @item.module
(struct_item name: (type_identifier) @name) @item.struct
(union_item name: (type_identifier) @name) @item.struct
(enum_item name: (type_identifier) @name) @item.enum
(trait_item name: (type_identifier) @name) @item.trait
(impl_item body: (declaration_list) @body) @item.impl
(function_item name: (identifier) @name) @item.function
(function_signature_item name: (identifier) @name) @item.function
(const_item name: (identifier) @name) @item.constant
(static_item name: (identifier) @name) @item.constant
(type_item name: (type_identifier) @name) @item.type
(macro_definition name: (identifier) @name) @item.macro
"#;
const GO: &str = r#"
(function_declaration name: (identifier) @name) @item.function
(method_declaration receiver: (parameter_list) @receiver name: (field_identifier) @name) @item.method
(type_spec name: (type_identifier) @name type: (struct_type)) @item.struct
(type_spec name: (type_identifier) @name type: (interface_type)) @item.interface
(type_spec name: (type_identifier) @name) @item.type
(const_spec name: (identifier) @name) @item.constant
"#;
const PYTHON: &str = r#"
(class_definition name: (identifier) @name) @item.class
(function_definition name: (identifier) @name) @item.function
"#;
const JAVASCRIPT: &str = r#"
(class_declaration name: (_) @name) @item.class
(function_declaration name: (identifier) @name) @item.function
(generator_function_declaration name: (identifier) @name) @item.function
(method_definition name: (_) @name) @item.method
(variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)]) @item.function
"#;
const TYPESCRIPT_EXTRA: &str = r#"
(abstract_class_declaration name: (_) @name) @item.class
(interface_declaration name: (_) @name) @item.interface
(type_alias_declaration name: (_) @name) @item.type
(enum_declaration name: (_) @name) @item.enum
(internal_module name: (_) @name) @item.module
(function_signature name: (_) @name) @item.function
(method_signature name: (_) @name) @item.method
(abstract_method_signature name: (_) @name) @item.method
"#;
const C: &str = r#"
(function_definition declarator: (function_declarator declarator: (_) @name)) @item.function
(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (_) @name))) @item.function
(struct_specifier name: (_) @name body: (_)) @item.struct
(union_specifier name: (_) @name body: (_)) @item.struct
(enum_specifier name: (_) @name body: (_)) @item.enum
(type_definition declarator: (type_identifier) @name) @item.type
"#;
const CPP_EXTRA: &str = r#"
(namespace_definition name: (_) @name) @item.namespace
(class_specifier name: (_) @name body: (_)) @item.class
(function_definition declarator: (reference_declarator (function_declarator declarator: (_) @name))) @item.function
(field_declaration declarator: (function_declarator declarator: (_) @name)) @item.method
(alias_declaration name: (type_identifier) @name) @item.type
"#;
const JAVA: &str = r#"
(class_declaration name: (identifier) @name) @item.class
(interface_declaration name: (identifier) @name) @item.interface
(enum_declaration name: (identifier) @name) @item.enum
(record_declaration name: (identifier) @name) @item.class
(annotation_type_declaration name: (identifier) @name) @item.interface
(method_declaration name: (identifier) @name) @item.method
(constructor_declaration name: (identifier) @name) @item.constructor
"#;
const MARKDOWN: &str = r#"
(section (atx_heading (inline) @name)) @item.heading
(setext_heading (paragraph (inline) @name)) @item.heading
"#;
const RUBY: &str = r#"
(class name: (_) @name) @item.class
(module name: (_) @name) @item.module
(method name: (_) @name) @item.method
(singleton_method name: (_) @name) @item.method
"#;
const BASH: &str = r#"
(function_definition name: (word) @name) @item.function
"#;
/// The query for a language the editor highlights, or `None` for one it has
/// no outline for.
fn query_source(language: &str) -> Option<String> {
Some(match language {
"rust" => RUST.into(),
"go" => GO.into(),
"python" => PYTHON.into(),
"javascript" => JAVASCRIPT.into(),
"typescript" | "tsx" => format!("{JAVASCRIPT}{TYPESCRIPT_EXTRA}"),
"c" => C.into(),
"cpp" => format!("{C}{CPP_EXTRA}"),
"java" => JAVA.into(),
"markdown" => MARKDOWN.into(),
"ruby" => RUBY.into(),
"bash" => BASH.into(),
_ => return None,
})
}
/// Compiled once per language per process. A query that does not compile
/// against its grammar is a bug here, not in the file: it is logged once and
/// the language goes without an outline.
fn query_for(language: &str) -> Option<Arc<Query>> {
static QUERIES: LazyLock<Mutex<HashMap<String, Option<Arc<Query>>>>> =
LazyLock::new(Default::default);
let mut queries = QUERIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(q) = queries.get(language) {
return q.clone();
}
let compiled = query_source(language).and_then(|source| {
let config = LanguageRegistry::singleton().language(language)?;
Query::new(&config.language, &source)
.map_err(|e| log::warn!("outline: the {language} query does not compile: {e}"))
.ok()
.map(Arc::new)
});
queries.insert(language.to_string(), compiled.clone());
compiled
}
/// Whether `language` has an outline at all.
pub(crate) fn supports(language: &str) -> bool {
query_source(language).is_some()
}
/// The longest name a symbol shows; an impl header with a where clause can
/// otherwise run the width of the screen.
const NAME_MAX_CHARS: usize = 80;
fn collapse(text: &str) -> String {
let mut out = text.split_whitespace().collect::<Vec<_>>().join(" ");
if out.chars().count() > NAME_MAX_CHARS {
out = out.chars().take(NAME_MAX_CHARS - 1).collect::<String>() + "…";
}
out
}
fn text_of(text: &Rope, range: Range<usize>) -> String {
let end = range.end.min(text.len());
let start = range.start.min(end);
text.slice(start..end).to_string()
}
/// The outline of `text`, as parsed into `tree`.
pub(crate) fn outline_from_tree(language: &str, tree: &tree_sitter::Tree, text: &Rope) -> Outline {
let Some(query) = query_for(language) else {
return Outline::default();
};
let names = query.capture_names();
struct Found {
pattern: usize,
kind: SymbolKind,
range: Range<usize>,
name: String,
selection: usize,
}
let mut found: HashMap<usize, Found> = HashMap::new();
let mut cursor = QueryCursor::new();
// No pattern has a predicate, so the text is never consulted.
let mut matches = cursor.matches(&query, tree.root_node(), &[] as &[u8]);
while let Some(m) = matches.next() {
let mut item = None;
let mut name_parts: Vec<(usize, String)> = Vec::new();
let mut body = None;
let mut receiver = None;
for cap in m.captures {
let capture = names[cap.index as usize];
let node = cap.node;
if let Some(kind) = capture.strip_prefix("item.") {
item = SymbolKind::from_capture(kind).map(|k| (k, node));
} else if capture == "name" {
name_parts.push((node.start_byte(), text_of(text, node.byte_range())));
} else if capture == "body" {
body = Some(node.start_byte());
} else if capture == "receiver" {
receiver = Some(text_of(text, node.byte_range()));
}
}
let Some((kind, node)) = item else { continue };
if found
.get(&node.id())
.is_some_and(|f| f.pattern <= m.pattern_index)
{
continue;
}
name_parts.sort_by_key(|(at, _)| *at);
let (name, selection) = match (name_parts.first(), body) {
(Some((at, _)), _) => (
name_parts
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" "),
*at,
),
(None, Some(body)) => (text_of(text, node.start_byte()..body), node.start_byte()),
(None, None) => continue,
};
let mut name = collapse(&name);
if let Some(receiver) = receiver {
let ty = receiver
.trim_matches(|c| c == '(' || c == ')')
.split_whitespace()
.last()
.unwrap_or_default()
.to_string();
if !ty.is_empty() {
name = format!("({ty}).{name}");
}
}
if name.is_empty() {
continue;
}
found.insert(
node.id(),
Found {
pattern: m.pattern_index,
kind,
range: node.byte_range(),
name,
selection,
},
);
}
// Document order, a container before what it holds: by start, and the
// longer of two that start together first.
let mut items: Vec<Found> = found.into_values().collect();
items.sort_by(|a, b| {
a.range
.start
.cmp(&b.range.start)
.then(b.range.end.cmp(&a.range.end))
});
let mut symbols: Vec<Symbol> = Vec::with_capacity(items.len());
let mut open: Vec<(usize, usize)> = Vec::new(); // (index, end byte)
for f in items {
while open.last().is_some_and(|(_, end)| *end <= f.range.start) {
open.pop();
}
let parent = open.last().map(|(ix, _)| *ix);
let kind = match (f.kind, parent) {
(SymbolKind::Function, Some(p)) if symbols[p].kind.holds_methods() => {
SymbolKind::Method
}
(kind, _) => kind,
};
let ix = symbols.len();
symbols.push(Symbol {
name: f.name,
kind,
range: text.offset_to_position(f.range.start)..text.offset_to_position(f.range.end),
selection: text.offset_to_position(f.selection),
depth: open.len(),
parent,
});
open.push((ix, f.range.end));
}
Outline { symbols }
}
/// The outline of a text nobody has parsed yet — a buffer that has not been
/// drawn. Parses it once, here.
pub(crate) fn outline_of_text(language: &str, text: &Rope) -> Outline {
let Some(config) = LanguageRegistry::singleton().language(language) else {
return Outline::default();
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&config.language).is_err() {
return Outline::default();
}
let source = text.to_string();
match parser.parse(&source, None) {
Some(tree) => outline_from_tree(language, &tree, text),
None => Outline::default(),
}
}
#[cfg(test)]
pub(crate) fn outline_of(language: &str, source: &str) -> Outline {
outline_of_text(language, &Rope::from(source))
}
#[cfg(test)]
mod tests {
use super::*;
/// `name@depth:kind` for every symbol, in order.
fn shape(language: &str, source: &str) -> Vec<String> {
outline_of(language, source)
.symbols
.iter()
.map(|s| format!("{}@{}:{}", s.name, s.depth, s.kind.tag()))
.collect()
}
#[test]
fn every_outline_query_compiles_against_its_grammar() {
for language in [
"rust",
"go",
"python",
"javascript",
"typescript",
"tsx",
"c",
"cpp",
"java",
"markdown",
"ruby",
"bash",
] {
assert!(query_for(language).is_some(), "{language}");
}
assert!(query_for("json").is_none());
}
#[test]
fn rust_nests_methods_under_their_impl() {
let src = "mod net {\n pub struct Server { port: u16 }\n impl<T> Display for Wrapper<T> where T: Debug {\n fn fmt(&self) {}\n }\n impl Server {\n pub fn serve(&self) {}\n const MAX: usize = 3;\n }\n}\ntrait Run { fn run(&self); }\nenum Mode { A, B }\nfn main() {}\n";
assert_eq!(
shape("rust", src),
[
"net@0:mod",
"Server@1:struct",
"impl<T> Display for Wrapper<T> where T: Debug@1:impl",
"fmt@2:method",
"impl Server@1:impl",
"serve@2:method",
"MAX@2:const",
"Run@0:trait",
"run@1:method",
"Mode@0:enum",
"main@0:fn",
]
);
}
#[test]
fn go_methods_carry_their_receiver() {
let src = "package main\n\ntype Server struct{}\ntype Handler interface{ Serve() }\ntype ID int\nconst Max = 3\nfunc (s *Server) Serve() {}\nfunc main() {}\n";
assert_eq!(
shape("go", src),
[
"Server@0:struct",
"Handler@0:interface",
"ID@0:type",
"Max@0:const",
"(*Server).Serve@0:method",
"main@0:fn",
]
);
}
#[test]
fn python_classes_hold_their_methods() {
let src = "class A:\n def f(self):\n def inner():\n pass\n\n@decorator\ndef g():\n pass\n";
assert_eq!(
shape("python", src),
["A@0:class", "f@1:method", "inner@2:fn", "g@0:fn"]
);
}
#[test]
fn javascript_finds_classes_methods_and_arrow_functions() {
let src = "class Widget {\n render() {}\n}\nfunction boot() {}\nconst handler = () => {};\nconst n = 3;\n";
assert_eq!(
shape("javascript", src),
[
"Widget@0:class",
"render@1:method",
"boot@0:fn",
"handler@0:fn"
]
);
}
#[test]
fn typescript_adds_its_type_declarations() {
let src = "interface Props { name: string }\ntype Id = number;\nenum Color { Red }\nexport class View {\n show(): void {}\n}\nnamespace Util { export function f() {} }\n";
let got = shape("typescript", src);
assert_eq!(
got,
[
"Props@0:interface",
"Id@0:type",
"Color@0:enum",
"View@0:class",
"show@1:method",
"Util@0:mod",
"f@1:fn",
]
);
let tsx = shape("tsx", "function App() { return <div/>; }\n");
assert_eq!(tsx, ["App@0:fn"]);
}
#[test]
fn c_and_cpp_find_functions_through_their_declarators() {
let c = "struct point { int x; };\ntypedef int id_t;\nstatic char *name(void) { return 0; }\nint main(void) { return 0; }\n";
assert_eq!(
shape("c", c),
["point@0:struct", "id_t@0:type", "name@0:fn", "main@0:fn"]
);
let cpp = "namespace app {\nclass Server {\n public:\n void serve();\n};\nvoid Server::serve() {}\n}\n";
assert_eq!(
shape("cpp", cpp),
[
"app@0:namespace",
"Server@1:class",
"serve@2:method",
"Server::serve@1:fn",
]
);
}
#[test]
fn java_nests_members_in_their_class() {
let src = "class App {\n App() {}\n void run() {}\n interface Listener { void on(); }\n}\nenum Kind { A }\n";
assert_eq!(
shape("java", src),
[
"App@0:class",
"App@1:ctor",
"run@1:method",
"Listener@1:interface",
"on@2:method",
"Kind@0:enum",
]
);
}
#[test]
fn markdown_headings_nest_by_level() {
let src = "# Title\n\nintro\n\n## Install\n\ntext\n\n### From source\n\n## Usage\n";
assert_eq!(
shape("markdown", src),
["Title@0:#", "Install@1:#", "From source@2:#", "Usage@1:#",]
);
// tree-sitter-md opens no section for an underlined heading, so it
// names only its own line.
assert_eq!(
shape("markdown", "Setext\n======\n\ntext\n"),
["Setext@0:#"]
);
}
#[test]
fn ruby_and_bash_have_outlines_too() {
assert_eq!(
shape("ruby", "module M\n class C\n def go; end\n end\nend\n"),
["M@0:mod", "C@1:class", "go@2:method"]
);
assert_eq!(
shape("bash", "setup() {\n :\n}\nfunction run { :; }\n"),
["setup@0:fn", "run@0:fn"]
);
}
#[test]
fn a_language_without_a_query_has_an_empty_outline() {
assert!(outline_of("json", "{\"a\": 1}").is_empty());
}
#[test]
fn the_chain_at_a_position_runs_outermost_to_innermost() {
let src = "mod net {\n impl Server {\n fn serve(&self) {\n let x = 1;\n }\n }\n}\nfn main() {}\n";
let outline = outline_of("rust", src);
let names = |pos: Position| -> Vec<String> {
outline
.chain_at(pos)
.into_iter()
.map(|i| outline.symbols[i].name.clone())
.collect()
};
assert_eq!(names(Position::new(3, 12)), ["net", "impl Server", "serve"]);
assert_eq!(names(Position::new(1, 0)), ["net"]);
assert_eq!(names(Position::new(7, 3)), ["main"]);
assert_eq!(names(Position::new(6, 0)), ["net"]);
assert!(names(Position::new(6, 2)).is_empty());
let serve = outline
.symbols
.iter()
.position(|s| s.name == "serve")
.unwrap();
assert_eq!(outline.container_path(serve), ["net", "impl Server"]);
// Lands on the name, not on the start of the item.
assert_eq!(outline.symbols[serve].selection, Position::new(2, 11));
}
#[test]
fn a_language_servers_nested_answer_flattens_the_same_way() {
let p = |l, c| Position::new(l, c);
let outline = Outline::from_nodes(vec![
SymbolNode {
name: "later".into(),
kind: SymbolKind::from_lsp(12),
range: p(10, 0)..p(12, 1),
selection: p(10, 3),
children: vec![],
},
SymbolNode {
name: "Server".into(),
kind: SymbolKind::from_lsp(5),
range: p(0, 0)..p(8, 1),
selection: p(0, 6),
children: vec![SymbolNode {
name: "serve".into(),
kind: SymbolKind::from_lsp(6),
range: p(2, 2)..p(4, 3),
selection: p(2, 6),
children: vec![],
}],
},
]);
let got: Vec<_> = outline
.symbols
.iter()
.map(|s| (s.name.as_str(), s.depth, s.parent))
.collect();
assert_eq!(
got,
[
("Server", 0, None),
("serve", 1, Some(0)),
("later", 0, None)
]
);
assert_eq!(outline.chain_at(p(3, 0)), [0, 1]);
}
}
+415
View File
@@ -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<String>,
}
/// 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<Problem>,
}
/// 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<lsp_types::Diagnostic>)>) -> Vec<FileProblems> {
let mut files: Vec<FileProblems> = snapshot
.into_iter()
.map(|(path, diagnostics)| {
let mut problems: Vec<Problem> = 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>) {
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<Self>,
) -> Option<AnyElement> {
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<AnyElement> = 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<DiagnosticSeverity>, 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());
});
}
}
+898
View File
@@ -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<BufferId>,
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<BufferId> {
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<BufferId>, 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<BufferId>,
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<BufferId> {
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<BufferId> {
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<BufferId>, usize),
right: Option<(Vec<BufferId>, usize, bool)>,
) -> bool {
if self.is_split() {
return false;
}
let extras: Vec<BufferId> = 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<Side> {
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<Self>) -> 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<Self>,
) -> 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<Self>) {
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<E: InteractiveElement>(
&self,
element: E,
cx: &mut Context<Self>,
) -> 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<AnyElement>,
focused_body: AnyElement,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let pane = |crumbs: Option<AnyElement>, 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<Self>,
) -> 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<BufferId> {
(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<Tty7App>, vcx: &mut VisualTestContext) -> PathBuf {
app.read_with(vcx, |app, _| app.active_buffer().unwrap().path.clone())
}
fn split(app: &Entity<Tty7App>, 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<Tty7App>, 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<PathBuf> = 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);
});
}
}
+31 -1
View File
@@ -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<PathBuf>,
@@ -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<SplitEditor>,
}
/// The right-hand group of a split editor.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SplitEditor {
pub(crate) files: Vec<PathBuf>,
#[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::<Doc>(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();
+91
View File
@@ -125,10 +125,16 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SearchTabActions => "Commands",
L10nKey::SearchTabTerminals => "Terminals",
L10nKey::SearchTabHosts => "Hosts",
L10nKey::SearchTabSymbols => "Symbols",
L10nKey::SearchPlaceholderAll => "Search files, actions, terminals and hosts…",
L10nKey::SearchPlaceholderActions => "Search commands…",
L10nKey::SearchPlaceholderTerminals => "Search open tabs, shells and agents…",
L10nKey::SearchPlaceholderHosts => "Search hosts, or type user@host to connect…",
L10nKey::SearchPlaceholderSymbols => "Go to a symbol in this file…",
L10nKey::SearchSymbolsNone => "No symbols in this file",
L10nKey::SearchSymbolsNoneHint => {
"Symbols are listed for Rust, Go, Python, JavaScript, TypeScript, C, C++, Java, Ruby, shell and Markdown."
}
L10nKey::SearchTabFiles => "Files",
L10nKey::SearchPlaceholderFiles => "Go to a file by name — add :line to jump to it…",
L10nKey::SearchFilesNoRoots => "No project to search",
@@ -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",
+89
View File
@@ -126,10 +126,16 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "コマンド",
L10nKey::SearchTabTerminals => "ターミナル",
L10nKey::SearchTabHosts => "ホスト",
L10nKey::SearchTabSymbols => "シンボル",
L10nKey::SearchPlaceholderAll => "ファイル、アクション、ターミナル、ホストを検索…",
L10nKey::SearchPlaceholderActions => "コマンドを検索…",
L10nKey::SearchPlaceholderTerminals => "開いているタブ、シェル、エージェントを検索…",
L10nKey::SearchPlaceholderHosts => "ホストを検索、または user@host を入力して接続…",
L10nKey::SearchPlaceholderSymbols => "このファイル内のシンボルへ移動…",
L10nKey::SearchSymbolsNone => "このファイルにシンボルはありません",
L10nKey::SearchSymbolsNoneHint => {
"Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、シェル、Markdown のシンボルを一覧できます。"
}
L10nKey::SearchTabFiles => "ファイル",
L10nKey::SearchPlaceholderFiles => "名前でファイルに移動(:行番号 でその行へ)…",
L10nKey::SearchFilesNoRoots => "検索するプロジェクトがありません",
@@ -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: すべてのステージを取り消す",
+73
View File
@@ -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,
+83
View File
@@ -110,10 +110,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SearchTabActions => "命令",
L10nKey::SearchTabTerminals => "终端",
L10nKey::SearchTabHosts => "主机",
L10nKey::SearchTabSymbols => "符号",
L10nKey::SearchPlaceholderAll => "搜索文件、操作、终端和主机…",
L10nKey::SearchPlaceholderActions => "搜索命令…",
L10nKey::SearchPlaceholderTerminals => "搜索已打开的标签页、Shell 和 Agent…",
L10nKey::SearchPlaceholderHosts => "搜索主机,或输入 user@host 连接…",
L10nKey::SearchPlaceholderSymbols => "跳转到本文件中的符号…",
L10nKey::SearchSymbolsNone => "此文件中没有符号",
L10nKey::SearchSymbolsNoneHint => {
"支持 Rust、Go、Python、JavaScript、TypeScript、C、C++、Java、Ruby、Shell 和 Markdown 的符号。"
}
L10nKey::SearchTabFiles => "文件",
L10nKey::SearchPlaceholderFiles => "按名称转到文件,加上 :行号 可直接跳转…",
L10nKey::SearchFilesNoRoots => "没有可搜索的项目",
@@ -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:取消暂存全部更改",
+510
View File
@@ -70,6 +70,89 @@ fn fixed_bindings() -> Vec<KeyBinding> {
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<KeyBinding> {
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
"EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None),
"EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None),
"EditorGoToSymbol" => KeyBinding::new(
keystroke,
EditorGoToSymbol,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorNavigateBack" => KeyBinding::new(
keystroke,
EditorNavigateBack,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"EditorNavigateForward" => KeyBinding::new(
keystroke,
EditorNavigateForward,
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
),
"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<Keystroke> = keys
.split(' ')
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
.collect();
let stack: Vec<gpui::KeyContext> = 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"
);
});
}
}
+576
View File
@@ -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<Result<Value, ResponseError>>;
/// 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<Mutex<Option<HashMap<RequestId, Reply>>>>;
/// 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<Vec<u8>>,
/// 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<Option<Vec<Vec<u8>>>>,
child: Mutex<Option<Child>>,
}
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<Self>, smol::channel::Receiver<ServerEvent>)> {
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<Self>, smol::channel::Receiver<ServerEvent>)> {
let (out, out_rx) = std::sync::mpsc::channel::<Vec<u8>>();
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<u8>, 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<Self>,
method: &str,
params: Value,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + 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<Self>,
method: &str,
params: Value,
timeout: Duration,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + 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<Self>,
params: Value,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + use<> {
self.request_inner("initialize", params, false, INITIALIZE_TIMEOUT)
}
fn request_inner(
self: &Arc<Self>,
method: &str,
params: Value,
gated: bool,
timeout: Duration,
) -> impl std::future::Future<Output = anyhow::Result<Value>> + 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<R: lsp_types::request::Request>(
self: &Arc<Self>,
params: R::Params,
) -> impl std::future::Future<Output = anyhow::Result<R::Result>> + use<R> {
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<N: lsp_types::notification::Notification>(&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<Value, ResponseError>) {
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<LspClient>,
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<LspClient>,
smol::channel::Receiver<ServerEvent>,
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"]);
}
}
+445
View File
@@ -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::<usize>() 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<lsp_types::Diagnostic> {
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<TextEdit> {
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<TextEdit> {
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<usize> {
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<usize>,
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<std::str::Chars<'_>>, 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");
}
}
+447
View File
@@ -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<InputState>,
pub(crate) path: PathBuf,
pub(crate) language: &'static str,
pub(crate) app: WeakEntity<Tty7App>,
}
impl Tty7App {
fn lsp_enabled(cx: &gpui::App) -> bool {
cx.global::<crate::core::config::Config>().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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) -> 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<Self>,
) {
// 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<Tty7App>| {
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<Self>) {
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<Self>) {
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::request::Formatting>(
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<Self>) {
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<Self>,
) {
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::request::Rename>(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<Self>) {
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<Self>) -> Option<AnyElement> {
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)
}
}
+508
View File
@@ -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<PathBuf, Rope>,
enc: Encoding,
) -> Vec<Found> {
let mut found: Vec<Found> = 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<PathBuf, Rope>,
enc: Encoding,
) -> Vec<Option<Found>> {
let mut read: HashMap<PathBuf, Option<Vec<String>>> = 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<PathBuf, Rope> {
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<Self>,
) {
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<Self>) {
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::request::References>(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<Self>,
) -> Option<crate::ui::search::LiveQuery> {
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<Self>,
) {
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::request::WorkspaceSymbolRequest>(
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::<Vec<_>>(),
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::<Vec<_>>()
})
.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<Self>,
) {
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<String>,
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<WorkspaceHit> {
use lsp_types::{OneOf, WorkspaceSymbolResponse};
let hits: Vec<WorkspaceHit> = 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"));
}
}
+1479
View File
File diff suppressed because it is too large Load Diff
+648
View File
@@ -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<Option<std::collections::HashMap<PathBuf, Rope>>>,
path: PathBuf,
/// The buffer this provider serves — always the document's owner.
input: gpui::EntityId,
app: WeakEntity<Tty7App>,
}
pub(super) fn install(
input: &Entity<InputState>,
path: &Path,
app: WeakEntity<Tty7App>,
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<InputState>, 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<DocContext> {
LspStore::context(
&self.path,
self.input,
sync,
Some(window.window_handle()),
cx,
)
}
}
fn not_available<T: Default + Send + 'static>() -> Task<Result<T>> {
Task::ready(Ok(T::default()))
}
impl CompletionProvider for LspProvider {
fn completions(
&self,
text: &Rope,
offset: usize,
_trigger: CompletionContext,
window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<CompletionResponse>> {
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::<lsp_types::request::Completion>(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<Result<lsp_types::CompletionItem>> {
// 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::<lsp_types::request::ResolveCompletionItem>(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<InputState>,
) -> 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<lsp_types::TextEdit>,
text: &Rope,
encoding: Encoding,
) -> Vec<lsp_types::TextEdit> {
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<Result<Option<lsp_types::Hover>>> {
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::<lsp_types::request::HoverRequest>(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<Result<Vec<LocationLink>>> {
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<Output = Result<Vec<LocationLink>>> + 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::<lsp_types::request::GotoDefinition>(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<InputState>,
range: Range<usize>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<CodeAction>>> {
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::<lsp_types::request::CodeActionRequest>(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<InputState>,
action: CodeAction,
_push_to_history: bool,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
// 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::<lsp_types::request::CodeActionResolveRequest>(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::request::ExecuteCommand>(
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![])));
}
}
+294
View File
@@ -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<u8> {
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, ResponseError>) -> 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<Value, ResponseError>,
},
/// 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<Incoming> {
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<u8>,
}
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<Vec<u8>> {
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::<usize>().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<usize> {
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::<Value>(&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::<Value>(&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);
}
}
+397
View File
@@ -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<PathBuf> {
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());
}
}
+241
View File
@@ -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<String> {
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 &param.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<Self>) {
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::request::SignatureHelpRequest>(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<ParameterLabel>, active: Option<u32>) -> 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()
);
}
}
+184
View File
@@ -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<SymbolInformation>, text: &Rope, enc: Encoding) -> Vec<SymbolNode> {
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<SymbolNode>) {
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);
}
}
+544
View File
@@ -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<OneOf<TextEdit, AnnotatedTextEdit>>| 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<Box<dyn std::future::Future<Output = Option<Value>>>>| {
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::notification::DidOpenTextDocument>(
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(&params.diagnostics);
assert!(errors >= 1, "{:?}", params.diagnostics);
assert!(params.diagnostics.iter().any(|d| d.range.start.line == 1));
let hover = client.request::<lsp_types::request::HoverRequest>(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::request::DocumentSymbolRequest>(
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::request::References>(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<lsp_types::Location> = 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<InputState>,
pub(super) owner: Entity<InputState>,
pub(super) other: Entity<InputState>,
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::<LspStore>();
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::<crate::core::config::Config, _>(|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::<LspStore>().windows.len()), 1);
}
+1
View File
@@ -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;
+49
View File
@@ -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<u32>,
column: Option<u32>,
},
/// 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(_)
+37 -3
View File
@@ -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()));
}
}
+190
View File
@@ -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<Item>,
/// 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<Item>,
/// Asked with every query the Symbols tab is given; its answer comes
/// back through `SearchView::set_project_symbols`.
pub live_query: Option<LiveQuery>,
}
/// Something that answers a query later — a language server.
pub(crate) type LiveQuery = std::rc::Rc<dyn Fn(&str, &mut App)>;
impl Catalog {
pub(crate) fn new(mut actions: Vec<Item>, terminals: Vec<Item>, hosts: Vec<Item>) -> 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<Section> {
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<Section> {
let rows = |hits: Vec<(i32, Item)>| -> Vec<Row> {
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<Section> {
if self.0.is_empty() {
return Vec::new();
}
vec![Section {
title: None,
rows: self
.0
.iter()
.map(|(depth, item)| {
let mut item = item.clone();
item.title = format!("{}{}", SYMBOL_INDENT.repeat(*depth), item.title);
item.subtitle = None;
Row::Item(item)
})
.collect(),
}]
}
fn highlights(&self, _cx: &App) -> Vec<Item> {
Vec::new()
}
fn on_the_empty_all_tab(&self) -> bool {
false
}
fn search(&self, query: &str, _cx: &App) -> Vec<(i32, Item)> {
let items: Vec<Item> = self.0.iter().map(|(_, item)| item.clone()).collect();
rank(&items, query, |_| 0)
}
}
/// 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<Section> {
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<Item> {
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<String> = s
.rows
.iter()
.filter_map(|r| r.item().map(|i| i.title.clone()))
.collect();
(s.title.map(|t| t.to_string()), rows)
})
.collect::<Vec<_>>()
})
};
// 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);
+278 -27
View File
@@ -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<IndexPath> {
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<ListState<Self>>,
) -> 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<CommandKind>,
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<SearchTab>,
_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<CommandKind>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.symbol_moved = false;
self.symbol_initial = selected.clone();
self.update_catalog(|catalog| catalog.symbols = symbols, window, cx);
if let Some(kind) = selected {
self.list.update(cx, |state, cx| {
if let Some(ix) = state.delegate().position_of(&kind) {
state.set_selected_index(Some(ix), window, cx);
state.scroll_to_selected_item(window, cx);
}
});
}
}
/// 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<Item>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<SearchTab>, cx: &mut Context<Self>) {
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<Vec<SearchTab>> {
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<Self>) {
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<Item>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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>,
) {
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>) {
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<Self>) {
@@ -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]
+35
View File
@@ -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
}
+81
View File
@@ -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<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
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>) {
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>) {
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>) {
self.update_config(cx, |cfg| cfg.editor_markdown_preview = on);
}
}
+1
View File
@@ -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,
+4
View File
@@ -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),