Differentiate among tabs with the same name

Antonio Scandurra created

This commit introduces a new, optional `Item::tab_description` method
that lets implementers define a description for the tab with a certain
`detail`. When two or more tabs match the same description, we will
increase the `detail` until tabs don't match anymore or increasing the
`detail` doesn't disambiguate tabs any further.

As soon as we find a valid `detail` that disambiguates tabs enough, we
will pass it to `Item::tab_content`. In `Editor`, this is implemented by
showing more and more of the path's suffix as `detail` is increased.

Change summary

crates/diagnostics/src/diagnostics.rs |  7 ++
crates/editor/src/editor.rs           |  2 
crates/editor/src/items.rs            | 82 +++++++++++++++++++++++++++-
crates/editor/src/multi_buffer.rs     |  9 +-
crates/language/src/buffer.rs         |  4 
crates/project/src/worktree.rs        |  5 -
crates/search/src/project_search.rs   |  7 ++
crates/terminal/src/terminal.rs       |  7 ++
crates/theme/src/theme.rs             |  1 
crates/workspace/src/pane.rs          | 43 ++++++++++++++
crates/workspace/src/workspace.rs     | 25 +++++++-
styles/src/styleTree/workspace.ts     |  4 +
12 files changed, 171 insertions(+), 25 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostics.rs 🔗

@@ -501,7 +501,12 @@ impl ProjectDiagnosticsEditor {
 }
 
 impl workspace::Item for ProjectDiagnosticsEditor {
-    fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
+    fn tab_content(
+        &self,
+        _detail: Option<usize>,
+        style: &theme::Tab,
+        cx: &AppContext,
+    ) -> ElementBox {
         render_summary(
             &self.summary,
             &style.label.text,

crates/editor/src/editor.rs 🔗

@@ -1073,7 +1073,7 @@ impl Editor {
         &self.buffer
     }
 
-    pub fn title(&self, cx: &AppContext) -> String {
+    pub fn title<'a>(&self, cx: &'a AppContext) -> Cow<'a, str> {
         self.buffer().read(cx).title(cx)
     }
 

crates/editor/src/items.rs 🔗

@@ -1,4 +1,6 @@
-use crate::{Anchor, Autoscroll, Editor, Event, ExcerptId, NavigationData, ToPoint as _};
+use crate::{
+    Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer, NavigationData, ToPoint as _,
+};
 use anyhow::{anyhow, Result};
 use futures::FutureExt;
 use gpui::{
@@ -10,7 +12,12 @@ use project::{File, Project, ProjectEntryId, ProjectPath};
 use rpc::proto::{self, update_view};
 use settings::Settings;
 use smallvec::SmallVec;
-use std::{fmt::Write, path::PathBuf, time::Duration};
+use std::{
+    borrow::Cow,
+    fmt::Write,
+    path::{Path, PathBuf},
+    time::Duration,
+};
 use text::{Point, Selection};
 use util::TryFutureExt;
 use workspace::{FollowableItem, Item, ItemHandle, ItemNavHistory, ProjectItem, StatusItemView};
@@ -292,9 +299,39 @@ impl Item for Editor {
         }
     }
 
-    fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
-        let title = self.title(cx);
-        Label::new(title, style.label.clone()).boxed()
+    fn tab_description<'a>(&'a self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
+        match path_for_buffer(&self.buffer, detail, true, cx)? {
+            Cow::Borrowed(path) => Some(path.to_string_lossy()),
+            Cow::Owned(path) => Some(path.to_string_lossy().to_string().into()),
+        }
+    }
+
+    fn tab_content(
+        &self,
+        detail: Option<usize>,
+        style: &theme::Tab,
+        cx: &AppContext,
+    ) -> ElementBox {
+        Flex::row()
+            .with_child(
+                Label::new(self.title(cx).into(), style.label.clone())
+                    .aligned()
+                    .boxed(),
+            )
+            .with_children(detail.and_then(|detail| {
+                let path = path_for_buffer(&self.buffer, detail, false, cx)?;
+                Some(
+                    Label::new(
+                        path.to_string_lossy().into(),
+                        style.description.text.clone(),
+                    )
+                    .contained()
+                    .with_style(style.description.container)
+                    .aligned()
+                    .boxed(),
+                )
+            }))
+            .boxed()
     }
 
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
@@ -534,3 +571,38 @@ impl StatusItemView for CursorPosition {
         cx.notify();
     }
 }
