Detailed changes
@@ -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",
@@ -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" }
@@ -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": {
@@ -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": {
@@ -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<V: 'static>(
+ blur_handler: impl FnMut(&mut V, &mut ViewContext<'_, V>) + 'static,
+ cx: &mut ViewContext<V>,
+ ) -> Self {
+ let head = cx.new_view(|cx| EmptyHead::new(cx));
+ cx.on_blur(&head.focus_handle(cx), blur_handler).detach();
+ Self::Empty(head)
}
}
@@ -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<D: PickerDelegate> Picker<D> {
/// 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>) -> 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<D: PickerDelegate> Picker<D> {
}
}
+ fn on_empty_head_blur(&mut self, cx: &mut ViewContext<Self>) {
+ let Head::Empty(_) = &self.head else {
+ panic!("unexpected call");
+ };
+ self.cancel(&menu::Cancel, cx);
+ }
+
pub fn refresh(&mut self, cx: &mut ViewContext<Self>) {
let query = self.query(cx);
self.update_matches(query, cx);
@@ -394,6 +401,16 @@ impl<D: PickerDelegate> Picker<D> {
.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),
@@ -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"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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<Picker<TabSwitcherDelegate>>,
+ init_modifiers: Option<Modifiers>,
+}
+
+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>) {
+ workspace.register_action(|workspace, action: &Toggle, cx| {
+ let Some(tab_switcher) = workspace.active_modal::<Self>(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<Workspace>) {
+ 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 {
+ 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<Self>,
+ ) {
+ 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<DismissEvent> 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<Self>) -> 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<dyn ItemHandle>,
+ detail: usize,
+}
+
+pub struct TabSwitcherDelegate {
+ select_last: bool,
+ tab_switcher: WeakView<TabSwitcher>,
+ selected_index: usize,
+ pane: WeakView<Pane>,
+ matches: Vec<TabMatch>,
+}
+
+impl TabSwitcherDelegate {
+ fn new(
+ action: &Toggle,
+ tab_switcher: WeakView<TabSwitcher>,
+ pane: WeakView<Pane>,
+ cx: &mut ViewContext<TabSwitcher>,
+ ) -> 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<Pane>, cx: &mut ViewContext<TabSwitcher>) {
+ 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<Box<dyn ItemHandle>> = 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<str> {
+ "".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<Picker<Self>>) {
+ self.selected_index = ix;
+ cx.notify();
+ }
+
+ fn separators_after_indices(&self) -> Vec<usize> {
+ Vec::new()
+ }
+
+ fn update_matches(
+ &mut self,
+ _raw_query: String,
+ cx: &mut ViewContext<Picker<Self>>,
+ ) -> Task<()> {
+ self.update_matches(cx);
+ Task::ready(())
+ }
+
+ fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
+ 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<Picker<TabSwitcherDelegate>>) {
+ self.tab_switcher
+ .update(cx, |_, cx| cx.emit(DismissEvent))
+ .log_err();
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ cx: &mut ViewContext<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ 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),
+ )
+ }
+}
@@ -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::<Editor>(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<AppState> {
+ 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<Workspace>,
+ cx: &mut VisualTestContext,
+) -> View<Picker<TabSwitcherDelegate>> {
+ cx.dispatch_action(Toggle { select_last });
+ get_active_tab_switcher(workspace, cx)
+}
+
+#[track_caller]
+fn get_active_tab_switcher(
+ workspace: &View<Workspace>,
+ cx: &mut VisualTestContext,
+) -> View<Picker<TabSwitcherDelegate>> {
+ workspace.update(cx, |workspace, cx| {
+ workspace
+ .active_modal::<TabSwitcher>(cx)
+ .expect("tab switcher is not open")
+ .read(cx)
+ .picker
+ .clone()
+ })
+}
+
+async fn open_buffer(
+ file_path: &str,
+ workspace: &View<Workspace>,
+ cx: &mut gpui::VisualTestContext,
+) -> Box<dyn ItemHandle> {
+ 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<TabSwitcherDelegate>,
+ expected_selection_index: usize,
+ expected_item: Box<dyn ItemHandle>,
+) {
+ 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<TabSwitcherDelegate>,
+ match_index: usize,
+ expected_item: Box<dyn ItemHandle>,
+) {
+ 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<Workspace>, cx: &mut VisualTestContext) {
+ workspace.update(cx, |workspace, cx| {
+ assert!(
+ workspace.active_modal::<TabSwitcher>(cx).is_none(),
+ "tab switcher is still open"
+ );
+ });
+}
@@ -422,6 +422,10 @@ impl Pane {
self.active_item_index
}
+ pub fn activation_history(&self) -> &Vec<EntityId> {
+ &self.activation_history
+ }
+
pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext<Self>) {
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<usize> {
- let mut tab_details = self.items.iter().map(|_| 0).collect::<Vec<_>>();
-
- 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>) {
self.zoomed = zoomed;
cx.notify();
@@ -2127,6 +2084,54 @@ fn dirty_message_for(buffer_path: Option<ProjectPath>) -> String {
format!("{path} contains unsaved edits. Do you want to save it?")
}
+pub fn tab_details(items: &Vec<Box<dyn ItemHandle>>, cx: &AppContext) -> Vec<usize> {
+ let mut tab_details = items.iter().map(|_| 0).collect::<Vec<_>>();
+ 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<dyn ItemHandle>, cx: &WindowContext) -> Option<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))
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -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
@@ -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);