git_ui.rs

  1use ::settings::Settings;
  2use collections::HashMap;
  3use futures::{future::FusedFuture, select, FutureExt};
  4use git::repository::{GitFileStatus, GitRepository, RepoPath};
  5use git_panel_settings::GitPanelSettings;
  6use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext};
  7use project::{Project, WorktreeId};
  8use std::sync::mpsc;
  9use std::{
 10    pin::{pin, Pin},
 11    sync::Arc,
 12    time::Duration,
 13};
 14use sum_tree::SumTree;
 15use ui::{Color, Icon, IconName, IntoElement, SharedString};
 16use worktree::RepositoryEntry;
 17
 18pub mod git_panel;
 19mod git_panel_settings;
 20
 21const GIT_TASK_DEBOUNCE: Duration = Duration::from_millis(50);
 22
 23actions!(
 24    git,
 25    [
 26        StageFile,
 27        UnstageFile,
 28        ToggleStaged,
 29        // Revert actions are currently in the editor crate:
 30        // editor::RevertFile,
 31        // editor::RevertSelectedHunks
 32        StageAll,
 33        UnstageAll,
 34        RevertAll,
 35        CommitChanges,
 36        CommitAllChanges,
 37        ClearCommitMessage
 38    ]
 39);
 40
 41pub fn init(cx: &mut AppContext) {
 42    GitPanelSettings::register(cx);
 43    let git_state = cx.new_model(GitState::new);
 44    cx.set_global(GlobalGitState(git_state));
 45}
 46
 47#[derive(Default, Debug, PartialEq, Eq, Clone)]
 48pub enum GitViewMode {
 49    #[default]
 50    List,
 51    Tree,
 52}
 53
 54struct GlobalGitState(Model<GitState>);
 55
 56impl Global for GlobalGitState {}
 57
 58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 59enum StatusAction {
 60    Stage,
 61    Unstage,
 62}
 63
 64pub struct GitState {
 65    /// The current commit message being composed.
 66    commit_message: Option<SharedString>,
 67
 68    /// When a git repository is selected, this is used to track which repository's changes
 69    /// are currently being viewed or modified in the UI.
 70    active_repository: Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)>,
 71
 72    updater_tx: mpsc::Sender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
 73
 74    all_repositories: HashMap<WorktreeId, SumTree<RepositoryEntry>>,
 75
 76    list_view_mode: GitViewMode,
 77}
 78
 79impl GitState {
 80    pub fn new(cx: &mut ModelContext<'_, Self>) -> Self {
 81        let (updater_tx, updater_rx) = mpsc::channel();
 82        cx.spawn(|_, cx| async move {
 83            // Long-running task to periodically update git indices based on messages from the panel.
 84
 85            // We read messages from the channel in batches that refer to the same repository.
 86            // When we read a message whose repository is different from the current batch's repository,
 87            // the batch is finished, and since we can't un-receive this last message, we save it
 88            // to begin the next batch.
 89            let mut leftover_message: Option<(
 90                Arc<dyn GitRepository>,
 91                Vec<RepoPath>,
 92                StatusAction,
 93            )> = None;
 94            let mut git_task = None;
 95            loop {
 96                let mut timer = cx.background_executor().timer(GIT_TASK_DEBOUNCE).fuse();
 97                let _result = {
 98                    let mut task: Pin<&mut dyn FusedFuture<Output = anyhow::Result<()>>> =
 99                        match git_task.as_mut() {
100                            Some(task) => pin!(task),
101                            // If no git task is running, just wait for the timeout.
102                            None => pin!(std::future::pending().fuse()),
103                        };
104                    select! {
105                        result = task => {
106                            // Task finished.
107                            git_task = None;
108                            Some(result)
109                        }
110                        _ = timer => None,
111                    }
112                };
113
114                // TODO handle failure of the git command
115
116                if git_task.is_none() {
117                    // No git task running now; let's see if we should launch a new one.
118                    let mut to_stage = Vec::new();
119                    let mut to_unstage = Vec::new();
120                    let mut current_repo = leftover_message.as_ref().map(|msg| msg.0.clone());
121                    for (git_repo, paths, action) in leftover_message
122                        .take()
123                        .into_iter()
124                        .chain(updater_rx.try_iter())
125                    {
126                        if current_repo
127                            .as_ref()
128                            .map_or(false, |repo| !Arc::ptr_eq(repo, &git_repo))
129                        {
130                            // End of a batch, save this for the next one.
131                            leftover_message = Some((git_repo.clone(), paths, action));
132                            break;
133                        } else if current_repo.is_none() {
134                            // Start of a batch.
135                            current_repo = Some(git_repo);
136                        }
137
138                        if action == StatusAction::Stage {
139                            to_stage.extend(paths);
140                        } else {
141                            to_unstage.extend(paths);
142                        }
143                    }
144
145                    // TODO handle the same path being staged and unstaged
146
147                    if to_stage.is_empty() && to_unstage.is_empty() {
148                        continue;
149                    }
150
151                    if let Some(git_repo) = current_repo {
152                        git_task = Some(
153                            cx.background_executor()
154                                .spawn(async move { git_repo.update_index(&to_stage, &to_unstage) })
155                                .fuse(),
156                        );
157                    }
158                }
159            }
160        })
161        .detach();
162        GitState {
163            commit_message: None,
164            active_repository: None,
165            updater_tx,
166            list_view_mode: GitViewMode::default(),
167            all_repositories: HashMap::default(),
168        }
169    }
170
171    pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
172        cx.global::<GlobalGitState>().0.clone()
173    }
174
175    pub fn activate_repository(
176        &mut self,
177        worktree_id: WorktreeId,
178        active_repository: RepositoryEntry,
179        git_repo: Arc<dyn GitRepository>,
180    ) {
181        self.active_repository = Some((worktree_id, active_repository, git_repo));
182    }
183
184    pub fn active_repository(
185        &self,
186    ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
187        self.active_repository.as_ref()
188    }
189
190    pub fn commit_message(&mut self, message: Option<SharedString>) {
191        self.commit_message = message;
192    }
193
194    pub fn clear_commit_message(&mut self) {
195        self.commit_message = None;
196    }
197
198    pub fn stage_entry(&mut self, repo_path: RepoPath) {
199        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
200            let _ = self
201                .updater_tx
202                .send((git_repo.clone(), vec![repo_path], StatusAction::Stage));
203        }
204    }
205
206    pub fn unstage_entry(&mut self, repo_path: RepoPath) {
207        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
208            let _ =
209                self.updater_tx
210                    .send((git_repo.clone(), vec![repo_path], StatusAction::Unstage));
211        }
212    }
213
214    pub fn stage_entries(&mut self, entries: Vec<RepoPath>) {
215        if let Some((_, _, git_repo)) = self.active_repository.as_ref() {
216            let _ = self
217                .updater_tx
218                .send((git_repo.clone(), entries, StatusAction::Stage));
219        }
220    }
221
222    fn act_on_all(&mut self, action: StatusAction) {
223        if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() {
224            let _ = self.updater_tx.send((
225                git_repo.clone(),
226                active_repository
227                    .status()
228                    .map(|entry| entry.repo_path)
229                    .collect(),
230                action,
231            ));
232        }
233    }
234
235    pub fn stage_all(&mut self) {
236        self.act_on_all(StatusAction::Stage);
237    }
238
239    pub fn unstage_all(&mut self) {
240        self.act_on_all(StatusAction::Unstage);
241    }
242}
243
244pub fn first_worktree_repository(
245    project: &Model<Project>,
246    worktree_id: WorktreeId,
247    cx: &mut AppContext,
248) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
249    project
250        .read(cx)
251        .worktree_for_id(worktree_id, cx)
252        .and_then(|worktree| {
253            let snapshot = worktree.read(cx).snapshot();
254            let repo = snapshot.repositories().iter().next()?.clone();
255            let git_repo = worktree
256                .read(cx)
257                .as_local()?
258                .get_local_repo(&repo)?
259                .repo()
260                .clone();
261            Some((repo, git_repo))
262        })
263}
264
265pub fn first_repository_in_project(
266    project: &Model<Project>,
267    cx: &mut AppContext,
268) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
269    project.read(cx).worktrees(cx).next().and_then(|worktree| {
270        let snapshot = worktree.read(cx).snapshot();
271        let repo = snapshot.repositories().iter().next()?.clone();
272        let git_repo = worktree
273            .read(cx)
274            .as_local()?
275            .get_local_repo(&repo)?
276            .repo()
277            .clone();
278        Some((snapshot.id(), repo, git_repo))
279    })
280}
281
282const ADDED_COLOR: Hsla = Hsla {
283    h: 142. / 360.,
284    s: 0.68,
285    l: 0.45,
286    a: 1.0,
287};
288const MODIFIED_COLOR: Hsla = Hsla {
289    h: 48. / 360.,
290    s: 0.76,
291    l: 0.47,
292    a: 1.0,
293};
294const REMOVED_COLOR: Hsla = Hsla {
295    h: 355. / 360.,
296    s: 0.65,
297    l: 0.65,
298    a: 1.0,
299};
300
301// TODO: Add updated status colors to theme
302pub fn git_status_icon(status: GitFileStatus) -> impl IntoElement {
303    match status {
304        GitFileStatus::Added | GitFileStatus::Untracked => {
305            Icon::new(IconName::SquarePlus).color(Color::Custom(ADDED_COLOR))
306        }
307        GitFileStatus::Modified => {
308            Icon::new(IconName::SquareDot).color(Color::Custom(MODIFIED_COLOR))
309        }
310        GitFileStatus::Conflict => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)),
311        GitFileStatus::Deleted => {
312            Icon::new(IconName::SquareMinus).color(Color::Custom(REMOVED_COLOR))
313        }
314    }
315}