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);