settings: Add max tabs option (#18933)

Ulysse Buonomo created

Add a `max_tabs` option to the settings that ensure no more than this
amount of tabs are open in a pane. If set to `null`, there is no limit.

Closes #4784

Release Notes:

- Added a `max_tabs` option to cap the maximum number of open tabs.

Change summary

assets/settings/default.json               |  2 
crates/workspace/src/pane.rs               | 97 ++++++++++++++++++++++++
crates/workspace/src/workspace_settings.rs |  8 +
3 files changed, 107 insertions(+)

Detailed changes

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

crates/workspace/src/pane.rs 🔗

@@ -896,6 +896,8 @@ impl Pane {
         destination_index: Option<usize>,
         cx: &mut ViewContext<Self>,
     ) {
+        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<Self>) {
+        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<EntityId, usize> = 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<Item = &Box<dyn ItemHandle>>,
         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<usize>) {
+        cx.update_global(|store: &mut SettingsStore, cx| {
+            store.update_user_settings::<WorkspaceSettings>(cx, |settings| {
+                settings.max_tabs = value.map(|v| NonZero::new(v).unwrap())
+            });
+        });
+    }
+
     fn add_labeled_item(
         pane: &View<Pane>,
         label: &str,

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<String, String>,
     pub show_user_picture: bool,
+    pub max_tabs: Option<NonZeroUsize>,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -133,6 +136,11 @@ pub struct WorkspaceSettingsContent {
     ///
     /// Default: true
     pub show_user_picture: Option<bool>,
+    // Maximum open tabs in a pane. Will not close an unsaved
+    // tab. Set to `None` for unlimited tabs.
+    //
+    // Default: none
+    pub max_tabs: Option<NonZeroUsize>,
 }
 
 #[derive(Deserialize)]