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.
This commit is contained in:
l0ng-ai
2026-09-28 17:54:04 +08:00
parent ebbeaaca60
commit 467da40a95
15 changed files with 1046 additions and 154 deletions
+3
View File
@@ -130,6 +130,9 @@ actions!(
EditorGoToSymbol,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
EditorFocusLeftGroup,
EditorFocusRightGroup,
EditorNewFile,
EditorGoToDefinition,
EditorQuickFix,
+10 -1
View File
@@ -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()) {
+190 -128
View File
@@ -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<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 {
@@ -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<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,
@@ -2819,29 +2846,64 @@ 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;
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<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));
@@ -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<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,
@@ -3335,35 +3426,9 @@ 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 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| {
+12 -3
View File
@@ -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<Self>) {
let Some(f) = self.buffer(id) else {
return;
};
+121 -18
View File
@@ -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<Item> {
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<E: InteractiveElement>(
&self,
element: E,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> Option<AnyElement> {
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<Self>,
) -> Option<AnyElement> {
let f = self.buffer(id)?;
let caret = f.input.read(cx).cursor_position();
let segments: Vec<String> = 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<Tty7App>, vcx: &mut VisualTestContext, path: &str) -> BufferId {
pub(crate) fn open_rs(
app: &Entity<Tty7App>,
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<Tty7App>, vcx: &mut VisualTestContext) -> Position {
pub(crate) fn caret(app: &Entity<Tty7App>, 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<Tty7App>, vcx: &mut VisualTestContext) {
pub(crate) fn draw(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) {
app.update(vcx, |app, cx| app.editor_nav_tick(cx));
}
fn put_caret(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, line: u32) {
pub(crate) fn put_caret(app: &Entity<Tty7App>, 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<CommandKind> = 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,
]
);
}
}
+570
View File
@@ -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<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;
}
}
/// 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.
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();
}
}
/// 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 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 = 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);
}
}
+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();
+8
View File
@@ -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",
+8
View File
@@ -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 => "保存先のフルパス",
+8
View File
@@ -958,6 +958,14 @@ l10n_keys! {
EditorGoToSymbolAction,
EditorNavigateBack,
EditorNavigateForward,
EditorSplitRight,
EditorFocusLeftGroup,
EditorFocusRightGroup,
EditorSplitSameFile,
CmdEditorGoToSymbol,
CmdEditorGoBack,
CmdEditorGoForward,
CmdEditorSplitRight,
EditorSaveAs,
EditorSaveAsAction,
EditorSaveAsPlaceholder,
+8
View File
@@ -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 => "要保存到的完整路径",
+49 -3
View File
@@ -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<KeyBinding> {
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(),
+13
View File
@@ -116,6 +116,11 @@ 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,
@@ -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",
+11
View File
@@ -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();
+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),