Initial git panel refinements (#21912)

Nate Butler created

- Wire up settings
- Update static Panel impl
- Tidy up renders

Release Notes:

- N/A

Change summary

Cargo.lock                     |   8 ++
assets/settings/default.json   |   8 ++
crates/git_ui/Cargo.toml       |  10 +++
crates/git_ui/src/git_panel.rs | 100 ++++++++++++++++++++++++++++-------
crates/git_ui/src/git_ui.rs    |   9 +++
crates/git_ui/src/settings.rs  |  41 ++++++++++++++
crates/zed/src/main.rs         |   1 
crates/zed/src/zed.rs          |   1 
8 files changed, 157 insertions(+), 21 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5174,9 +5174,17 @@ dependencies = [
 name = "git_ui"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
+ "db",
  "gpui",
+ "project",
+ "schemars",
  "serde",
+ "serde_derive",
+ "serde_json",
+ "settings",
  "ui",
+ "util",
  "windows 0.58.0",
  "workspace",
 ]

assets/settings/default.json 🔗

@@ -471,6 +471,14 @@
     // Default width of the chat panel.
     "default_width": 240
   },
+  "git_panel": {
+    // Whether to show the git panel button in the status bar.
+    "button": true,
+    // Where to the git panel. Can be 'left' or 'right'.
+    "dock": "left",
+    // Default width of the git panel.
+    "default_width": 360
+  },
   "message_editor": {
     // Whether to automatically replace emoji shortcodes with emoji characters.
     // For example: typing `:wave:` gets replaced with `👋`.

crates/git_ui/Cargo.toml 🔗

@@ -13,10 +13,18 @@ name = "git_ui"
 path = "src/git_ui.rs"
 
 [dependencies]
+anyhow.workspace = true
+db.workspace = true
 gpui.workspace = true
+project.workspace = true
+schemars.workspace = true
 serde.workspace = true
-workspace.workspace = true
+serde_derive.workspace = true
+serde_json.workspace = true
+settings.workspace = true
 ui.workspace = true
+util.workspace = true
+workspace.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,8 +1,19 @@
+use std::sync::Arc;
+use util::TryFutureExt;
+
+use db::kvp::KEY_VALUE_STORE;
 use gpui::*;
+use project::Fs;
+use serde::{Deserialize, Serialize};
+use settings::Settings as _;
 use ui::{prelude::*, Checkbox, Divider, DividerColor, ElevationIndex};
 use workspace::dock::{DockPosition, Panel, PanelEvent};
 use workspace::Workspace;
 
+use crate::settings::GitPanelSettings;
+
+const GIT_PANEL_KEY: &str = "GitPanel";
+
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(
         |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
@@ -14,11 +25,17 @@ pub fn init(cx: &mut AppContext) {
     .detach();
 }
 
+#[derive(Serialize, Deserialize)]
+struct SerializedGitPanel {
+    width: Option<Pixels>,
+}
+
 actions!(git_panel, [Deploy, ToggleFocus]);
 
-#[derive(Clone)]
 pub struct GitPanel {
     _workspace: WeakView<Workspace>,
+    pending_serialization: Task<Option<()>>,
+    fs: Arc<dyn Fs>,
     focus_handle: FocusHandle,
     width: Option<Pixels>,
 }
@@ -29,20 +46,39 @@ impl GitPanel {
         cx: AsyncWindowContext,
     ) -> Task<Result<View<Self>>> {
         cx.spawn(|mut cx| async move {
-            workspace.update(&mut cx, |workspace, cx| {
-                let workspace_handle = workspace.weak_handle();
-
-                cx.new_view(|cx| Self::new(workspace_handle, cx))
-            })
+            // Clippy incorrectly classifies this as a redundant closure
+            #[allow(clippy::redundant_closure)]
+            workspace.update(&mut cx, |workspace, cx| Self::new(workspace, cx))
         })
     }
 
-    pub fn new(workspace: WeakView<Workspace>, cx: &mut ViewContext<Self>) -> Self {
-        Self {
-            _workspace: workspace,
+    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
+        let fs = workspace.app_state().fs.clone();
+        let weak_workspace = workspace.weak_handle();
+
+        cx.new_view(|cx| Self {
+            fs,
+            _workspace: weak_workspace,
+            pending_serialization: Task::ready(None),
             focus_handle: cx.focus_handle(),
             width: Some(px(360.)),
-        }
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background_executor().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        GIT_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedGitPanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
     }
 
     pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -53,14 +89,14 @@ impl GitPanel {
             .bg(ElevationIndex::Surface.bg(cx))
             .child(
                 h_flex()
-                    .gap_1()
+                    .gap_2()
                     .child(Checkbox::new("all-changes", true.into()).disabled(true))
                     .child(div().text_buffer(cx).text_ui_sm(cx).child("0 changes")),
             )
             .child(div().flex_grow())
             .child(
                 h_flex()
-                    .gap_1()
+                    .gap_2()
                     .child(
                         IconButton::new("discard-changes", IconName::Undo)
                             .icon_size(IconSize::Small)
@@ -104,6 +140,22 @@ impl GitPanel {
                 .opacity(0.5),
         )
     }
+
+    fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
+        h_flex()
+            .h_full()
+            .flex_1()
+            .justify_center()
+            .items_center()
+            .child(
+                v_flex()
+                    .gap_3()
+                    .child("No changes to commit")
+                    .text_ui_sm(cx)
+                    .mx_auto()
+                    .text_color(Color::Placeholder.color(cx)),
+            )
+    }
 }
 
 impl Render for GitPanel {
@@ -124,7 +176,7 @@ impl Render for GitPanel {
                     .h(px(8.))
                     .child(Divider::horizontal_dashed().color(DividerColor::Border)),
             )
-            .child(div().flex_1())
+            .child(self.render_empty_state(cx))
             .child(
                 h_flex()
                     .items_center()
@@ -148,27 +200,35 @@ impl Panel for GitPanel {
         "GitPanel"
     }
 
-    fn position(&self, _cx: &gpui::WindowContext) -> DockPosition {
-        DockPosition::Left
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        GitPanelSettings::get_global(cx).dock
     }
 
     fn position_is_valid(&self, position: DockPosition) -> bool {
         matches!(position, DockPosition::Left | DockPosition::Right)
     }
 
-    fn set_position(&mut self, _position: DockPosition, _cx: &mut ViewContext<Self>) {}
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<GitPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings, _| settings.dock = Some(position),
+        );
+    }
 
-    fn size(&self, _cx: &gpui::WindowContext) -> Pixels {
-        self.width.unwrap_or(px(360.))
+    fn size(&self, cx: &gpui::WindowContext) -> Pixels {
+        self.width
+            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
     }
 
     fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
         self.width = size;
+        self.serialize(cx);
         cx.notify();
     }
 
-    fn icon(&self, _cx: &gpui::WindowContext) -> Option<ui::IconName> {
-        Some(ui::IconName::GitBranch)
+    fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
+        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
     }
 
     fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {

crates/git_ui/src/git_ui.rs 🔗

@@ -1 +1,10 @@
+use ::settings::Settings;
+use gpui::AppContext;
+use settings::GitPanelSettings;
+
 pub mod git_panel;
+mod settings;
+
+pub fn init(cx: &mut AppContext) {
+    GitPanelSettings::register(cx);
+}

crates/git_ui/src/settings.rs 🔗

@@ -0,0 +1,41 @@
+use gpui::Pixels;
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+use workspace::dock::DockPosition;
+
+#[derive(Deserialize, Debug)]
+pub struct GitPanelSettings {
+    pub button: bool,
+    pub dock: DockPosition,
+    pub default_width: Pixels,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct PanelSettingsContent {
+    /// Whether to show the panel button in the status bar.
+    ///
+    /// Default: true
+    pub button: Option<bool>,
+    /// Where to dock the panel.
+    ///
+    /// Default: left
+    pub dock: Option<DockPosition>,
+    /// Default width of the panel in pixels.
+    ///
+    /// Default: 360
+    pub default_width: Option<f32>,
+}
+
+impl Settings for GitPanelSettings {
+    const KEY: Option<&'static str> = Some("git_panel");
+
+    type FileContent = PanelSettingsContent;
+
+    fn load(
+        sources: SettingsSources<Self::FileContent>,
+        _: &mut gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        sources.json_merge()
+    }
+}

crates/zed/src/main.rs 🔗

@@ -463,6 +463,7 @@ fn main() {
         call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
+        git_ui::init(cx);
         vcs_menu::init(cx);
         feedback::init(cx);
         markdown_preview::init(cx);

crates/zed/src/zed.rs 🔗

@@ -3477,6 +3477,7 @@ mod tests {
             language::init(cx);
             editor::init(cx);
             collab_ui::init(&app_state, cx);
+            git_ui::init(cx);
             project_panel::init((), cx);
             outline_panel::init((), cx);
             terminal_view::init(cx);