Skip to content

Commit 102e708

Browse files
git: Git Panel UI, continued (#22960)
TODO: - [ ] Investigate incorrect hit target for `stage all` button - [ ] Add top level context menu - [ ] Add entry context menus - [x] Show paths in list view - [ ] For now, `enter` can just open the file - [ ] 🐞: Hover deadzone in list caused by scrollbar - [x] 🐞: Incorrect status/nothing shown when multiple worktrees are added --- This PR continues work on the feature flagged git panel. Changes: - Defines and wires up git panel actions & keybindings - Re-scopes some actions from `git_ui` -> `git`. - General git actions (StageAll, CommitChanges, ...) are scoped to `git`. - Git panel specific actions (Close, FocusCommitEditor, ...) are scoped to `git_panel. - Staging actions & UI are now connected to git! - Unify more reusable git status into the GitState global over being tied to the panel directly. - Uses the new git status codepaths instead of filtering all workspace entries Release Notes: - N/A --------- Co-authored-by: Cole Miller <[email protected]> Co-authored-by: Cole Miller <[email protected]>
1 parent 1c6dd03 commit 102e708

File tree

13 files changed

+999
-833
lines changed

13 files changed

+999
-833
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

assets/keymaps/default-macos.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,38 @@
682682
"space": "project_panel::Open"
683683
}
684684
},
685+
{
686+
"context": "GitPanel && !CommitEditor",
687+
"use_key_equivalents": true,
688+
"bindings": {
689+
"escape": "git_panel::Close"
690+
}
691+
},
692+
{
693+
"context": "GitPanel && ChangesList",
694+
"use_key_equivalents": true,
695+
"bindings": {
696+
"up": "menu::SelectPrev",
697+
"down": "menu::SelectNext",
698+
"cmd-up": "menu::SelectFirst",
699+
"cmd-down": "menu::SelectLast",
700+
"enter": "menu::Confirm",
701+
"space": "git::ToggleStaged",
702+
"cmd-shift-space": "git::StageAll",
703+
"ctrl-shift-space": "git::UnstageAll",
704+
"alt-down": "git_panel::FocusEditor"
705+
}
706+
},
707+
{
708+
"context": "GitPanel && CommitEditor > Editor",
709+
"use_key_equivalents": true,
710+
"bindings": {
711+
"alt-up": "git_panel::FocusChanges",
712+
"escape": "git_panel::FocusChanges",
713+
"cmd-enter": "git::CommitChanges",
714+
"cmd-alt-enter": "git::CommitAllChanges"
715+
}
716+
},
685717
{
686718
"context": "CollabPanel && not_editing",
687719
"use_key_equivalents": true,

crates/collab/src/tests/random_project_collaboration_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest {
12211221
id,
12221222
guest_project.remote_id(),
12231223
);
1224-
assert_eq!(guest_snapshot.repositories().collect::<Vec<_>>(), host_snapshot.repositories().collect::<Vec<_>>(),
1224+
assert_eq!(guest_snapshot.repositories().iter().collect::<Vec<_>>(), host_snapshot.repositories().iter().collect::<Vec<_>>(),
12251225
"{} has different repositories than the host for worktree {:?} and project {:?}",
12261226
client.username,
12271227
host_snapshot.abs_path(),

crates/editor/src/git/project_diff.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ impl ProjectDiffEditor {
197197
let snapshot = worktree.read(cx).snapshot();
198198
let applicable_entries = snapshot
199199
.repositories()
200+
.iter()
200201
.flat_map(|entry| {
201202
entry.status().map(|git_entry| {
202-
(git_entry.status, entry.join(git_entry.repo_path))
203+
(git_entry.combined_status(), entry.join(git_entry.repo_path))
203204
})
204205
})
205206
.filter_map(|(status, path)| {

crates/git/src/repository.rs

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
use crate::status::GitStatusPair;
12
use crate::GitHostingProviderRegistry;
23
use crate::{blame::Blame, status::GitStatus};
3-
use anyhow::{Context, Result};
4+
use anyhow::{anyhow, Context, Result};
45
use collections::{HashMap, HashSet};
56
use git2::BranchType;
67
use gpui::SharedString;
@@ -15,6 +16,7 @@ use std::{
1516
sync::Arc,
1617
};
1718
use sum_tree::MapSeekTarget;
19+
use util::command::new_std_command;
1820
use util::ResultExt;
1921

2022
#[derive(Clone, Debug, Hash, PartialEq)]
@@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync {
5153

5254
/// Returns the path to the repository, typically the `.git` folder.
5355
fn dot_git_dir(&self) -> PathBuf;
56+
57+
fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>;
5458
}
5559

5660
impl std::fmt::Debug for dyn GitRepository {
@@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository {
152156
Ok(_) => Ok(true),
153157
Err(e) => match e.code() {
154158
git2::ErrorCode::NotFound => Ok(false),
155-
_ => Err(anyhow::anyhow!(e)),
159+
_ => Err(anyhow!(e)),
156160
},
157161
}
158162
}
@@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository {
196200
repo.set_head(
197201
revision
198202
.name()
199-
.ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
203+
.ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
200204
)?;
201205
Ok(())
202206
}
@@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository {
228232
self.hosting_provider_registry.clone(),
229233
)
230234
}
235+
236+
fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> {
237+
let working_directory = self
238+
.repository
239+
.lock()
240+
.workdir()
241+
.context("failed to read git work directory")?
242+
.to_path_buf();
243+
if !stage.is_empty() {
244+
let add = new_std_command(&self.git_binary_path)
245+
.current_dir(&working_directory)
246+
.args(["add", "--"])
247+
.args(stage.iter().map(|p| p.as_ref()))
248+
.status()?;
249+
if !add.success() {
250+
return Err(anyhow!("Failed to stage files: {add}"));
251+
}
252+
}
253+
if !unstage.is_empty() {
254+
let rm = new_std_command(&self.git_binary_path)
255+
.current_dir(&working_directory)
256+
.args(["restore", "--staged", "--"])
257+
.args(unstage.iter().map(|p| p.as_ref()))
258+
.status()?;
259+
if !rm.success() {
260+
return Err(anyhow!("Failed to unstage files: {rm}"));
261+
}
262+
}
263+
Ok(())
264+
}
231265
}
232266

233267
#[derive(Debug, Clone)]
@@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository {
298332
let mut entries = state
299333
.worktree_statuses
300334
.iter()
301-
.filter_map(|(repo_path, status)| {
335+
.filter_map(|(repo_path, status_worktree)| {
302336
if path_prefixes
303337
.iter()
304338
.any(|path_prefix| repo_path.0.starts_with(path_prefix))
305339
{
306-
Some((repo_path.to_owned(), *status))
340+
Some((
341+
repo_path.to_owned(),
342+
GitStatusPair {
343+
index_status: None,
344+
worktree_status: Some(*status_worktree),
345+
},
346+
))
307347
} else {
308348
None
309349
}
310350
})
311351
.collect::<Vec<_>>();
312-
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
352+
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
313353

314354
Ok(GitStatus {
315355
entries: entries.into(),
@@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository {
363403
.with_context(|| format!("failed to get blame for {:?}", path))
364404
.cloned()
365405
}
406+
407+
fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> {
408+
unimplemented!()
409+
}
366410
}
367411

368412
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
@@ -398,6 +442,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
398442
pub enum GitFileStatus {
399443
Added,
400444
Modified,
445+
// TODO conflicts should be represented by the GitStatusPair
401446
Conflict,
402447
Deleted,
403448
Untracked,
@@ -426,6 +471,16 @@ impl GitFileStatus {
426471
_ => None,
427472
}
428473
}
474+
475+
pub fn from_byte(byte: u8) -> Option<Self> {
476+
match byte {
477+
b'M' => Some(GitFileStatus::Modified),
478+
b'A' => Some(GitFileStatus::Added),
479+
b'D' => Some(GitFileStatus::Deleted),
480+
b'?' => Some(GitFileStatus::Untracked),
481+
_ => None,
482+
}
483+
}
429484
}
430485

431486
pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
@@ -453,6 +508,12 @@ impl RepoPath {
453508
}
454509
}
455510

511+
impl std::fmt::Display for RepoPath {
512+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513+
self.0.to_string_lossy().fmt(f)
514+
}
515+
}
516+
456517
impl From<&Path> for RepoPath {
457518
fn from(value: &Path) -> Self {
458519
RepoPath::new(value.into())

crates/git/src/status.rs

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath};
22
use anyhow::{anyhow, Result};
33
use std::{path::Path, process::Stdio, sync::Arc};
44

5+
#[derive(Clone, Debug, PartialEq, Eq)]
6+
pub struct GitStatusPair {
7+
// Not both `None`.
8+
pub index_status: Option<GitFileStatus>,
9+
pub worktree_status: Option<GitFileStatus>,
10+
}
11+
12+
impl GitStatusPair {
13+
pub fn is_staged(&self) -> Option<bool> {
14+
match (self.index_status, self.worktree_status) {
15+
(Some(_), None) => Some(true),
16+
(None, Some(_)) => Some(false),
17+
(Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false),
18+
(Some(_), Some(_)) => None,
19+
(None, None) => unreachable!(),
20+
}
21+
}
22+
23+
// TODO reconsider uses of this
24+
pub fn combined(&self) -> GitFileStatus {
25+
self.index_status.or(self.worktree_status).unwrap()
26+
}
27+
}
28+
529
#[derive(Clone)]
630
pub struct GitStatus {
7-
pub entries: Arc<[(RepoPath, GitFileStatus)]>,
31+
pub entries: Arc<[(RepoPath, GitStatusPair)]>,
832
}
933

1034
impl GitStatus {
@@ -20,6 +44,7 @@ impl GitStatus {
2044
"status",
2145
"--porcelain=v1",
2246
"--untracked-files=all",
47+
"--no-renames",
2348
"-z",
2449
])
2550
.args(path_prefixes.iter().map(|path_prefix| {
@@ -47,36 +72,32 @@ impl GitStatus {
4772
let mut entries = stdout
4873
.split('\0')
4974
.filter_map(|entry| {
50-
if entry.is_char_boundary(3) {
51-
let (status, path) = entry.split_at(3);
52-
let status = status.trim();
53-
Some((
54-
RepoPath(Path::new(path).into()),
55-
match status {
56-
"A" => GitFileStatus::Added,
57-
"M" => GitFileStatus::Modified,
58-
"D" => GitFileStatus::Deleted,
59-
"??" => GitFileStatus::Untracked,
60-
_ => return None,
61-
},
62-
))
63-
} else {
64-
None
75+
let sep = entry.get(2..3)?;
76+
if sep != " " {
77+
return None;
78+
};
79+
let path = &entry[3..];
80+
let status = entry[0..2].as_bytes();
81+
let index_status = GitFileStatus::from_byte(status[0]);
82+
let worktree_status = GitFileStatus::from_byte(status[1]);
83+
if (index_status, worktree_status) == (None, None) {
84+
return None;
6585
}
86+
let path = RepoPath(Path::new(path).into());
87+
Some((
88+
path,
89+
GitStatusPair {
90+
index_status,
91+
worktree_status,
92+
},
93+
))
6694
})
6795
.collect::<Vec<_>>();
68-
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
96+
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
6997
Ok(Self {
7098
entries: entries.into(),
7199
})
72100
}
73-
74-
pub fn get(&self, path: &Path) -> Option<GitFileStatus> {
75-
self.entries
76-
.binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path))
77-
.ok()
78-
.map(|index| self.entries[index].1)
79-
}
80101
}
81102

82103
impl Default for GitStatus {

crates/git_ui/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ anyhow.workspace = true
1717
collections.workspace = true
1818
db.workspace = true
1919
editor.workspace = true
20+
futures.workspace = true
2021
git.workspace = true
2122
gpui.workspace = true
2223
language.workspace = true
@@ -27,6 +28,7 @@ serde.workspace = true
2728
serde_derive.workspace = true
2829
serde_json.workspace = true
2930
settings.workspace = true
31+
sum_tree.workspace = true
3032
theme.workspace = true
3133
ui.workspace = true
3234
util.workspace = true

crates/git_ui/TODO.md

Lines changed: 0 additions & 45 deletions
This file was deleted.

0 commit comments

Comments
 (0)