mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
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:
@@ -130,6 +130,9 @@ actions!(
|
||||
EditorGoToSymbol,
|
||||
EditorNavigateBack,
|
||||
EditorNavigateForward,
|
||||
EditorSplitRight,
|
||||
EditorFocusLeftGroup,
|
||||
EditorFocusRightGroup,
|
||||
EditorNewFile,
|
||||
EditorGoToDefinition,
|
||||
EditorQuickFix,
|
||||
|
||||
+10
-1
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "保存先のフルパス",
|
||||
|
||||
@@ -958,6 +958,14 @@ l10n_keys! {
|
||||
EditorGoToSymbolAction,
|
||||
EditorNavigateBack,
|
||||
EditorNavigateForward,
|
||||
EditorSplitRight,
|
||||
EditorFocusLeftGroup,
|
||||
EditorFocusRightGroup,
|
||||
EditorSplitSameFile,
|
||||
CmdEditorGoToSymbol,
|
||||
CmdEditorGoBack,
|
||||
CmdEditorGoForward,
|
||||
CmdEditorSplitRight,
|
||||
EditorSaveAs,
|
||||
EditorSaveAsAction,
|
||||
EditorSaveAsPlaceholder,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user