From 894b39a9189baab458c6db383445bd1b6adcef7f Mon Sep 17 00:00:00 2001 From: Andrew Lygin Date: Wed, 27 Mar 2024 21:15:08 +0300 Subject: [PATCH] Add tab switcher (#7987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tab Switcher implementation (#7653): - `ctrl-tab` opens the Tab Switcher and moves selection to the previously selcted tab. It also cycles selection forward. - `ctrl-shift-tab` opens the Tab Switcher and moves selection to the last tab in the list. It also cycles selection backward. - Tab is selected and the Tab Switcher is closed on the shortcut modifier key (`ctrl` by default) release. - List items are in reverse activation history order. - The list reacts to the item changes in background (new tab, tab closed, tab title changed etc.) Intentionally not in scope of this PR: - File icons - Close buttons I will come back to these features. I think they need to be implemented in separate PRs, and be synchronized with changes in how tabs are rendered, to reuse the code as it's done in the current implementation. The Tab Switcher looks usable even without them. Known Issues: Tab Switcher doesn't react to mouse click on a list item. It's not a tab switcher specific problem, it looks like ctrl-clicks are not handled the same way in Zed as cmd-clicks. For instance, menu items can be activated with cmd-click, but don't react to ctrl-click. Since the Tab Switcher's default keybinding is `ctrl-tab`, the user can only click an item with `ctrl` pushed down, thus preventing `on_click()` from firing. fixes #7653, #7321 Release Notes: - Added Tab Switcher which is accessible via `ctrl-tab` and `ctrl-shift-tab` (#7653) (#7321) Related issues: - Unblocks #7356, I hope 😄 How it looks and works (it's only `ctrl-tab`'s and `ctrl-shift-tab`'s, no `enter`'s or mouse clicks): https://github.com/zed-industries/zed/assets/2101250/4ad4ec6a-5314-481b-8b35-7ac85e43eb92 --------- Co-authored-by: Conrad Irwin Co-authored-by: Mikayla Maki --- Cargo.lock | 23 ++ Cargo.toml | 2 + assets/keymaps/default-linux.json | 8 +- assets/keymaps/default-macos.json | 7 + crates/picker/src/head.rs | 9 +- crates/picker/src/picker.rs | 23 +- crates/tab_switcher/Cargo.toml | 32 ++ crates/tab_switcher/LICENSE-GPL | 1 + crates/tab_switcher/src/tab_switcher.rs | 279 +++++++++++++++++ crates/tab_switcher/src/tab_switcher_tests.rs | 282 ++++++++++++++++++ crates/workspace/src/pane.rs | 103 ++++--- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + 13 files changed, 715 insertions(+), 56 deletions(-) create mode 100644 crates/tab_switcher/Cargo.toml create mode 120000 crates/tab_switcher/LICENSE-GPL create mode 100644 crates/tab_switcher/src/tab_switcher.rs create mode 100644 crates/tab_switcher/src/tab_switcher_tests.rs diff --git a/Cargo.lock b/Cargo.lock index c18801d874..93ec57c598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9398,6 +9398,28 @@ dependencies = [ "winx", ] +[[package]] +name = "tab_switcher" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "ctor", + "editor", + "env_logger", + "gpui", + "language", + "menu", + "picker", + "project", + "serde", + "serde_json", + "theme", + "ui", + "util", + "workspace", +] + [[package]] name = "taffy" version = "0.3.11" @@ -12522,6 +12544,7 @@ dependencies = [ "settings", "simplelog", "smol", + "tab_switcher", "task", "tasks_ui", "terminal_view", diff --git a/Cargo.toml b/Cargo.toml index ac35201bd7..c9000f0343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ members = [ "crates/story", "crates/storybook", "crates/sum_tree", + "crates/tab_switcher", "crates/terminal", "crates/terminal_view", "crates/text", @@ -188,6 +189,7 @@ sqlez_macros = { path = "crates/sqlez_macros" } story = { path = "crates/story" } storybook = { path = "crates/storybook" } sum_tree = { path = "crates/sum_tree" } +tab_switcher = { path = "crates/tab_switcher" } terminal = { path = "crates/terminal" } terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9734d5ec9a..22da693baf 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -263,9 +263,7 @@ { "context": "Pane", "bindings": { - "ctrl-shift-tab": "pane::ActivatePrevItem", "ctrl-pageup": "pane::ActivatePrevItem", - "ctrl-tab": "pane::ActivateNextItem", "ctrl-pagedown": "pane::ActivateNextItem", "ctrl-w": "pane::CloseActiveItem", "alt-ctrl-t": "pane::CloseInactiveItems", @@ -420,6 +418,8 @@ "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", + "ctrl-tab": "tab_switcher::Toggle", + "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], "ctrl-e": "file_finder::Toggle", "ctrl-shift-p": "command_palette::Toggle", "ctrl-shift-m": "diagnostics::Deploy", @@ -589,6 +589,10 @@ "context": "FileFinder", "bindings": { "ctrl-shift-p": "file_finder::SelectPrev" } }, + { + "context": "TabSwitcher", + "bindings": { "ctrl-shift-tab": "menu::SelectPrev" } + }, { "context": "Terminal", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d1b12be757..8c0353a421 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -17,6 +17,7 @@ "cmd-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "cmd-escape": "menu::Cancel", + "ctrl-escape": "menu::Cancel", "ctrl-c": "menu::Cancel", "shift-enter": "menu::UseSelectedQuery", "cmd-shift-w": "workspace::CloseWindow", @@ -441,6 +442,8 @@ "cmd-k cmd-t": "theme_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", + "ctrl-tab": "tab_switcher::Toggle", + "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", @@ -603,6 +606,10 @@ "context": "FileFinder", "bindings": { "cmd-shift-p": "file_finder::SelectPrev" } }, + { + "context": "TabSwitcher", + "bindings": { "ctrl-shift-tab": "menu::SelectPrev" } + }, { "context": "Terminal", "bindings": { diff --git a/crates/picker/src/head.rs b/crates/picker/src/head.rs index 45dbb00eab..cd4f2729b0 100644 --- a/crates/picker/src/head.rs +++ b/crates/picker/src/head.rs @@ -28,8 +28,13 @@ impl Head { Self::Editor(editor) } - pub fn empty(cx: &mut WindowContext) -> Self { - Self::Empty(cx.new_view(|cx| EmptyHead::new(cx))) + pub fn empty( + blur_handler: impl FnMut(&mut V, &mut ViewContext<'_, V>) + 'static, + cx: &mut ViewContext, + ) -> Self { + let head = cx.new_view(|cx| EmptyHead::new(cx)); + cx.on_blur(&head.focus_handle(cx), blur_handler).detach(); + Self::Empty(head) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 7cd58a250c..dc2f894ab3 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -2,8 +2,8 @@ use anyhow::Result; use editor::{scroll::Autoscroll, Editor}; use gpui::{ div, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent, DismissEvent, - EventEmitter, FocusHandle, FocusableView, Length, ListState, Render, Task, - UniformListScrollHandle, View, ViewContext, WindowContext, + EventEmitter, FocusHandle, FocusableView, Length, ListState, MouseButton, MouseUpEvent, Render, + Task, UniformListScrollHandle, View, ViewContext, WindowContext, }; use head::Head; use std::{sync::Arc, time::Duration}; @@ -116,7 +116,7 @@ impl Picker { /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height. /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`. pub fn nonsearchable_uniform_list(delegate: D, cx: &mut ViewContext) -> Self { - let head = Head::empty(cx); + let head = Head::empty(Self::on_empty_head_blur, cx); Self::new(delegate, ContainerKind::UniformList, head, cx) } @@ -313,6 +313,13 @@ impl Picker { } } + fn on_empty_head_blur(&mut self, cx: &mut ViewContext) { + let Head::Empty(_) = &self.head else { + panic!("unexpected call"); + }; + self.cancel(&menu::Cancel, cx); + } + pub fn refresh(&mut self, cx: &mut ViewContext) { let query = self.query(cx); self.update_matches(query, cx); @@ -394,6 +401,16 @@ impl Picker { .on_click(cx.listener(move |this, event: &ClickEvent, cx| { this.handle_click(ix, event.down.modifiers.command, cx) })) + // As of this writing, GPUI intercepts `ctrl-[mouse-event]`s on macOS + // and produces right mouse button events. This matches platforms norms + // but means that UIs which depend on holding ctrl down (such as the tab + // switcher) can't be clicked on. Hence, this handler. + .on_mouse_up( + MouseButton::Right, + cx.listener(move |this, event: &MouseUpEvent, cx| { + this.handle_click(ix, event.modifiers.command, cx) + }), + ) .children( self.delegate .render_match(ix, ix == self.delegate.selected_index(), cx), diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml new file mode 100644 index 0000000000..83d6e20e61 --- /dev/null +++ b/crates/tab_switcher/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tab_switcher" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lib] +path = "src/tab_switcher.rs" +doctest = false + +[dependencies] +collections.workspace = true +gpui.workspace = true +menu.workspace = true +picker.workspace = true +serde.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +anyhow.workspace = true +ctor.workspace = true +editor.workspace = true +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +project.workspace = true +serde_json.workspace = true +theme = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/tab_switcher/LICENSE-GPL b/crates/tab_switcher/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/tab_switcher/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs new file mode 100644 index 0000000000..0e444171c0 --- /dev/null +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -0,0 +1,279 @@ +#[cfg(test)] +mod tab_switcher_tests; + +use collections::HashMap; +use gpui::{ + impl_actions, rems, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, + Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, View, ViewContext, + VisualContext, WeakView, +}; +use picker::{Picker, PickerDelegate}; +use serde::Deserialize; +use std::sync::Arc; +use ui::{prelude::*, ListItem, ListItemSpacing}; +use util::ResultExt; +use workspace::{ + item::ItemHandle, + pane::{render_item_indicator, tab_details, Event as PaneEvent}, + ModalView, Pane, Workspace, +}; + +const PANEL_WIDTH_REMS: f32 = 28.; + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct Toggle { + #[serde(default)] + pub select_last: bool, +} + +impl_actions!(tab_switcher, [Toggle]); + +pub struct TabSwitcher { + picker: View>, + init_modifiers: Option, +} + +impl ModalView for TabSwitcher {} + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(TabSwitcher::register).detach(); +} + +impl TabSwitcher { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(|workspace, action: &Toggle, cx| { + let Some(tab_switcher) = workspace.active_modal::(cx) else { + Self::open(action, workspace, cx); + return; + }; + + tab_switcher.update(cx, |tab_switcher, cx| { + tab_switcher + .picker + .update(cx, |picker, cx| picker.cycle_selection(cx)) + }); + }); + } + + fn open(action: &Toggle, workspace: &mut Workspace, cx: &mut ViewContext) { + let weak_pane = workspace.active_pane().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = TabSwitcherDelegate::new(action, cx.view().downgrade(), weak_pane, cx); + TabSwitcher::new(delegate, cx) + }); + } + + fn new(delegate: TabSwitcherDelegate, cx: &mut ViewContext) -> Self { + Self { + picker: cx.new_view(|cx| Picker::nonsearchable_uniform_list(delegate, cx)), + init_modifiers: cx.modifiers().modified().then_some(cx.modifiers()), + } + } + + fn handle_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + cx: &mut ViewContext, + ) { + let Some(init_modifiers) = self.init_modifiers else { + return; + }; + if !event.modified() || !init_modifiers.is_subset_of(event) { + self.init_modifiers = None; + if self.picker.read(cx).delegate.matches.is_empty() { + cx.emit(DismissEvent) + } else { + cx.dispatch_action(menu::Confirm.boxed_clone()); + } + } + } +} + +impl EventEmitter for TabSwitcher {} + +impl FocusableView for TabSwitcher { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for TabSwitcher { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .key_context("TabSwitcher") + .w(rems(PANEL_WIDTH_REMS)) + .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .child(self.picker.clone()) + } +} + +struct TabMatch { + item_index: usize, + item: Box, + detail: usize, +} + +pub struct TabSwitcherDelegate { + select_last: bool, + tab_switcher: WeakView, + selected_index: usize, + pane: WeakView, + matches: Vec, +} + +impl TabSwitcherDelegate { + fn new( + action: &Toggle, + tab_switcher: WeakView, + pane: WeakView, + cx: &mut ViewContext, + ) -> Self { + Self::subscribe_to_updates(&pane, cx); + Self { + select_last: action.select_last, + tab_switcher, + selected_index: 0, + pane, + matches: Vec::new(), + } + } + + fn subscribe_to_updates(pane: &WeakView, cx: &mut ViewContext) { + let Some(pane) = pane.upgrade() else { + return; + }; + cx.subscribe(&pane, |tab_switcher, _, event, cx| { + match event { + PaneEvent::AddItem { .. } | PaneEvent::RemoveItem { .. } | PaneEvent::Remove => { + tab_switcher + .picker + .update(cx, |picker, cx| picker.refresh(cx)) + } + _ => {} + }; + }) + .detach(); + } + + fn update_matches(&mut self, cx: &mut WindowContext) { + self.matches.clear(); + let Some(pane) = self.pane.upgrade() else { + return; + }; + + let pane = pane.read(cx); + let mut history_indices = HashMap::default(); + pane.activation_history().iter().rev().enumerate().for_each( + |(history_index, entity_id)| { + history_indices.insert(entity_id, history_index); + }, + ); + + let items: Vec> = pane.items().map(|item| item.boxed_clone()).collect(); + items + .iter() + .enumerate() + .zip(tab_details(&items, cx)) + .map(|((item_index, item), detail)| TabMatch { + item_index, + item: item.boxed_clone(), + detail, + }) + .for_each(|tab_match| self.matches.push(tab_match)); + + let non_history_base = history_indices.len(); + self.matches.sort_by(move |a, b| { + let a_score = *history_indices + .get(&a.item.item_id()) + .unwrap_or(&(a.item_index + non_history_base)); + let b_score = *history_indices + .get(&b.item.item_id()) + .unwrap_or(&(b.item_index + non_history_base)); + a_score.cmp(&b_score) + }); + + if self.matches.len() > 1 { + if self.select_last { + self.selected_index = self.matches.len() - 1; + } else { + self.selected_index = 1; + } + } + } +} + +impl PickerDelegate for TabSwitcherDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = ix; + cx.notify(); + } + + fn separators_after_indices(&self) -> Vec { + Vec::new() + } + + fn update_matches( + &mut self, + _raw_query: String, + cx: &mut ViewContext>, + ) -> Task<()> { + self.update_matches(cx); + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + let Some(pane) = self.pane.upgrade() else { + return; + }; + let Some(selected_match) = self.matches.get(self.selected_index()) else { + return; + }; + pane.update(cx, |pane, cx| { + pane.activate_item(selected_match.item_index, true, true, cx); + }); + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + self.tab_switcher + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let tab_match = self + .matches + .get(ix) + .expect("Invalid matches state: no element for index {ix}"); + + let label = tab_match.item.tab_content(Some(tab_match.detail), true, cx); + let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx); + + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .selected(selected) + .child(h_flex().w_full().child(label)) + .children(indicator), + ) + } +} diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs new file mode 100644 index 0000000000..b4a2a510bb --- /dev/null +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -0,0 +1,282 @@ +use super::*; +use editor::Editor; +use gpui::{TestAppContext, VisualTestContext}; +use menu::SelectPrev; +use project::{Project, ProjectPath}; +use serde_json::json; +use std::path::Path; +use workspace::{AppState, Workspace}; + +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} + +#[gpui::test] +async fn test_open_with_prev_tab_selected_and_cycle_on_toggle_action( + cx: &mut gpui::TestAppContext, +) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + "4.txt": "Fourth file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + let tab_1 = open_buffer("1.txt", &workspace, cx).await; + let tab_2 = open_buffer("2.txt", &workspace, cx).await; + let tab_3 = open_buffer("3.txt", &workspace, cx).await; + let tab_4 = open_buffer("4.txt", &workspace, cx).await; + + // Starts with the previously opened item selected + let tab_switcher = open_tab_switcher(false, &workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 4); + assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone()); + assert_match_selection(tab_switcher, 1, tab_3.boxed_clone()); + assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone()); + assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone()); + }); + + cx.dispatch_action(Toggle { select_last: false }); + cx.dispatch_action(Toggle { select_last: false }); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 4); + assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone()); + assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone()); + assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone()); + assert_match_selection(tab_switcher, 3, tab_1.boxed_clone()); + }); + + cx.dispatch_action(SelectPrev); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 4); + assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone()); + assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone()); + assert_match_selection(tab_switcher, 2, tab_2.boxed_clone()); + assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone()); + }); +} + +#[gpui::test] +async fn test_open_with_last_tab_selected(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + let tab_1 = open_buffer("1.txt", &workspace, cx).await; + let tab_2 = open_buffer("2.txt", &workspace, cx).await; + let tab_3 = open_buffer("3.txt", &workspace, cx).await; + + // Starts with the last item selected + let tab_switcher = open_tab_switcher(true, &workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 3); + assert_match_at_position(tab_switcher, 0, tab_3); + assert_match_at_position(tab_switcher, 1, tab_2); + assert_match_selection(tab_switcher, 2, tab_1); + }); +} + +#[gpui::test] +async fn test_open_item_on_modifiers_release(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "1.txt": "First file", + "2.txt": "Second file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + let tab_1 = open_buffer("1.txt", &workspace, cx).await; + let tab_2 = open_buffer("2.txt", &workspace, cx).await; + + cx.simulate_modifiers_change(Modifiers::control()); + let tab_switcher = open_tab_switcher(false, &workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 2); + assert_match_at_position(tab_switcher, 0, tab_2.boxed_clone()); + assert_match_selection(tab_switcher, 1, tab_1.boxed_clone()); + }); + + cx.simulate_modifiers_change(Modifiers::none()); + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!(active_editor.read(cx).title(cx), "1.txt"); + }); + assert_tab_switcher_is_closed(workspace, cx); +} + +#[gpui::test] +async fn test_open_on_empty_pane(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + app_state.fs.as_fake().insert_tree("/root", json!({})).await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + cx.simulate_modifiers_change(Modifiers::control()); + let tab_switcher = open_tab_switcher(false, &workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert!(tab_switcher.delegate.matches.is_empty()); + }); + + cx.simulate_modifiers_change(Modifiers::none()); + assert_tab_switcher_is_closed(workspace, cx); +} + +#[gpui::test] +async fn test_open_with_single_item(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({"1.txt": "Single file"})) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + let tab = open_buffer("1.txt", &workspace, cx).await; + + let tab_switcher = open_tab_switcher(false, &workspace, cx); + tab_switcher.update(cx, |tab_switcher, _| { + assert_eq!(tab_switcher.delegate.matches.len(), 1); + assert_match_selection(tab_switcher, 0, tab); + }); +} + +fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let state = AppState::test(cx); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + super::init(cx); + editor::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + state + }) +} + +#[track_caller] +fn open_tab_switcher( + select_last: bool, + workspace: &View, + cx: &mut VisualTestContext, +) -> View> { + cx.dispatch_action(Toggle { select_last }); + get_active_tab_switcher(workspace, cx) +} + +#[track_caller] +fn get_active_tab_switcher( + workspace: &View, + cx: &mut VisualTestContext, +) -> View> { + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .expect("tab switcher is not open") + .read(cx) + .picker + .clone() + }) +} + +async fn open_buffer( + file_path: &str, + workspace: &View, + cx: &mut gpui::VisualTestContext, +) -> Box { + let project = workspace.update(cx, |workspace, _| workspace.project().clone()); + let worktree_id = project.update(cx, |project, cx| { + let worktree = project.worktrees().last().expect("worktree not found"); + worktree.read(cx).id() + }); + let project_path = ProjectPath { + worktree_id, + path: Arc::from(Path::new(file_path)), + }; + workspace + .update(cx, move |workspace, cx| { + workspace.open_path(project_path, None, true, cx) + }) + .await + .unwrap() +} + +#[track_caller] +fn assert_match_selection( + tab_switcher: &Picker, + expected_selection_index: usize, + expected_item: Box, +) { + assert_eq!( + tab_switcher.delegate.selected_index(), + expected_selection_index, + "item is not selected" + ); + assert_match_at_position(tab_switcher, expected_selection_index, expected_item); +} + +#[track_caller] +fn assert_match_at_position( + tab_switcher: &Picker, + match_index: usize, + expected_item: Box, +) { + let match_item = tab_switcher + .delegate + .matches + .get(match_index) + .unwrap_or_else(|| panic!("Tab Switcher has no match for index {match_index}")); + assert_eq!(match_item.item.item_id(), expected_item.item_id()); +} + +#[track_caller] +fn assert_tab_switcher_is_closed(workspace: View, cx: &mut VisualTestContext) { + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_none(), + "tab switcher is still open" + ); + }); +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 41ba2079f9..195d1f816f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -422,6 +422,10 @@ impl Pane { self.active_item_index } + pub fn activation_history(&self) -> &Vec { + &self.activation_history + } + pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext) { self.can_split = can_split; cx.notify(); @@ -1309,17 +1313,7 @@ impl Pane { let label = item.tab_content(Some(detail), is_active, cx); let close_side = &ItemSettings::get_global(cx).close_position; - - let indicator = maybe!({ - let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) { - (true, _) => Color::Warning, - (_, true) => Color::Accent, - (false, false) => return None, - }; - - Some(Indicator::dot().color(indicator_color)) - }); - + let indicator = render_item_indicator(item.boxed_clone(), cx); let item_id = item.item_id(); let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; @@ -1529,7 +1523,7 @@ impl Pane { self.items .iter() .enumerate() - .zip(self.tab_details(cx)) + .zip(tab_details(&self.items, cx)) .map(|((ix, item), detail)| self.render_tab(ix, item, detail, cx)), ) .child( @@ -1576,43 +1570,6 @@ impl Pane { .child(overlay().anchor(AnchorCorner::TopRight).child(menu.clone())) } - fn tab_details(&self, cx: &AppContext) -> Vec { - let mut tab_details = self.items.iter().map(|_| 0).collect::>(); - - let mut tab_descriptions = HashMap::default(); - let mut done = false; - while !done { - done = true; - - // Store item indices by their tab description. - for (ix, (item, detail)) in self.items.iter().zip(&tab_details).enumerate() { - if let Some(description) = item.tab_description(*detail, cx) { - if *detail == 0 - || Some(&description) != item.tab_description(detail - 1, cx).as_ref() - { - tab_descriptions - .entry(description) - .or_insert(Vec::new()) - .push(ix); - } - } - } - - // If two or more items have the same tab description, increase eir level - // of detail and try again. - for (_, item_ixs) in tab_descriptions.drain() { - if item_ixs.len() > 1 { - done = false; - for ix in item_ixs { - tab_details[ix] += 1; - } - } - } - } - - tab_details - } - pub fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { self.zoomed = zoomed; cx.notify(); @@ -2127,6 +2084,54 @@ fn dirty_message_for(buffer_path: Option) -> String { format!("{path} contains unsaved edits. Do you want to save it?") } +pub fn tab_details(items: &Vec>, cx: &AppContext) -> Vec { + let mut tab_details = items.iter().map(|_| 0).collect::>(); + let mut tab_descriptions = HashMap::default(); + let mut done = false; + while !done { + done = true; + + // Store item indices by their tab description. + for (ix, (item, detail)) in items.iter().zip(&tab_details).enumerate() { + if let Some(description) = item.tab_description(*detail, cx) { + if *detail == 0 + || Some(&description) != item.tab_description(detail - 1, cx).as_ref() + { + tab_descriptions + .entry(description) + .or_insert(Vec::new()) + .push(ix); + } + } + } + + // If two or more items have the same tab description, increase their level + // of detail and try again. + for (_, item_ixs) in tab_descriptions.drain() { + if item_ixs.len() > 1 { + done = false; + for ix in item_ixs { + tab_details[ix] += 1; + } + } + } + } + + tab_details +} + +pub fn render_item_indicator(item: Box, cx: &WindowContext) -> Option { + maybe!({ + let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) { + (true, _) => Color::Warning, + (_, true) => Color::Accent, + (false, false) => return None, + }; + + Some(Indicator::dot().color(indicator_color)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index be89a8d92c..2475c2ebfd 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -77,6 +77,7 @@ serde_json.workspace = true settings.workspace = true simplelog = "0.9" smol.workspace = true +tab_switcher.workspace = true task.workspace = true tasks_ui.workspace = true terminal_view.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index fe38706352..4611b69524 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -247,6 +247,7 @@ fn main() { go_to_line::init(cx); file_finder::init(cx); + tab_switcher::init(cx); outline::init(cx); project_symbols::init(cx); project_panel::init(Assets, cx);