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}