git_ui.rs

  1use ::settings::Settings;
  2use collections::HashMap;
  3use futures::channel::mpsc;
  4use futures::StreamExt as _;
  5use git::repository::{GitFileStatus, GitRepository, RepoPath};
  6use git_panel_settings::GitPanelSettings;
  7use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext};
  8use project::{Project, WorktreeId};
  9use std::sync::Arc;
 10use sum_tree::SumTree;
 11use ui::{Color, Icon, IconName, IntoElement, SharedString};
 12use util::ResultExt as _;
 13use worktree::RepositoryEntry;
 14
 15pub mod git_panel;
 16mod git_panel_settings;
 17
 18actions!(
 19    git,
 20    [
 21        StageFile,
 22        UnstageFile,
 23        ToggleStaged,
 24        // Revert actions are currently in the editor crate:
 25        // editor::RevertFile,
 26        // editor::RevertSelectedHunks
 27        StageAll,
 28        UnstageAll,
 29        RevertAll,
 30        CommitChanges,
 31        CommitAllChanges,
 32        ClearCommitMessage
 33    ]
 34);
 35
 36pub fn init(cx: &mut AppContext) {
 37    GitPanelSettings::register(cx);
 38    let git_state = cx.new_model(GitState::new);
 39    cx.set_global(GlobalGitState(git_state));
 40}
 41
 42#[derive(Default, Debug, PartialEq, Eq, Clone)]
 43pub enum GitViewMode {
 44    #[default]
 45    List,
 46    Tree,
 47}
 48
 49struct GlobalGitState(Model<GitState>);
 50
 51impl Global for GlobalGitState {}
 52
 53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 54enum StatusAction {
 55    Stage,
 56    Unstage,
 57}
 58
 59pub struct GitState {
 60    /// The current commit message being composed.
 61    commit_message: Option<SharedString>,
 62
 63    /// When a git repository is selected, this is used to track which repository's changes
 64    /// are currently being viewed or modified in the UI.
 65    active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
 66
 67    updater_tx: mpsc::UnboundedSender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
 68
 69    all_repositories: HashMap<WorktreeId, SumTree<RepositoryEntry>>,
 70
 71    list_view_mode: GitViewMode,
 72}
 73
 74impl GitState {
 75    pub fn new(cx: &mut ModelContext<'_, Self>) -> Self {
 76        let (updater_tx, mut updater_rx) =
 77            mpsc::unbounded::<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>();
 78        cx.spawn(|_, cx| async move {
 79            while let Some((git_repo, paths, action)) = updater_rx.next().await {
 80                cx.background_executor()
 81                    .spawn(async move {
 82                        match action {
 83                            StatusAction::Stage => git_repo.stage_paths(&paths),
 84                            StatusAction::Unstage => git_repo.unstage_paths(&paths),
 85                        }
 86                    })
 87                    .await
 88                    .log_err();
 89            }
 90        })
 91        .detach();
 92        GitState {
 93            commit_message: None,
 94            active_repository: None,
 95            updater_tx,
 96            list_view_mode: GitViewMode::default(),
 97            all_repositories: HashMap::default(),
 98        }
 99    }
100
101    pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
102        cx.global::<GlobalGitState>().0.clone()
103    }
104
105    pub fn activate_repository(
106        &mut self,
107        worktree_id: WorktreeId,
108        active_repository: RepositoryEntry,
109        git_repo: Arc<dyn GitRepository>,
110    ) {
111        self.active_repository = Some((worktree_id, active_repository, git_repo));
112    }
113
114    pub fn active_repository(
115        &self,
116    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
117        self.active_repository.as_ref()
118    }
119
120    pub fn commit_message(&mut self, message: Option<SharedString>) {
121        self.commit_message = message;
122    }
123
124    pub fn clear_commit_message(&mut self) {
125        self.commit_message = None;
126    }
127
128    pub fn stage_entry(&mut self, repo_path: RepoPath) {
129        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
130            let _ = self.updater_tx.unbounded_send((
131                git_repo.clone(),
132                vec![repo_path],
133                StatusAction::Stage,
134            ));
135        }
136    }
137
138    pub fn unstage_entry(&mut self, repo_path: RepoPath) {
139        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
140            let _ = self.updater_tx.unbounded_send((
141                git_repo.clone(),
142                vec![repo_path],
143                StatusAction::Unstage,
144            ));
145        }
146    }
147
148    pub fn stage_entries(&mut self, entries: Vec<RepoPath>) {
149        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
150            let _ =
151                self.updater_tx
152                    .unbounded_send((git_repo.clone(), entries, StatusAction::Stage));
153        }
154    }
155
156    fn act_on_all(&mut self, action: StatusAction) {
157        if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() {
158            let _ = self.updater_tx.unbounded_send((
159                git_repo.clone(),
160                active_repository
161                    .status()
162                    .map(|entry| entry.repo_path)
163                    .collect(),
164                action,
165            ));
166        }
167    }
168
169    pub fn stage_all(&mut self) {
170        self.act_on_all(StatusAction::Stage);
171    }
172
173    pub fn unstage_all(&mut self) {
174        self.act_on_all(StatusAction::Unstage);
175    }
176}
177
178pub fn first_worktree_repository(
179    project: &Model<Project>,
180    worktree_id: WorktreeId,
181    cx: &mut AppContext,
182) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
183    project
184        .read(cx)
185        .worktree_for_id(worktree_id, cx)
186        .and_then(|worktree| {
187            let snapshot = worktree.read(cx).snapshot();
188            let repo = snapshot.repositories().iter().next()?.clone();
189            let git_repo = worktree
190                .read(cx)
191                .as_local()?
192                .get_local_repo(&repo)?
193                .repo()
194                .clone();
195            Some((repo, git_repo))
196        })
197}
198
199pub fn first_repository_in_project(
200    project: &Model<Project>,
201    cx: &mut AppContext,
202) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
203    project.read(cx).worktrees(cx).next().and_then(|worktree| {
204        let snapshot = worktree.read(cx).snapshot();
205        let repo = snapshot.repositories().iter().next()?.clone();
206        let git_repo = worktree
207            .read(cx)
208            .as_local()?
209            .get_local_repo(&repo)?
210            .repo()
211            .clone();
212        Some((snapshot.id(), repo, git_repo))
213    })
214}
215
216const ADDED_COLOR: Hsla = Hsla {
217    h: 142. / 360.,
218    s: 0.68,
219    l: 0.45,
220    a: 1.0,
221};
222const MODIFIED_COLOR: Hsla = Hsla {
223    h: 48. / 360.,
224    s: 0.76,
225    l: 0.47,
226    a: 1.0,
227};
228const REMOVED_COLOR: Hsla = Hsla {
229    h: 355. / 360.,
230    s: 0.65,
231    l: 0.65,
232    a: 1.0,
233};
234
235// TODO: Add updated status colors to theme
236pub fn git_status_icon(status: GitFileStatus) -> impl IntoElement {
237    match status {
238        GitFileStatus::Added | GitFileStatus::Untracked => {
239            Icon::new(IconName::SquarePlus).color(Color::Custom(ADDED_COLOR))
240        }
241        GitFileStatus::Modified => {
242            Icon::new(IconName::SquareDot).color(Color::Custom(MODIFIED_COLOR))
243        }
244        GitFileStatus::Conflict => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)),
245        GitFileStatus::Deleted => {
246            Icon::new(IconName::SquareMinus).color(Color::Custom(REMOVED_COLOR))
247        }
248    }
249}