From 467da40a95b60ae71f587f9282a7279b67dacd9d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:54:04 +0800 Subject: [PATCH] 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. --- src/core/actions.rs | 3 + src/ui/app.rs | 11 +- src/ui/code_editor.rs | 318 +++++++++++-------- src/ui/code_editor/gutter.rs | 15 +- src/ui/code_editor/nav.rs | 139 +++++++-- src/ui/code_editor/split.rs | 570 +++++++++++++++++++++++++++++++++++ src/ui/editor_session.rs | 32 +- src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 8 + src/ui/i18n/zh.rs | 8 + src/ui/keymap.rs | 52 +++- src/ui/search/command.rs | 13 + src/ui/search/view.rs | 11 + src/ui/theme.rs | 4 + 15 files changed, 1046 insertions(+), 154 deletions(-) create mode 100644 src/ui/code_editor/split.rs diff --git a/src/core/actions.rs b/src/core/actions.rs index 94a2582a..fe1658cc 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -130,6 +130,9 @@ actions!( EditorGoToSymbol, EditorNavigateBack, EditorNavigateForward, + EditorSplitRight, + EditorFocusLeftGroup, + EditorFocusRightGroup, EditorNewFile, EditorGoToDefinition, EditorQuickFix, diff --git a/src/ui/app.rs b/src/ui/app.rs index f0017ebd..ff72296f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4591,7 +4591,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 +5888,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 @@ -6313,6 +6316,12 @@ impl Tty7App { } ToggleDocumentPreview => self.toggle_document_preview(cx), ToggleDocumentWrap => self.toggle_document_wrap(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()) { diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index adc6b518..4808ceeb 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -25,6 +25,7 @@ use crate::ui::i18n::{L10nKey, t, t_fmt}; mod gutter; mod nav; pub(crate) mod outline; +mod split; pub(crate) use nav::KEY_CONTEXT as NAV_KEY_CONTEXT; @@ -185,6 +186,10 @@ pub(crate) struct TabCode { pub(crate) roots: Vec, pub(crate) expanded: std::collections::HashSet, pub(crate) selected: Option, + /// The second editor group, when the editor is split — the one without + /// the focus. `files` and `active` above are always the focused group's + /// (see `split`). + pub(crate) split: Option, } impl TabCode { @@ -196,6 +201,7 @@ impl TabCode { roots: Vec::new(), expanded: std::collections::HashSet::new(), selected: None, + split: None, } } @@ -834,12 +840,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() } @@ -857,10 +873,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() } @@ -2366,7 +2381,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); @@ -2380,7 +2395,7 @@ 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); @@ -2452,10 +2467,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; @@ -2749,7 +2769,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 @@ -2807,7 +2827,14 @@ impl Tty7App { // What was recorded is what is being put back; recording it again // before the files have loaded would write down an empty tab. self.editor.recorded.insert(tab_id, state.clone()); - let files = state.files.clone(); + // Both groups' files in one trip, the left group's first. + let files: Vec = state + .files + .iter() + .chain(state.split.iter().flat_map(|s| s.files.iter())) + .cloned() + .collect(); + let requested = files.clone(); HostOps::run_in( host.clone(), window, @@ -2819,29 +2846,64 @@ impl Tty7App { .collect::>() }, move |app, loaded: Vec>, window, cx| { - let front = loaded - .get(state.active) - .and_then(Option::as_ref) - .map(|l| l.path.clone()); - let mut ids = Vec::new(); - for l in loaded.into_iter().flatten() { - ids.push(app.editor_install(host.clone(), l, tab_id, false, window, cx)); + let mut opened: Vec<(PathBuf, BufferId)> = Vec::new(); + for (path, l) in requested.into_iter().zip(loaded) { + if let Some(l) = l { + let id = app.editor_install(host.clone(), l, tab_id, false, window, cx); + opened.push((path, id)); + } } + let group = |paths: &[PathBuf], active: usize| -> (Vec, usize) { + let front = paths.get(active); + let mut ids: Vec = Vec::new(); + let mut at = 0; + for path in paths { + let Some(&(_, id)) = opened.iter().find(|(p, _)| p == path) else { + continue; + }; + if ids.contains(&id) { + continue; + } + if Some(path) == front { + at = ids.len(); + } + ids.push(id); + } + (ids, at) + }; + let (left, left_active) = group(&state.files, state.active); + let right = state + .split + .as_ref() + .map(|s| (group(&s.files, s.active), s.focused)); let Some(tab_ix) = app.tab_index_of(tab_id) else { return; }; let Some(code) = app.tabs[tab_ix].code.as_deref_mut() else { return; }; - if let Some(front) = front - && let Some(pos) = code.files.iter().position(|id| { - app.editor - .buffers - .iter() - .any(|b| b.id() == *id && b.path == front) - }) - { - code.active = pos; + match (left.is_empty(), right) { + (false, Some(((right, right_active), focused))) if !right.is_empty() => { + code.files = left; + code.active = left_active; + code.split = Some(split::OtherGroup { + files: right, + active: right_active, + focus_left: true, + }); + if focused { + code.swap_focus(); + } + } + (false, _) => { + code.files = left; + code.active = left_active; + } + (true, Some(((right, right_active), _))) if !right.is_empty() => { + code.files = right; + code.active = right_active; + } + _ => {} } code.visible = state.visible && !code.files.is_empty(); if tab_ix == app.active && code.visible { @@ -2865,24 +2927,38 @@ impl Tty7App { if !self.editor.restored.contains(&tab_id) { continue; } - let mut files = Vec::new(); - let mut active = 0; - for (pos, id) in code.files.iter().enumerate() { - // Only files on the window's own machine: an SFTP buffer's - // host is a connection that will not exist next launch. - let Some(f) = self.buffer(*id) else { continue }; - if f.untitled.is_some() || f.host.id() != spawn_host { - continue; + // Only files on the window's own machine: an SFTP buffer's host + // is a connection that will not exist next launch. + let group = |ids: &[BufferId], front: usize| -> (Vec, usize) { + let mut files = Vec::new(); + let mut active = 0; + for (pos, id) in ids.iter().enumerate() { + let Some(f) = self.buffer(*id) else { continue }; + if f.untitled.is_some() || f.host.id() != spawn_host { + continue; + } + if pos == front { + active = files.len(); + } + files.push(f.path.clone()); } - if pos == code.active { - active = files.len(); - } - files.push(f.path.clone()); - } + (files, active) + }; + let sides = code.sides(); + let (files, active) = group(&sides[0].files, sides[0].active); + let split = sides.get(1).and_then(|right| { + let (files, active) = group(&right.files, right.active); + (!files.is_empty()).then_some(editor_session::SplitEditor { + files, + active, + focused: right.focused, + }) + }); let state = TabEditor { files, active, visible: code.visible, + split, }; if self.editor.recorded.get(&tab_id) != Some(&state) { changed.push((tab_id, state)); @@ -2903,12 +2979,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); } } @@ -2939,62 +3015,15 @@ impl Tty7App { if !self.code_panel_visible() { return None; } + 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 = 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(), - ) - .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() - } - }; + 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))) @@ -3009,10 +3038,9 @@ impl Tty7App { .min_w_0() .h_full() .children(header) - .children(breadcrumbs) .when_some(conflict_banner, |this, b| this.child(b)) .children(bar) - .child(div().flex_1().min_h_0().child(body)); + .child(self.render_editor_groups(breadcrumbs, body, 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 @@ -3062,6 +3090,69 @@ impl Tty7App { ) } + /// One group's text: the file `id`, rendered or as source, or the empty + /// panel. `slot` tells the two groups' elements apart. + pub(crate) fn render_editor_body( + &self, + id: Option, + slot: usize, + cx: &mut Context, + ) -> AnyElement { + match id.and_then(|id| self.buffer(id)) { + None => self.render_editor_empty(cx).into_any_element(), + Some(f) if f.preview => { + let markdown = f.input.read(cx).text().to_string(); + let scroll = f.preview_scroll.clone(); + // The bar's wrapper takes its height from `flex_1`, so it needs + // a column with a definite height to grow inside — hand it one + // rather than dropping it straight into the overlay, or the + // pane sizes to its content and there is nothing left to + // scroll. + v_flex() + .size_full() + .child(crate::ui::scrollbar::with_vertical_scrollbar( + ("editor-md-preview-scrollbar", slot), + div() + .id(("editor-md-preview", slot)) + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .px_4() + .py_3() + .child( + gpui_component::text::TextView::markdown( + ("editor-md-preview-body", slot), + markdown, + ) + .style(crate::ui::theme::markdown_style(cx)), + ), + &scroll, + )) + .into_any_element() + } + Some(f) => { + let input = f.input.clone(); + let id = f.id(); + let app = cx.entity().downgrade(); + div() + .id(("editor-body", slot)) + .size_full() + .child( + Input::new(&input) + .appearance(false) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .size_full(), + ) + .children(self.render_editor_gutter_peek(id, cx)) + .context_menu(move |menu, _window, cx| { + Self::editor_body_menu(menu, &app, id, cx) + }) + .into_any_element() + } + } + } + /// The editor header alone, for the strip above a docked column. pub(crate) fn render_editor_header_only( &self, @@ -3335,35 +3426,9 @@ impl Tty7App { } fn render_code_status_bar(&self, window: &Window, cx: &mut Context) -> gpui::Div { - // The roots below belong to this window's own machine. A file read - // over SFTP is on another one, where they mean nothing, so it shows - // its own full path rather than borrowing the local repo's name. - let tree_host = self.spawn_host(cx); - let code = self.tab_code(); + // No path here: the breadcrumbs over the text show where the file is. let muted = cx.theme().muted_foreground; let active = self.active_buffer(); - let path_text: Option = code.map(|c| { - let repo = c - .roots - .first() - .and_then(|r| r.file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - match active { - Some(f) if f.untitled.is_some() => f.label(), - Some(f) if f.host.id() != tree_host => f.path.display().to_string().into(), - Some(f) => { - let rel = c - .roots - .iter() - .find_map(|r| f.path.strip_prefix(r).ok()) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| f.label().to_string()); - format!("{repo} › {rel}").into() - } - None => repo.into(), - } - }); let cursor: Option = active.map(|f| { let input = f.input.read(cx); let pos = input.cursor_position(); @@ -3415,9 +3480,6 @@ 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()) .children(self.render_lsp_status(cx)) .when(is_markdown, |this| { diff --git a/src/ui/code_editor/gutter.rs b/src/ui/code_editor/gutter.rs index b569e34c..1475d1eb 100644 --- a/src/ui/code_editor/gutter.rs +++ b/src/ui/code_editor/gutter.rs @@ -398,9 +398,18 @@ impl Tty7App { if !enabled(cx) { return; } - let Some(id) = self.tab_code().and_then(TabCode::active_id) else { - return; - }; + // Both groups' files when the editor is split: the one without the + // focus is on screen too. + let shown = self + .tab_code() + .map(|c| [c.active_id(), c.other_active_id()]) + .unwrap_or_default(); + for id in shown.into_iter().flatten() { + self.editor_gutter_sync_buffer(id, cx); + } + } + + fn editor_gutter_sync_buffer(&mut self, id: BufferId, cx: &mut Context) { let Some(f) = self.buffer(id) else { return; }; diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index c3fc93cb..35cb812c 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -25,7 +25,7 @@ use gpui_component::{ActiveTheme as _, h_flex}; use tty7_core::core::machine::TabId; use super::outline::{self, Outline, SymbolKind}; -use super::{BufferId, CURSOR_SCROLL_ATTEMPTS, place_cursor}; +use super::{BufferId, CURSOR_SCROLL_ATTEMPTS, TabCode, place_cursor}; use crate::ui::app::Tty7App; use crate::ui::host_ops::HostId; use crate::ui::i18n::{L10nKey, t}; @@ -630,14 +630,52 @@ impl Tty7App { // ---- Chrome ---- + /// The palette's editor rows, offered while a file is in front of it. + pub(crate) fn editor_palette_items(&self) -> Vec { + use crate::ui::search::CommandGroup; + if !self.code_panel_visible() || self.active_buffer().is_none() { + return Vec::new(); + } + [ + (L10nKey::CmdEditorGoToSymbol, CommandKind::EditorGoToSymbol), + (L10nKey::CmdEditorGoBack, CommandKind::EditorNavigateBack), + ( + L10nKey::CmdEditorGoForward, + CommandKind::EditorNavigateForward, + ), + (L10nKey::CmdEditorSplitRight, CommandKind::EditorSplitRight), + ] + .into_iter() + .map(|(key, kind)| Item::localized(key, kind).in_group(CommandGroup::View)) + .collect() + } + /// The editor's own chords, handled by the element that holds it. pub(crate) fn editor_nav_actions( &self, element: E, cx: &mut Context, ) -> E { - use crate::core::actions::{EditorGoToSymbol, EditorNavigateBack, EditorNavigateForward}; + use crate::core::actions::{ + EditorFocusLeftGroup, EditorFocusRightGroup, EditorGoToSymbol, EditorNavigateBack, + EditorNavigateForward, EditorSplitRight, + }; element + .on_action(cx.listener(|this, _: &EditorSplitRight, window, cx| { + if !this.editor_split(window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorFocusLeftGroup, window, cx| { + if !this.editor_focus_group(true, window, cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &EditorFocusRightGroup, window, cx| { + if !this.editor_focus_group(false, window, cx) { + cx.propagate(); + } + })) .on_action(cx.listener(|this, _: &EditorGoToSymbol, window, cx| { this.editor_go_to_symbol(window, cx) })) @@ -656,11 +694,22 @@ impl Tty7App { window: &Window, cx: &mut Context, ) -> Option { - let f = self.active_buffer()?; - if f.preview { - return None; - } - let id = f.id(); + let id = self.tab_code().and_then(TabCode::active_id)?; + let slot = self.tab_code().map_or(0, TabCode::focused_slot); + self.render_editor_breadcrumbs_for(id, slot, true, window, cx) + } + + /// The breadcrumbs of one group's file. `focused` is whether that group + /// has the focus; the other group's symbols first bring the focus over. + pub(crate) fn render_editor_breadcrumbs_for( + &mut self, + id: BufferId, + slot: usize, + focused: bool, + window: &Window, + cx: &mut Context, + ) -> Option { + let f = self.buffer(id)?; let caret = f.input.read(cx).cursor_position(); let segments: Vec = if f.untitled.is_some() { vec![f.label().to_string()] @@ -670,7 +719,8 @@ impl Tty7App { path_segments(&f.path, if local { roots } else { &[] }) }; let file = (f.untitled.is_none()).then(|| f.path.clone()); - let supported = f.untitled.is_none() && outline::supports(f.language()); + // A rendered Markdown file keeps the path; its symbols are the source's. + let supported = !f.preview && f.untitled.is_none() && outline::supports(f.language()); let chain: Vec<(SymbolKind, String)> = match supported { true => self .editor_outline(id, false, cx) @@ -706,7 +756,7 @@ impl Tty7App { ); } let path = div() - .id("editor-breadcrumb-path") + .id(("editor-breadcrumb-path", slot)) .flex_shrink(1.) .min_w_0() .overflow_hidden() @@ -737,7 +787,7 @@ impl Tty7App { ); } div() - .id("editor-breadcrumb-symbols") + .id(("editor-breadcrumb-symbols", slot)) .flex_shrink(1.) .min_w_0() .overflow_hidden() @@ -749,12 +799,18 @@ impl Tty7App { gpui_component::tooltip::Tooltip::new(t(L10nKey::EditorGoToSymbolAction)) .build(window, cx) }) - .on_click(cx.listener(|this, _, window, cx| this.editor_go_to_symbol(window, cx))) + .on_click(cx.listener(move |this, _, window, cx| { + if !focused { + let left = slot == 0; + this.editor_focus_group(left, window, cx); + } + this.editor_go_to_symbol(window, cx) + })) }); Some( h_flex() - .id("editor-breadcrumbs") + .id(("editor-breadcrumbs", slot)) .flex_none() .w_full() .h(px(24.)) @@ -1062,14 +1118,14 @@ mod tests { } #[cfg(test)] -mod gpui_tests { +pub(super) mod gpui_tests { use super::*; use crate::ui::app::test_window::harness_with_tabs; use crate::ui::editor_text::{EditorConfig, TextFormat}; use gpui::{Entity, TestAppContext, VisualTestContext}; /// `alpha` on line 0, `beta` on 30, `gamma` on 50. - fn source() -> String { + pub(crate) fn source() -> String { let mut lines = vec!["fn alpha() {".to_string()]; lines.extend((1..=20).map(|_| " let x = 1;".to_string())); lines.push("}".into()); @@ -1082,7 +1138,11 @@ mod gpui_tests { lines.join("\n") + "\n" } - fn open_rs(app: &Entity, vcx: &mut VisualTestContext, path: &str) -> BufferId { + pub(crate) fn open_rs( + app: &Entity, + vcx: &mut VisualTestContext, + path: &str, + ) -> BufferId { let path = PathBuf::from(path); app.update_in(vcx, |app, window, cx| { let host = app.active_host(cx).expect("a host"); @@ -1105,7 +1165,7 @@ mod gpui_tests { }) } - fn caret(app: &Entity, vcx: &mut VisualTestContext) -> Position { + pub(crate) fn caret(app: &Entity, vcx: &mut VisualTestContext) -> Position { app.read_with(vcx, |app, cx| { app.active_buffer() .unwrap() @@ -1116,11 +1176,11 @@ mod gpui_tests { } /// What drawing the editor does: sample the caret. - fn draw(app: &Entity, vcx: &mut VisualTestContext) { + pub(crate) fn draw(app: &Entity, vcx: &mut VisualTestContext) { app.update(vcx, |app, cx| app.editor_nav_tick(cx)); } - fn put_caret(app: &Entity, vcx: &mut VisualTestContext, line: u32) { + pub(crate) fn put_caret(app: &Entity, vcx: &mut VisualTestContext, line: u32) { app.update_in(vcx, |app, window, cx| { let input = app.active_buffer().unwrap().input.clone(); input.update(cx, |s, cx| { @@ -1260,4 +1320,47 @@ mod gpui_tests { "the place moved down with its code" ); } + + #[gpui::test] + fn the_symbol_chord_puts_go_to_symbol_away(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_go_to_symbol(window, cx) + }); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.search.is_some())); + vcx.simulate_keystrokes("secondary-shift-o"); + vcx.run_until_parked(); + assert!( + app.read_with(&vcx, |app, _| app.search.is_none()), + "the chord that opened it closes it" + ); + assert_eq!(caret(&app, &mut vcx), Position::new(5, 4)); + } + + #[gpui::test] + fn the_palette_offers_the_editor_commands_only_with_a_file_open(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + assert!(app.read_with(&vcx, |app, _| app.editor_palette_items().is_empty())); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + let kinds: Vec = app.read_with(&vcx, |app, _| { + app.editor_palette_items() + .into_iter() + .map(|i| i.kind) + .collect() + }); + assert_eq!( + kinds, + [ + CommandKind::EditorGoToSymbol, + CommandKind::EditorNavigateBack, + CommandKind::EditorNavigateForward, + CommandKind::EditorSplitRight, + ] + ); + } } diff --git a/src/ui/code_editor/split.rs b/src/ui/code_editor/split.rs new file mode 100644 index 00000000..296fca51 --- /dev/null +++ b/src/ui/code_editor/split.rs @@ -0,0 +1,570 @@ +//! The editor split in two: two groups of files side by side, each with a +//! file in front, the way VS Code's ⌘\ splits an editor. +//! +//! **One group has the focus, and it is `TabCode`'s own `files`/`active`.** +//! The other one waits in [`OtherGroup`]. Moving the focus swaps the two, so +//! everything that acts on "the file in front" — saving, go to line, the +//! language servers, the change markers, multiple cursors, back/forward, the +//! strip in the header — acts on the focused group without knowing a split +//! exists. The header's strip is the focused group's; the other group shows +//! its file in front and its breadcrumbs. +//! +//! **The same file in both groups.** A buffer is one `OpenFile` with one +//! `InputState`, which is what the language servers, the gutter markers, the +//! undo history and every hook in `code_editor.rs` key on. A second +//! `InputState` mirroring the first would need every one of those attached to +//! it twice, and edits copied back and forth without echoing — a lot of +//! surface for two views of one text. So the text is drawn once: in the group +//! with the focus. The other group shows a placeholder naming the file, and a +//! click on it moves the focus (and with it the live editor) over. The caret +//! and scroll of that file are therefore shared between the two groups; two +//! *different* files each keep their own, being different buffers. +//! +//! A group whose last file closes goes away, and the split with it. + +use gpui::Focusable as _; +use gpui::prelude::*; +use gpui::{AnyElement, Context, MouseButton, SharedString, Window, div, px}; +use gpui_component::{ActiveTheme as _, h_flex, v_flex}; + +use super::{BufferId, TabCode}; +use crate::ui::app::Tty7App; +use crate::ui::i18n::{L10nKey, t}; + +/// The group without the focus. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct OtherGroup { + pub(crate) files: Vec, + pub(crate) active: usize, + /// Whether the group *with* the focus is the left one. + pub(crate) focus_left: bool, +} + +impl OtherGroup { + fn active_id(&self) -> Option { + self.files.get(self.active).copied() + } +} + +/// Takes `id` out of one group's strip, bringing its neighbour forward the +/// way `TabCode::forget` does. +fn remove(files: &mut Vec, active: &mut usize, id: BufferId) -> bool { + let Some(pos) = files.iter().position(|f| *f == id) else { + return false; + }; + files.remove(pos); + if *active > pos || *active >= files.len() { + *active = active.saturating_sub(1); + } + true +} + +/// One side of the editor, as it is drawn. +pub(crate) struct Side { + pub(crate) files: Vec, + pub(crate) active: usize, + pub(crate) focused: bool, +} + +impl TabCode { + pub(crate) fn is_split(&self) -> bool { + self.split.is_some() + } + + /// Whether either group shows this buffer. + pub(crate) fn shows(&self, id: BufferId) -> bool { + self.files.contains(&id) || self.split.as_ref().is_some_and(|o| o.files.contains(&id)) + } + + /// How many groups show this buffer: 0, 1 or 2. + pub(crate) fn views_of(&self, id: BufferId) -> usize { + usize::from(self.files.contains(&id)) + + usize::from(self.split.as_ref().is_some_and(|o| o.files.contains(&id))) + } + + /// Every buffer either group shows, once each, the focused group's first. + pub(crate) fn all_files(&self) -> Vec { + let mut all = self.files.clone(); + if let Some(other) = &self.split { + for id in &other.files { + if !all.contains(id) { + all.push(*id); + } + } + } + all + } + + /// The file in front of the group without the focus. + pub(crate) fn other_active_id(&self) -> Option { + self.split.as_ref().and_then(OtherGroup::active_id) + } + + /// Gives the focus to the other group. + pub(crate) fn swap_focus(&mut self) { + let Some(other) = self.split.as_mut() else { + return; + }; + std::mem::swap(&mut self.files, &mut other.files); + std::mem::swap(&mut self.active, &mut other.active); + other.focus_left = !other.focus_left; + } + + /// Which side the focused group is drawn on: 0 left, 1 right. What tells + /// the two groups' elements apart. + pub(crate) fn focused_slot(&self) -> usize { + usize::from(!self.focus_is_left()) + } + + /// Whether the focused group is on the left. `true` when not split. + pub(crate) fn focus_is_left(&self) -> bool { + self.split.as_ref().is_none_or(|o| o.focus_left) + } + + /// Shows `id` in the other group and gives that group the focus — + /// splitting the editor first if it is not split yet, with the new group + /// on the right. + pub(crate) fn split_with(&mut self, id: BufferId) { + match self.split.as_mut() { + None => { + self.split = Some(OtherGroup { + files: std::mem::replace(&mut self.files, vec![id]), + active: std::mem::replace(&mut self.active, 0), + focus_left: false, + }); + } + Some(_) => { + self.swap_focus(); + self.show(id); + } + } + } + + /// Closes a file in the focused group only. The group goes if it was its + /// last file, and the focus passes to the one that is left. + pub(crate) fn close_in_focused(&mut self, id: BufferId) -> bool { + let closed = self.forget(id); + self.collapse(); + closed + } + + /// Takes a buffer out of both groups, for a buffer that is going away. + pub(crate) fn forget_everywhere(&mut self, id: BufferId) -> bool { + let mut gone = self.forget(id); + if let Some(other) = self.split.as_mut() { + gone |= remove(&mut other.files, &mut other.active, id); + } + self.collapse(); + gone + } + + /// A group with nothing left in it closes. + fn collapse(&mut self) { + let Some(other) = self.split.as_ref() else { + return; + }; + if other.files.is_empty() { + self.split = None; + } else if self.files.is_empty() { + let other = self.split.take().unwrap_or_default(); + self.files = other.files; + self.active = other.active; + } + } + + /// The two sides, left first. One when the editor is not split. + pub(crate) fn sides(&self) -> Vec { + let focused = Side { + files: self.files.clone(), + active: self.active, + focused: true, + }; + let Some(other) = &self.split else { + return vec![focused]; + }; + let other_side = Side { + files: other.files.clone(), + active: other.active, + focused: false, + }; + match other.focus_left { + true => vec![focused, other_side], + false => vec![other_side, focused], + } + } +} + +impl Tty7App { + /// Splits the editor: the file in front opens in a second group to the + /// right, which takes the focus. Already split, it opens in the other + /// group instead. `false` with no file to split. + pub(crate) fn editor_split(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(code) = self.tab_code_mut() else { + return false; + }; + let Some(id) = code.active_id() else { + return false; + }; + code.split_with(id); + self.editor.bar = None; + self.focus_editor(window, cx); + cx.notify(); + true + } + + /// Gives the focus to the group on the left (or right). `false` when there + /// is no such group to go to — not split, or already there — so the chord + /// can carry on to the panes. + pub(crate) fn editor_focus_group( + &mut self, + left: bool, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(code) = self.tab_code_mut() else { + return false; + }; + if !code.is_split() || code.focus_is_left() == left { + return false; + } + code.swap_focus(); + self.editor.bar = None; + self.focus_editor(window, cx); + cx.notify(); + true + } + + /// Keeps the focused group the one whose text has the keyboard: a click, + /// or anything else that focuses the other group's editor, moves the + /// group focus there too. + pub(crate) fn editor_split_follow_focus(&mut self, window: &Window, cx: &mut Context) { + let Some(other) = self.tab_code().and_then(TabCode::other_active_id) else { + return; + }; + if self.tab_code().and_then(TabCode::active_id) == Some(other) { + return; + } + let focused = self.buffer(other).is_some_and(|f| { + f.input + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + }); + if focused && let Some(code) = self.tab_code_mut() { + code.swap_focus(); + cx.notify(); + } + } + + /// The editor's text area: one group, or two side by side. + pub(crate) fn render_editor_groups( + &mut self, + focused_crumbs: Option, + focused_body: AnyElement, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let pane = |crumbs: Option, body: AnyElement| { + v_flex() + .flex_1() + .min_w_0() + .min_h_0() + .h_full() + .children(crumbs) + .child(div().flex_1().min_h_0().child(body)) + }; + let Some(code) = self.tab_code() else { + return pane(focused_crumbs, focused_body).into_any_element(); + }; + if !code.is_split() { + return pane(focused_crumbs, focused_body).into_any_element(); + } + let focus_left = code.focus_is_left(); + let other = code.other_active_id(); + let same = other.is_some() && other == code.active_id(); + let other_slot = usize::from(focus_left); + let (other_crumbs, other_body) = match other { + Some(id) if same => (None, self.render_editor_same_file(id, focus_left, cx)), + Some(id) => ( + self.render_editor_breadcrumbs_for(id, other_slot, false, window, cx), + self.render_editor_body(Some(id), other_slot, cx), + ), + None => (None, self.render_editor_empty(cx).into_any_element()), + }; + let accent = cx.theme().primary; + let focused = pane(focused_crumbs, focused_body) + .border_t_2() + .border_color(accent) + .into_any_element(); + let unfocused = pane(other_crumbs, other_body) + .border_t_2() + .border_color(gpui::transparent_black()) + .id(("editor-group", other_slot)) + // Before the text sees the click, so what the click does lands in + // the group it was made in. + .capture_any_mouse_down(cx.listener(move |this, _, window, cx| { + this.editor_focus_group(!focus_left, window, cx); + })) + .into_any_element(); + let divider = div() + .flex_none() + .w(px(1.)) + .h_full() + .bg(cx.theme().sidebar_border); + let (left, right) = match focus_left { + true => (focused, unfocused), + false => (unfocused, focused), + }; + h_flex() + .flex_1() + .min_h_0() + .size_full() + .child(left) + .child(divider) + .child(right) + .into_any_element() + } + + /// The other group's view of the file the focused group is showing: it is + /// drawn once, there, and a click brings it here. + fn render_editor_same_file( + &self, + id: BufferId, + focus_left: bool, + cx: &mut Context, + ) -> AnyElement { + let name: SharedString = self.buffer(id).map(|f| f.label()).unwrap_or_default(); + let theme = cx.theme(); + v_flex() + .id("editor-group-same-file") + .size_full() + .items_center() + .justify_center() + .gap_1() + .cursor_pointer() + .text_size(gpui::rems(crate::ui::right_panel::TEXT)) + .text_color(theme.muted_foreground) + .child(div().text_color(theme.foreground).child(name)) + .child(t(L10nKey::EditorSplitSameFile)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.editor_focus_group(!focus_left, window, cx); + }), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(gpui::EntityId::from).collect() + } + + fn code(files: &[BufferId], active: usize) -> TabCode { + let mut code = TabCode::new(); + code.files = files.to_vec(); + code.active = active; + code + } + + #[test] + fn splitting_opens_the_file_in_front_in_a_focused_right_group() { + let ids = ids(3); + let mut code = code(&ids, 1); + code.split_with(ids[1]); + assert_eq!(code.files, [ids[1]]); + assert_eq!(code.active_id(), Some(ids[1])); + assert!(!code.focus_is_left()); + let sides = code.sides(); + assert_eq!(sides.len(), 2); + assert_eq!( + (sides[0].files.clone(), sides[0].focused), + (ids.clone(), false) + ); + assert!(sides[1].focused); + assert_eq!(code.views_of(ids[1]), 2); + assert_eq!(code.views_of(ids[0]), 1); + assert_eq!(code.all_files(), [ids[1], ids[0], ids[2]]); + } + + #[test] + fn the_focus_swaps_the_groups_but_not_their_places() { + let ids = ids(3); + let mut code = code(&ids[..2], 0); + code.split_with(ids[0]); + code.show(ids[2]); + code.swap_focus(); + assert!(code.focus_is_left()); + assert_eq!(code.files, ids[..2]); + assert_eq!(code.other_active_id(), Some(ids[2])); + let sides = code.sides(); + assert!(sides[0].focused && !sides[1].focused); + assert_eq!(sides[1].files, [ids[0], ids[2]]); + } + + #[test] + fn a_group_that_loses_its_last_file_closes() { + let ids = ids(2); + let mut code = code(&ids, 0); + code.split_with(ids[1]); + // Closing the right group's only file leaves the left one, focused. + assert!(code.close_in_focused(ids[1])); + assert!(!code.is_split()); + assert_eq!(code.files, ids); + assert_eq!(code.active_id(), Some(ids[0]), "it keeps what was in front"); + + // Closing the other group's last file from elsewhere (the buffer went + // away) closes that group instead. + code.split_with(ids[0]); + code.swap_focus(); + assert!(code.forget_everywhere(ids[0])); + assert!(!code.is_split()); + assert_eq!(code.files, [ids[1]]); + assert!(!code.shows(ids[0])); + } + + #[test] + fn closing_in_one_group_keeps_the_file_in_the_other() { + let ids = ids(2); + let mut code = code(&ids, 0); + code.split_with(ids[0]); + code.show(ids[1]); + code.close_in_focused(ids[0]); + assert!(code.is_split()); + assert!(code.shows(ids[0])); + assert_eq!(code.views_of(ids[0]), 1); + } +} + +#[cfg(test)] +mod gpui_tests { + use std::path::PathBuf; + + use gpui::{Entity, TestAppContext, VisualTestContext}; + + use super::super::nav::gpui_tests::{caret, open_rs, put_caret}; + use super::*; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::editor_session::{self, SplitEditor, TabEditor}; + + fn front(app: &Entity, vcx: &mut VisualTestContext) -> PathBuf { + app.read_with(vcx, |app, _| app.active_buffer().unwrap().path.clone()) + } + + fn split(app: &Entity, vcx: &mut VisualTestContext) -> bool { + app.read_with(vcx, |app, _| app.tab_code().is_some_and(TabCode::is_split)) + } + + #[gpui::test] + fn two_groups_keep_their_own_file_and_caret(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/split/a.rs"); + put_caret(&app, &mut vcx, 5); + assert!(app.update_in(&mut vcx, |app, window, cx| app.editor_split(window, cx))); + assert!(split(&app, &mut vcx)); + // The new group is on the right, focused, showing the same file. + app.read_with(&vcx, |app, _| { + let code = app.tab_code().unwrap(); + assert!(!code.focus_is_left()); + assert_eq!(code.active_id(), code.other_active_id()); + }); + // A file opened now opens in the focused (right) group only. + open_rs(&app, &mut vcx, "/split/b.rs"); + put_caret(&app, &mut vcx, 40); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/b.rs")); + + // To the left: a.rs, with its own caret. + assert!(app.update_in(&mut vcx, |app, window, cx| { + app.editor_focus_group(true, window, cx) + })); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs")); + assert_eq!(caret(&app, &mut vcx).line, 5); + assert!( + !app.update_in(&mut vcx, |app, window, cx| app + .editor_focus_group(true, window, cx)), + "no group further left: the chord carries on to the panes" + ); + // And back. + assert!(app.update_in(&mut vcx, |app, window, cx| { + app.editor_focus_group(false, window, cx) + })); + assert_eq!(caret(&app, &mut vcx).line, 40); + + // Closing both files of the right group closes the group. + app.update_in(&mut vcx, |app, window, cx| { + let n = app.tab_code().unwrap().files.len(); + for _ in 0..n { + app.editor_close_file(0, window, cx); + } + }); + assert!(!split(&app, &mut vcx)); + assert_eq!(front(&app, &mut vcx), PathBuf::from("/split/a.rs")); + assert!( + app.read_with(&vcx, |app, _| app + .editor + .buffers + .iter() + .all(|b| b.path != std::path::Path::new("/split/b.rs"))), + "a file no group shows any more is closed" + ); + } + + #[gpui::test] + fn a_split_is_recorded_and_comes_back(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + // Canonical, as a loaded file's path is: /var is /private/var here. + let root = dir.path().canonicalize().unwrap(); + 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); + } +} diff --git a/src/ui/editor_session.rs b/src/ui/editor_session.rs index 4b198fb5..4fc79d19 100644 --- a/src/ui/editor_session.rs +++ b/src/ui/editor_session.rs @@ -24,7 +24,8 @@ const MAX_TABS: usize = 256; const WRITE_DELAY: std::time::Duration = std::time::Duration::from_millis(500); -/// One tab's editor, as it was last seen. +/// One tab's editor, as it was last seen. `files` and `active` are the left +/// group's — the only group, unless the editor was split. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct TabEditor { pub(crate) files: Vec, @@ -32,6 +33,20 @@ pub(crate) struct TabEditor { pub(crate) active: usize, #[serde(default)] pub(crate) visible: bool, + /// The right group of a split editor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) split: Option, +} + +/// The right-hand group of a split editor. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SplitEditor { + pub(crate) files: Vec, + #[serde(default)] + pub(crate) active: usize, + /// Whether this group, not the left one, had the focus. + #[serde(default)] + pub(crate) focused: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -188,6 +203,11 @@ mod tests { files: vec![PathBuf::from("/src/main.rs"), PathBuf::from("/README.md")], active: 1, visible: true, + split: Some(SplitEditor { + files: vec![PathBuf::from("/src/lib.rs")], + active: 0, + focused: true, + }), }, touched: 7, }, @@ -197,6 +217,16 @@ mod tests { assert_eq!(back.tabs[&id].state, doc.tabs[&id].state); } + #[test] + fn a_record_from_before_the_split_still_reads() { + let old = r#"{"tabs": {}}"#; + assert!(serde_json::from_str::(old).is_ok()); + let state: TabEditor = + serde_json::from_str(r#"{"files": ["/a.rs"], "active": 0, "visible": true}"#).unwrap(); + assert_eq!(state.split, None); + assert!(!serde_json::to_string(&state).unwrap().contains("split")); + } + #[test] fn the_oldest_tabs_are_forgotten_first() { let mut doc = Doc::default(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9f03e611..4ab669c2 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1228,6 +1228,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { 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::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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dcf1568b..294d55ba 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1276,6 +1276,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorGoToSymbolAction => "エディタ内のシンボルへ移動…", L10nKey::EditorNavigateBack => "戻る", L10nKey::EditorNavigateForward => "進む", + L10nKey::EditorSplitRight => "エディタを右に分割", + L10nKey::EditorFocusLeftGroup => "左のエディタグループにフォーカス", + L10nKey::EditorFocusRightGroup => "右のエディタグループにフォーカス", + L10nKey::EditorSplitSameFile => "もう一方のグループで開いています — クリックしてここで編集", + L10nKey::CmdEditorGoToSymbol => "エディタ: シンボルへ移動…", + L10nKey::CmdEditorGoBack => "エディタ: 戻る", + L10nKey::CmdEditorGoForward => "エディタ: 進む", + L10nKey::CmdEditorSplitRight => "エディタ: 右に分割", L10nKey::EditorSaveAs => "名前を付けて保存", L10nKey::EditorSaveAsAction => "名前を付けて保存…", L10nKey::EditorSaveAsPlaceholder => "保存先のフルパス", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 146d7ca3..58ac294f 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -958,6 +958,14 @@ l10n_keys! { EditorGoToSymbolAction, EditorNavigateBack, EditorNavigateForward, + EditorSplitRight, + EditorFocusLeftGroup, + EditorFocusRightGroup, + EditorSplitSameFile, + CmdEditorGoToSymbol, + CmdEditorGoBack, + CmdEditorGoForward, + CmdEditorSplitRight, EditorSaveAs, EditorSaveAsAction, EditorSaveAsPlaceholder, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e4016d4d..322f2735 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1146,6 +1146,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorGoToSymbolAction => "跳转到编辑器中的符号…", L10nKey::EditorNavigateBack => "后退", L10nKey::EditorNavigateForward => "前进", + L10nKey::EditorSplitRight => "向右拆分编辑器", + L10nKey::EditorFocusLeftGroup => "聚焦左侧编辑器组", + L10nKey::EditorFocusRightGroup => "聚焦右侧编辑器组", + L10nKey::EditorSplitSameFile => "已在另一组中打开 — 点击在此编辑", + L10nKey::CmdEditorGoToSymbol => "编辑器:跳转到符号…", + L10nKey::CmdEditorGoBack => "编辑器:后退", + L10nKey::CmdEditorGoForward => "编辑器:前进", + L10nKey::CmdEditorSplitRight => "编辑器:向右拆分", L10nKey::EditorSaveAs => "另存为", L10nKey::EditorSaveAsAction => "另存为…", L10nKey::EditorSaveAsPlaceholder => "要保存到的完整路径", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 8d851d0c..ae554978 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -671,6 +671,21 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { "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 @@ -997,6 +1012,18 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { 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(), @@ -1575,9 +1602,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" => { - Some(crate::ui::code_editor::NAV_KEY_CONTEXT) - } + "EditorGoToSymbol" + | "EditorNavigateBack" + | "EditorNavigateForward" + | "EditorSplitRight" + | "EditorFocusLeftGroup" + | "EditorFocusRightGroup" => Some(crate::ui::code_editor::NAV_KEY_CONTEXT), _ => None, } } @@ -1725,6 +1755,21 @@ fn make_binding(action: &str, keystroke: &str) -> Option { 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), @@ -3160,6 +3205,7 @@ mod gpui_tests { ("secondary-shift-o", EditorGoToSymbol::name_for_type()), (back, EditorNavigateBack::name_for_type()), (forward, EditorNavigateForward::name_for_type()), + ("secondary-\\", EditorSplitRight::name_for_type()), ] { assert_eq!( in_input(keys).first(), diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 7dea7c65..27f886eb 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -116,6 +116,11 @@ pub enum CommandKind { line: Option, column: Option, }, + /// The code editor's navigation, from the palette. + EditorGoToSymbol, + EditorNavigateBack, + EditorNavigateForward, + EditorSplitRight, /// A Go to Symbol row: this 0-based place in the file in front. GoToSymbol { line: u32, @@ -252,6 +257,10 @@ impl CommandKind { DocumentWidthTwoThirds => "document-width-two-thirds", ToggleDocumentPreview => "document-preview", ToggleDocumentWrap => "document-wrap", + 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", @@ -381,6 +390,10 @@ impl CommandKind { DocumentWidthTwoThirds => "DocumentWidthTwoThirds", ToggleDocumentPreview => "ToggleDocumentPreview", ToggleDocumentWrap => "ToggleDocumentWrap", + EditorGoToSymbol => "EditorGoToSymbol", + EditorNavigateBack => "EditorNavigateBack", + EditorNavigateForward => "EditorNavigateForward", + EditorSplitRight => "EditorSplitRight", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", ScmCommit => "ScmCommit", diff --git a/src/ui/search/view.rs b/src/ui/search/view.rs index a238440f..7ec6d06c 100644 --- a/src/ui/search/view.rs +++ b/src/ui/search/view.rs @@ -1029,6 +1029,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(); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index c9834ff1..1652df26 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -86,6 +86,10 @@ pub(crate) fn set_menus(cx: &mut App) { Menu::new(t(L10nKey::AppMenuView)).items([ MenuItem::action(t(L10nKey::AppMenuSearchEverywhere), TogglePalette), MenuItem::action(t(L10nKey::CmdGoToFile), QuickOpenFile), + MenuItem::action(t(L10nKey::EditorGoToSymbolAction), EditorGoToSymbol), + MenuItem::action(t(L10nKey::EditorNavigateBack), EditorNavigateBack), + MenuItem::action(t(L10nKey::EditorNavigateForward), EditorNavigateForward), + MenuItem::action(t(L10nKey::EditorSplitRight), EditorSplitRight), MenuItem::separator(), MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize), MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),