diff --git a/Cargo.lock b/Cargo.lock index cc77ab6e8e..070a039ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5178,8 +5178,10 @@ dependencies = [ "anyhow", "collections", "db", + "editor", "git", "gpui", + "language", "menu", "project", "schemars", @@ -5187,6 +5189,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "theme", "ui", "util", "windows 0.58.0", diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index a1ac4881b0..120ca92857 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -16,8 +16,10 @@ path = "src/git_ui.rs" anyhow.workspace = true collections.workspace = true db.workspace = true +editor.workspace = true git.workspace = true gpui.workspace = true +language.workspace = true menu.workspace = true project.workspace = true schemars.workspace = true @@ -25,6 +27,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 08f37d0a42..e57145f988 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,18 +1,16 @@ -use crate::{git_status_icon, settings::GitPanelSettings}; -use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll}; -use anyhow::Result; +use crate::{ + git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitStagedChanges, GitState, + RevertAll, StageAll, UnstageAll, +}; +use anyhow::{Context as _, Result}; use db::kvp::KEY_VALUE_STORE; +use editor::Editor; use git::{ diff::DiffHunk, repository::{GitFileStatus, RepoPath}, }; use gpui::*; -use gpui::{ - actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent, - CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext, - ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent, - MouseButton, ScrollStrategy, Stateful, Task, UniformListScrollHandle, View, WeakView, -}; +use language::Buffer; use menu::{SelectNext, SelectPrev}; use project::{EntryKind, Fs, Project, ProjectEntryId, WorktreeId}; use serde::{Deserialize, Serialize}; @@ -28,9 +26,9 @@ use std::{ time::Duration, usize, }; +use theme::ThemeSettings; use ui::{ - prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, Scrollbar, - ScrollbarState, Tooltip, + prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -39,7 +37,7 @@ use workspace::{ }; use worktree::StatusEntry; -actions!(git_panel, [ToggleFocus]); +actions!(git_panel, [ToggleFocus, OpenEntryMenu]); const GIT_PANEL_KEY: &str = "GitPanel"; @@ -61,6 +59,13 @@ pub enum Event { Focus, } +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub enum ViewMode { + #[default] + List, + Tree, +} + pub struct GitStatusEntry {} #[derive(Debug, PartialEq, Eq, Clone)] @@ -76,12 +81,6 @@ struct EntryDetails { index: usize, } -impl EntryDetails { - pub fn is_dir(&self) -> bool { - self.kind.is_dir() - } -} - #[derive(Serialize, Deserialize)] struct SerializedGitPanel { width: Option, @@ -98,10 +97,12 @@ pub struct GitPanel { scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, selected_item: Option, + view_mode: ViewMode, show_scrollbar: bool, // TODO Reintroduce expanded directories, once we're deriving directories from paths // expanded_dir_ids: HashMap>, - + git_state: Model, + commit_editor: View, // The entries that are currently shown in the panel, aka // not hidden by folding or such visible_entries: Vec, @@ -154,9 +155,12 @@ impl GitPanel { } pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { + let git_state = GitState::get_global(cx); + let fs = workspace.app_state().fs.clone(); // let weak_workspace = workspace.weak_handle(); let project = workspace.project().clone(); + let language_registry = workspace.app_state().languages.clone(); let git_panel = cx.new_view(|cx: &mut ViewContext| { let focus_handle = cx.focus_handle(); @@ -192,6 +196,58 @@ impl GitPanel { }) .detach(); + let state = git_state.read(cx); + let current_commit_message = state.commit_message.clone(); + + let commit_editor = cx.new_view(|cx| { + let theme = ThemeSettings::get_global(cx); + + let mut text_style = cx.text_style(); + let refinement = TextStyleRefinement { + font_family: Some(theme.buffer_font.family.clone()), + font_features: Some(FontFeatures::disable_ligatures()), + font_size: Some(px(12.).into()), + color: Some(cx.theme().colors().editor_foreground), + background_color: Some(gpui::transparent_black()), + ..Default::default() + }; + + text_style.refine(&refinement); + + let mut commit_editor = Editor::auto_height(10, cx); + if let Some(message) = current_commit_message { + commit_editor.set_text(message, cx); + } else { + commit_editor.set_text("", cx); + } + // commit_editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + commit_editor.set_use_autoclose(false); + commit_editor.set_show_gutter(false, cx); + commit_editor.set_show_wrap_guides(false, cx); + commit_editor.set_show_indent_guides(false, cx); + commit_editor.set_text_style_refinement(refinement); + commit_editor.set_placeholder_text("Enter commit message", cx); + commit_editor + }); + + let buffer = commit_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("commit editor must be singleton"); + + cx.subscribe(&buffer, Self::on_buffer_event).detach(); + + let markdown = language_registry.language_for_name("Markdown"); + cx.spawn(|_, mut cx| async move { + let markdown = markdown.await.context("failed to load Markdown language")?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(markdown), cx) + }) + }) + .detach_and_log_err(cx); + let scroll_handle = UniformListScrollHandle::new(); let mut git_panel = Self { @@ -206,10 +262,13 @@ impl GitPanel { scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()), scroll_handle, selected_item: None, + view_mode: ViewMode::default(), show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, // git_diff_editor: Some(diff_display_editor(cx)), // git_diff_editor_updates: Task::ready(()), + commit_editor, + git_state, reveal_in_editor: Task::ready(()), project, }; @@ -403,19 +462,30 @@ impl GitPanel { println!("Unstage all triggered"); } - fn discard_all(&mut self, _: &DiscardAll, _cx: &mut ViewContext) { + fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext) { // TODO: Implement discard all println!("Discard all triggered"); } + fn clear_message(&mut self, cx: &mut ViewContext) { + let git_state = self.git_state.clone(); + git_state.update(cx, |state, _cx| state.clear_message()); + self.commit_editor + .update(cx, |editor, cx| editor.set_text("", cx)); + } + /// Commit all staged changes - fn commit_staged_changes(&mut self, _: &CommitStagedChanges, _cx: &mut ViewContext) { + fn commit_staged_changes(&mut self, _: &CommitStagedChanges, cx: &mut ViewContext) { + self.clear_message(cx); + // TODO: Implement commit all staged println!("Commit staged changes triggered"); } /// Commit all changes, regardless of whether they are staged or not - fn commit_all_changes(&mut self, _: &CommitAllChanges, _cx: &mut ViewContext) { + fn commit_all_changes(&mut self, _: &CommitAllChanges, cx: &mut ViewContext) { + self.clear_message(cx); + // TODO: Implement commit all changes println!("Commit all changes triggered"); } @@ -771,6 +841,23 @@ impl GitPanel { cx.notify(); } + + fn on_buffer_event( + &mut self, + _buffer: Model, + event: &language::BufferEvent, + cx: &mut ViewContext, + ) { + if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event { + let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx)); + + self.git_state.update(cx, |state, _cx| { + state.commit_message = Some(commit_message.into()); + }); + + cx.notify(); + } + } } impl GitPanel { @@ -799,7 +886,11 @@ impl GitPanel { pub fn render_panel_header(&self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle(cx).clone(); - let changes_string = format!("{} changes", self.entry_count()); + let changes_string = match self.entry_count() { + 0 => "No changes".to_string(), + 1 => "1 change".to_string(), + n => format!("{} changes", n), + }; h_flex() .h(px(32.)) @@ -823,7 +914,7 @@ impl GitPanel { Tooltip::for_action_in( "Discard all changes", - &DiscardAll, + &RevertAll, &focus_handle, cx, ) @@ -833,7 +924,7 @@ impl GitPanel { ) .child(if self.all_staged() { self.panel_button("unstage-all", "Unstage All").on_click( - cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(DiscardAll))), + cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(RevertAll))), ) } else { self.panel_button("stage-all", "Stage All").on_click( @@ -844,6 +935,9 @@ impl GitPanel { } pub fn render_commit_editor(&self, cx: &ViewContext) -> impl IntoElement { + let editor = self.commit_editor.clone(); + let editor_focus_handle = editor.read(cx).focus_handle(cx).clone(); + let focus_handle_1 = self.focus_handle(cx).clone(); let focus_handle_2 = self.focus_handle(cx).clone(); @@ -879,25 +973,26 @@ impl GitPanel { div().w_full().h(px(140.)).px_2().pt_1().pb_2().child( v_flex() + .id("commit-editor-container") + .relative() .h_full() .py_2p5() .px_3() .bg(cx.theme().colors().editor_background) - .font_buffer(cx) - .text_ui_sm(cx) - .text_color(cx.theme().colors().text_muted) - .child("Add a message") - .gap_1() - .child(div().flex_grow()) - .child(h_flex().child(div().gap_1().flex_grow()).child( - if self.current_modifiers.alt { - commit_all_button - } else { - commit_staged_button - }, - )) - .cursor(CursorStyle::OperationNotAllowed) - .opacity(0.5), + .on_click(cx.listener(move |_, _: &ClickEvent, cx| cx.focus(&editor_focus_handle))) + .child(self.commit_editor.clone()) + .child( + h_flex() + .absolute() + .bottom_2p5() + .right_3() + .child(div().gap_1().flex_grow()) + .child(if self.current_modifiers.alt { + commit_all_button + } else { + commit_staged_button + }), + ), ) } @@ -1008,45 +1103,84 @@ impl GitPanel { details: EntryDetails, cx: &ViewContext, ) -> impl IntoElement { + let view_mode = self.view_mode.clone(); let checkbox_id = ElementId::Name(format!("checkbox_{}", ix).into()); let is_staged = ToggleState::Selected; let handle = cx.view().downgrade(); - h_flex() + // TODO: At this point, an entry should really have a status. + // Is this fixed with the new git status stuff? + let status = details.status.unwrap_or(GitFileStatus::Untracked); + + let end_slot = h_flex() + .invisible() + .when(selected, |this| this.visible()) + .when(!selected, |this| { + this.group_hover("git-panel-entry", |this| this.visible()) + }) + .gap_1() + .items_center() + .child( + IconButton::new("more", IconName::EllipsisVertical) + .icon_color(Color::Placeholder) + .icon_size(IconSize::Small), + ); + + let mut entry = h_flex() .id(("git-panel-entry", ix)) + .group("git-panel-entry") .h(px(28.)) .w_full() - .pl(px(12. + 12. * details.depth as f32)) .pr(px(4.)) .items_center() .gap_2() .font_buffer(cx) .text_ui_sm(cx) - .when(!details.is_dir(), |this| { - this.child(Checkbox::new(checkbox_id, is_staged)) - }) - .when_some(details.status, |this, status| { - this.child(git_status_icon(status)) - }) + .when(!selected, |this| { + this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) + }); + + if view_mode == ViewMode::Tree { + entry = entry.pl(px(12. + 12. * details.depth as f32)) + } else { + entry = entry.pl(px(12.)) + } + + if selected { + entry = entry.bg(cx.theme().status().info_background); + } + + entry = entry + .child(Checkbox::new(checkbox_id, is_staged)) + .child(git_status_icon(status)) .child( - ListItem::new(details.path.0.clone()) - .toggle_state(selected) - .child(h_flex().gap_1p5().child(details.display_name.clone())) - .on_click(move |e, cx| { - handle - .update(cx, |git_panel, cx| { - git_panel.selected_item = Some(details.index); - let change_focus = e.down.click_count > 1; - git_panel.reveal_entry_in_git_editor( - details.hunks.clone(), - change_focus, - None, - cx, - ); - }) - .ok(); - }), + h_flex() + .gap_1p5() + .when(status == GitFileStatus::Deleted, |this| { + this.text_color(cx.theme().colors().text_disabled) + .line_through() + }) + .child(details.display_name.clone()), ) + .child(div().flex_1()) + .child(end_slot) + // TODO: Only fire this if the entry is not currently revealed, otherwise the ui flashes + .on_click(move |e, cx| { + handle + .update(cx, |git_panel, cx| { + git_panel.selected_item = Some(details.index); + let change_focus = e.down.click_count > 1; + git_panel.reveal_entry_in_git_editor( + details.hunks.clone(), + change_focus, + None, + cx, + ); + }) + .ok(); + }); + + entry } fn reveal_entry_in_git_editor( @@ -1156,9 +1290,7 @@ impl Render for GitPanel { .on_action( cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)), ) - .on_action( - cx.listener(|this, &DiscardAll, cx| this.discard_all(&DiscardAll, cx)), - ) + .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) .on_action(cx.listener(|this, &CommitStagedChanges, cx| { this.commit_staged_changes(&CommitStagedChanges, cx) })) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index c1c3bd3ac0..89a47d884c 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,8 +1,8 @@ use ::settings::Settings; use git::repository::GitFileStatus; -use gpui::{actions, AppContext, Hsla}; +use gpui::{actions, AppContext, Context, Global, Hsla, Model}; use settings::GitPanelSettings; -use ui::{Color, Icon, IconName, IntoElement}; +use ui::{Color, Icon, IconName, IntoElement, SharedString}; pub mod git_panel; mod settings; @@ -12,14 +12,45 @@ actions!( [ StageAll, UnstageAll, - DiscardAll, + RevertAll, CommitStagedChanges, - CommitAllChanges + CommitAllChanges, + ClearMessage ] ); pub fn init(cx: &mut AppContext) { GitPanelSettings::register(cx); + let git_state = cx.new_model(|_cx| GitState::new()); + cx.set_global(GlobalGitState(git_state)); +} + +struct GlobalGitState(Model); + +impl Global for GlobalGitState {} + +pub struct GitState { + commit_message: Option, +} + +impl GitState { + pub fn new() -> Self { + GitState { + commit_message: None, + } + } + + pub fn set_message(&mut self, message: Option) { + self.commit_message = message; + } + + pub fn clear_message(&mut self) { + self.commit_message = None; + } + + pub fn get_global(cx: &mut AppContext) -> Model { + cx.global::().0.clone() + } } const ADDED_COLOR: Hsla = Hsla { @@ -51,6 +82,8 @@ pub fn git_status_icon(status: GitFileStatus) -> impl IntoElement { Icon::new(IconName::SquareDot).color(Color::Custom(MODIFIED_COLOR)) } GitFileStatus::Conflict => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)), - GitFileStatus::Deleted => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)), + GitFileStatus::Deleted => { + Icon::new(IconName::SquareMinus).color(Color::Custom(REMOVED_COLOR)) + } } }