diff --git a/assets/settings/default.json b/assets/settings/default.json index 05b172a167646c47e320e63fe0f3d340d4e305ed..635dcfad9f8be717aadff6b3ab01d8124443fae4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -552,6 +552,8 @@ // 4. Save when idle for a certain amount of time: // "autosave": { "after_delay": {"milliseconds": 500} }, "autosave": "off", + // Maximum number of tabs per pane. Unset for unlimited. + "max_tabs": null, // Settings related to the editor's tab bar. "tab_bar": { // Whether or not to show the tab bar in the editor diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8748c234f89232baea8fa5027aafc4f65910545e..3779f069905b5e8492d5f8a405e7efe0f09d48e2 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -896,6 +896,8 @@ impl Pane { destination_index: Option, cx: &mut ViewContext, ) { + self.close_items_over_max_tabs(cx); + if item.is_singleton(cx) { if let Some(&entry_id) = item.project_entry_ids(cx).first() { let project = self.project.read(cx); @@ -1298,6 +1300,43 @@ impl Pane { )) } + pub fn close_items_over_max_tabs(&mut self, cx: &mut ViewContext) { + let Some(max_tabs) = WorkspaceSettings::get_global(cx).max_tabs.map(|i| i.get()) else { + return; + }; + + // Reduce over the activation history to get every dirty items up to max_tabs + // count. + let mut index_list = Vec::new(); + let mut items_len = self.items_len(); + let mut indexes: HashMap = HashMap::default(); + for (index, item) in self.items.iter().enumerate() { + indexes.insert(item.item_id(), index); + } + for entry in self.activation_history.iter() { + if items_len < max_tabs { + break; + } + let Some(&index) = indexes.get(&entry.entity_id) else { + continue; + }; + if let Some(true) = self.items.get(index).map(|item| item.is_dirty(cx)) { + continue; + } + + index_list.push(index); + items_len -= 1; + } + // The sort and reverse is necessary since we remove items + // using their index position, hence removing from the end + // of the list first to avoid changing indexes. + index_list.sort_unstable(); + index_list + .iter() + .rev() + .for_each(|&index| self._remove_item(index, false, false, None, cx)); + } + pub(super) fn file_names_for_prompt( items: &mut dyn Iterator>, all_dirty_items: usize, @@ -3282,6 +3321,8 @@ impl Render for DraggedTab { #[cfg(test)] mod tests { + use std::num::NonZero; + use super::*; use crate::item::test::{TestItem, TestProjectItem}; use gpui::{TestAppContext, VisualTestContext}; @@ -3305,6 +3346,54 @@ mod tests { }); } + #[gpui::test] + async fn test_add_item_capped_to_max_tabs(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + for i in 0..7 { + add_labeled_item(&pane, format!("{}", i).as_str(), false, cx); + } + set_max_tabs(cx, Some(5)); + add_labeled_item(&pane, "7", false, cx); + // Remove items to respect the max tab cap. + assert_item_labels(&pane, ["3", "4", "5", "6", "7*"], cx); + pane.update(cx, |pane, cx| { + pane.activate_item(0, false, false, cx); + }); + add_labeled_item(&pane, "X", false, cx); + // Respect activation order. + assert_item_labels(&pane, ["3", "X*", "5", "6", "7"], cx); + + for i in 0..7 { + add_labeled_item(&pane, format!("D{}", i).as_str(), true, cx); + } + // Keeps dirty items, even over max tab cap. + assert_item_labels( + &pane, + ["D0^", "D1^", "D2^", "D3^", "D4^", "D5^", "D6*^"], + cx, + ); + + set_max_tabs(cx, None); + for i in 0..7 { + add_labeled_item(&pane, format!("N{}", i).as_str(), false, cx); + } + // No cap when max tabs is None. + assert_item_labels( + &pane, + [ + "D0^", "D1^", "D2^", "D3^", "D4^", "D5^", "D6^", "N0", "N1", "N2", "N3", "N4", + "N5", "N6*", + ], + cx, + ); + } + #[gpui::test] async fn test_add_item_with_new_item(cx: &mut TestAppContext) { init_test(cx); @@ -3984,6 +4073,14 @@ mod tests { }); } + fn set_max_tabs(cx: &mut TestAppContext, value: Option) { + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |settings| { + settings.max_tabs = value.map(|v| NonZero::new(v).unwrap()) + }); + }); + } + fn add_labeled_item( pane: &View, label: &str, diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index b27a09c24ccaa405aebe6f539c44ba5fc58b90cb..118287cc837c4663a840a539cd18683e650dde18 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -1,3 +1,5 @@ +use std::num::NonZeroUsize; + use anyhow::Result; use collections::HashMap; use gpui::AppContext; @@ -20,6 +22,7 @@ pub struct WorkspaceSettings { pub use_system_path_prompts: bool, pub command_aliases: HashMap, pub show_user_picture: bool, + pub max_tabs: Option, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -133,6 +136,11 @@ pub struct WorkspaceSettingsContent { /// /// Default: true pub show_user_picture: Option, + // Maximum open tabs in a pane. Will not close an unsaved + // tab. Set to `None` for unlimited tabs. + // + // Default: none + pub max_tabs: Option, } #[derive(Deserialize)]