+
+fn path_for_buffer<'a>(
+    buffer: &ModelHandle<MultiBuffer>,
+    mut depth: usize,
+    include_filename: bool,
+    cx: &'a AppContext,
+) -> Option<Cow<'a, Path>> {
+    let file = buffer.read(cx).as_singleton()?.read(cx).file()?;
+
+    let mut path = file.path().as_ref();
+    depth += 1;
+    while depth > 0 {
+        if let Some(parent) = path.parent() {
+            path = parent;
+            depth -= 1;
+        } else {
+            break;
+        }
+    }
+
+    if depth > 0 {
+        let full_path = file.full_path(cx);
+        if include_filename {
+            Some(full_path.into())
+        } else {
+            Some(full_path.parent().unwrap().to_path_buf().into())
+        }
+    } else {
+        let mut path = file.path().strip_prefix(path).unwrap();
+        if !include_filename {
+            path = path.parent().unwrap();
+        }
+        Some(path.into())
+    }
+}

crates/editor/src/multi_buffer.rs 🔗

@@ -14,6 +14,7 @@ use language::{
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{
+    borrow::Cow,
     cell::{Ref, RefCell},
     cmp, fmt, io,
     iter::{self, FromIterator},
@@ -1194,14 +1195,14 @@ impl MultiBuffer {
             .collect()
     }
 
-    pub fn title(&self, cx: &AppContext) -> String {
-        if let Some(title) = self.title.clone() {
-            return title;
+    pub fn title<'a>(&'a self, cx: &'a AppContext) -> Cow<'a, str> {
+        if let Some(title) = self.title.as_ref() {
+            return title.into();
         }
 
         if let Some(buffer) = self.as_singleton() {
             if let Some(file) = buffer.read(cx).file() {
-                return file.file_name(cx).to_string_lossy().into();
+                return file.file_name(cx).to_string_lossy();
             }
         }
 

crates/language/src/buffer.rs 🔗

@@ -20,7 +20,7 @@ use std::{
     any::Any,
     cmp::{self, Ordering},
     collections::{BTreeMap, HashMap},
-    ffi::OsString,
+    ffi::OsStr,
     future::Future,
     iter::{self, Iterator, Peekable},
     mem,
@@ -185,7 +185,7 @@ pub trait File: Send + Sync {
 
     /// Returns the last component of this handle's absolute path. If this handle refers to the root
     /// of its worktree, then this method will return the name of the worktree itself.
-    fn file_name(&self, cx: &AppContext) -> OsString;
+    fn file_name<'a>(&'a self, cx: &'a AppContext) -> &'a OsStr;
 
     fn is_deleted(&self) -> bool;
 

crates/project/src/worktree.rs 🔗

@@ -1646,11 +1646,10 @@ impl language::File for File {
 
     /// Returns the last component of this handle's absolute path. If this handle refers to the root
     /// of its worktree, then this method will return the name of the worktree itself.
-    fn file_name(&self, cx: &AppContext) -> OsString {
+    fn file_name<'a>(&'a self, cx: &'a AppContext) -> &'a OsStr {
         self.path
             .file_name()
-            .map(|name| name.into())
-            .unwrap_or_else(|| OsString::from(&self.worktree.read(cx).root_name))
+            .unwrap_or_else(|| OsStr::new(&self.worktree.read(cx).root_name))
     }
 
     fn is_deleted(&self) -> bool {

crates/search/src/project_search.rs 🔗

@@ -220,7 +220,12 @@ impl Item for ProjectSearchView {
             .update(cx, |editor, cx| editor.deactivated(cx));
     }
 
-    fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
+    fn tab_content(
+        &self,
+        _detail: Option<usize>,
+        tab_theme: &theme::Tab,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
         let settings = cx.global::<Settings>();
         let search_theme = &settings.theme.search;
         Flex::row()

crates/terminal/src/terminal.rs 🔗

@@ -324,7 +324,12 @@ impl View for Terminal {
 }
 
 impl Item for Terminal {
-    fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
+    fn tab_content(
+        &self,
+        _detail: Option<usize>,
+        tab_theme: &theme::Tab,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
         let settings = cx.global::<Settings>();
         let search_theme = &settings.theme.search; //TODO properly integrate themes
 

crates/theme/src/theme.rs 🔗

@@ -93,6 +93,7 @@ pub struct Tab {
     pub container: ContainerStyle,
     #[serde(flatten)]
     pub label: LabelStyle,
+    pub description: ContainedText,
     pub spacing: f32,
     pub icon_width: f32,
     pub icon_close: Color,

crates/workspace/src/pane.rs 🔗

@@ -840,8 +840,10 @@ impl Pane {
             } else {
                 None
             };
+
             let mut row = Flex::row().scrollable::<Tabs, _>(1, autoscroll, cx);
-            for (ix, item) in self.items.iter().enumerate() {
+            for (ix, (item, detail)) in self.items.iter().zip(self.tab_details(cx)).enumerate() {
+                let detail = if detail == 0 { None } else { Some(detail) };
                 let is_active = ix == self.active_item_index;
 
                 row.add_child({
@@ -850,7 +852,7 @@ impl Pane {
                     } else {
                         theme.workspace.tab.clone()
                     };
-                    let title = item.tab_content(&tab_style, cx);
+                    let title = item.tab_content(detail, &tab_style, cx);
 
                     let mut style = if is_active {
                         theme.workspace.active_tab.clone()
@@ -971,6 +973,43 @@ impl Pane {
             row.boxed()
         })
     }
+
+    fn tab_details(&self, cx: &AppContext) -> Vec<usize> {
+        let mut tab_details = (0..self.items.len()).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 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
+    }
 }
 
 impl Entity for Pane {

crates/workspace/src/workspace.rs 🔗

@@ -256,7 +256,11 @@ pub trait Item: View {
     fn navigate(&mut self, _: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
         false
     }
-    fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
+    fn tab_description<'a>(&'a self, _: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
+        None
+    }
+    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
+        -> ElementBox;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
     fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
     fn is_singleton(&self, cx: &AppContext) -> bool;
@@ -409,7 +413,9 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 }
 
 pub trait ItemHandle: 'static + fmt::Debug {
-    fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
+    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>>;
+    fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
+        -> ElementBox;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
     fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
     fn is_singleton(&self, cx: &AppContext) -> bool;
@@ -463,8 +469,17 @@ impl dyn ItemHandle {
 }
 
 impl<T: Item> ItemHandle for ViewHandle<T> {
-    fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
-        self.read(cx).tab_content(style, cx)
+    fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
+        self.read(cx).tab_description(detail, cx)
+    }
+
+    fn tab_content(
+        &self,
+        detail: Option<usize>,
+        style: &theme::Tab,
+        cx: &AppContext,
+    ) -> ElementBox {
+        self.read(cx).tab_content(detail, style, cx)
     }
 
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
@@ -3277,7 +3292,7 @@ mod tests {
     }
 
     impl Item for TestItem {
-        fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
+        fn tab_content(&self, _: Option<usize>, _: &theme::Tab, _: &AppContext) -> ElementBox {
             Empty::new().boxed()
         }
 

styles/src/styleTree/workspace.ts 🔗

@@ -27,6 +27,10 @@ export default function workspace(theme: Theme) {
       left: 8,
       right: 8,
     },
+    description: {
+      margin: { left: 6, top: 1 },
+      ...text(theme, "sans", "muted", { size: "2xs" })
+    }
   };
 
   const activeTab = {