feat(workspace): show git status on editor tabs (#2736)

Mikayla Maki created

Fixes https://github.com/zed-industries/community/issues/1674

Release Notes:

- Added option for showing git status on editor tabs

Change summary

assets/settings/default.json      |  4 ++++
crates/theme/src/theme.rs         |  5 +++--
crates/workspace/src/item.rs      | 27 +++++++++++++++++++++++++++
crates/workspace/src/pane.rs      | 30 +++++++++++++++++++++++++-----
crates/workspace/src/workspace.rs |  1 +
styles/src/style_tree/tab_bar.ts  | 14 ++++++++++++++
6 files changed, 74 insertions(+), 7 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -128,6 +128,10 @@
   // 4. Save when idle for a certain amount of time:
   //     "autosave": { "after_delay": {"milliseconds": 500} },
   "autosave": "off",
+  // Color tab titles based on the git status of the buffer.
+  "tabs": {
+    "git_status": false
+  },
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.
   "remove_trailing_whitespace_on_save": true,

crates/theme/src/theme.rs 🔗

@@ -350,6 +350,7 @@ pub struct Tab {
     pub icon_close_active: Color,
     pub icon_dirty: Color,
     pub icon_conflict: Color,
+    pub git: GitProjectStatus,
 }
 
 #[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -722,12 +723,12 @@ pub struct Scrollbar {
     pub thumb: ContainerStyle,
     pub width: f32,
     pub min_height_factor: f32,
-    pub git: GitDiffColors,
+    pub git: FileGitDiffColors,
     pub selections: Color,
 }
 
 #[derive(Clone, Deserialize, Default, JsonSchema)]
-pub struct GitDiffColors {
+pub struct FileGitDiffColors {
     pub inserted: Color,
     pub modified: Color,
     pub deleted: Color,

crates/workspace/src/item.rs 🔗

@@ -10,6 +10,9 @@ use gpui::{
     ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use project::{Project, ProjectEntryId, ProjectPath};
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::Setting;
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -27,6 +30,30 @@ use std::{
 };
 use theme::Theme;
 
+#[derive(Deserialize)]
+pub struct ItemSettings {
+    pub git_status: bool,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ItemSettingsContent {
+    git_status: Option<bool>,
+}
+
+impl Setting for ItemSettings {
+    const KEY: Option<&'static str> = Some("tabs");
+
+    type FileContent = ItemSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
 #[derive(Eq, PartialEq, Hash, Debug)]
 pub enum ItemEvent {
     CloseItem,

crates/workspace/src/pane.rs 🔗

@@ -3,14 +3,16 @@ mod dragged_item_receiver;
 use super::{ItemHandle, SplitDirection};
 pub use crate::toolbar::Toolbar;
 use crate::{
-    item::WeakItemHandle, notify_of_new_dock, AutosaveSetting, Item, NewCenterTerminal, NewFile,
-    NewSearch, ToggleZoom, Workspace, WorkspaceSettings,
+    item::{ItemSettings, WeakItemHandle},
+    notify_of_new_dock, AutosaveSetting, Item, NewCenterTerminal, NewFile, NewSearch, ToggleZoom,
+    Workspace, WorkspaceSettings,
 };
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
 use context_menu::{ContextMenu, ContextMenuItem};
 use drag_and_drop::{DragAndDrop, Draggable};
 use dragged_item_receiver::dragged_item_receiver;
+use fs::repository::GitFileStatus;
 use futures::StreamExt;
 use gpui::{
     actions,
@@ -866,6 +868,7 @@ impl Pane {
                 .paths_by_item
                 .get(&item.id())
                 .and_then(|(_, abs_path)| abs_path.clone());
+
             self.nav_history
                 .0
                 .borrow_mut()
@@ -1157,6 +1160,11 @@ impl Pane {
             .zip(self.tab_details(cx))
             .enumerate()
         {
+            let git_status = item
+                .project_path(cx)
+                .and_then(|path| self.project.read(cx).entry_for_path(&path, cx))
+                .and_then(|entry| entry.git_status());
+
             let detail = if detail == 0 { None } else { Some(detail) };
             let tab_active = ix == self.active_item_index;
 
@@ -1174,9 +1182,21 @@ impl Pane {
                         let tab_tooltip_text =
                             item.tab_tooltip_text(cx).map(|text| text.into_owned());
 
+                        let mut tab_style = theme
+                            .workspace
+                            .tab_bar
+                            .tab_style(pane_active, tab_active)
+                            .clone();
+                        let should_show_status = settings::get::<ItemSettings>(cx).git_status;
+                        if should_show_status && git_status != None {
+                            tab_style.label.text.color = match git_status.unwrap() {
+                                GitFileStatus::Added => tab_style.git.inserted,
+                                GitFileStatus::Modified => tab_style.git.modified,
+                                GitFileStatus::Conflict => tab_style.git.conflict,
+                            };
+                        }
+
                         move |mouse_state, cx| {
-                            let tab_style =
-                                theme.workspace.tab_bar.tab_style(pane_active, tab_active);
                             let hovered = mouse_state.hovered();
 
                             enum Tab {}
@@ -1188,7 +1208,7 @@ impl Pane {
                                         ix == 0,
                                         detail,
                                         hovered,
-                                        tab_style,
+                                        &tab_style,
                                         cx,
                                     )
                                 })

crates/workspace/src/workspace.rs 🔗

@@ -203,6 +203,7 @@ pub type WorkspaceId = i64;
 
 pub fn init_settings(cx: &mut AppContext) {
     settings::register::<WorkspaceSettings>(cx);
+    settings::register::<item::ItemSettings>(cx);
 }
 
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {

styles/src/style_tree/tab_bar.ts 🔗

@@ -6,6 +6,8 @@ import { useTheme } from "../common"
 export default function tab_bar(): any {
     const theme = useTheme()
 
+    const { is_light } = theme
+
     const height = 32
 
     const active_layer = theme.highest
@@ -38,6 +40,18 @@ export default function tab_bar(): any {
         icon_conflict: foreground(layer, "warning"),
         icon_dirty: foreground(layer, "accent"),
 
+        git: {
+            modified: is_light
+                ? theme.ramps.yellow(0.6).hex()
+                : theme.ramps.yellow(0.5).hex(),
+            inserted: is_light
+                ? theme.ramps.green(0.45).hex()
+                : theme.ramps.green(0.5).hex(),
+            conflict: is_light
+                ? theme.ramps.red(0.6).hex()
+                : theme.ramps.red(0.5).hex(),
+        },
+
         // When two tabs of the same name are open, a label appears next to them
         description: {
             margin: { left: 8 },