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 <53574922+cole-miller@users.noreply.github.com>
Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
Nate Butler 2025-01-13 11:47:09 -05:00 committed by GitHub
parent 1c6dd03e50
commit 102e70816c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1006 additions and 840 deletions

View file

@ -1,6 +1,7 @@
use crate::status::GitStatusPair;
use crate::GitHostingProviderRegistry;
use crate::{blame::Blame, status::GitStatus};
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
use collections::{HashMap, HashSet};
use git2::BranchType;
use gpui::SharedString;
@ -15,6 +16,7 @@ use std::{
sync::Arc,
};
use sum_tree::MapSeekTarget;
use util::command::new_std_command;
use util::ResultExt;
#[derive(Clone, Debug, Hash, PartialEq)]
@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync {
/// Returns the path to the repository, typically the `.git` folder.
fn dot_git_dir(&self) -> PathBuf;
fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>;
}
impl std::fmt::Debug for dyn GitRepository {
@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository {
Ok(_) => Ok(true),
Err(e) => match e.code() {
git2::ErrorCode::NotFound => Ok(false),
_ => Err(anyhow::anyhow!(e)),
_ => Err(anyhow!(e)),
},
}
}
@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository {
repo.set_head(
revision
.name()
.ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
.ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
)?;
Ok(())
}
@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository {
self.hosting_provider_registry.clone(),
)
}
fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> {
let working_directory = self
.repository
.lock()
.workdir()
.context("failed to read git work directory")?
.to_path_buf();
if !stage.is_empty() {
let add = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(["add", "--"])
.args(stage.iter().map(|p| p.as_ref()))
.status()?;
if !add.success() {
return Err(anyhow!("Failed to stage files: {add}"));
}
}
if !unstage.is_empty() {
let rm = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(["restore", "--staged", "--"])
.args(unstage.iter().map(|p| p.as_ref()))
.status()?;
if !rm.success() {
return Err(anyhow!("Failed to unstage files: {rm}"));
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository {
let mut entries = state
.worktree_statuses
.iter()
.filter_map(|(repo_path, status)| {
.filter_map(|(repo_path, status_worktree)| {
if path_prefixes
.iter()
.any(|path_prefix| repo_path.0.starts_with(path_prefix))
{
Some((repo_path.to_owned(), *status))
Some((
repo_path.to_owned(),
GitStatusPair {
index_status: None,
worktree_status: Some(*status_worktree),
},
))
} else {
None
}
})
.collect::<Vec<_>>();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
Ok(GitStatus {
entries: entries.into(),
@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository {
.with_context(|| format!("failed to get blame for {:?}", path))
.cloned()
}
fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> {
unimplemented!()
}
}
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<()> {
pub enum GitFileStatus {
Added,
Modified,
// TODO conflicts should be represented by the GitStatusPair
Conflict,
Deleted,
Untracked,
@ -426,6 +471,16 @@ impl GitFileStatus {
_ => None,
}
}
pub fn from_byte(byte: u8) -> Option<Self> {
match byte {
b'M' => Some(GitFileStatus::Modified),
b'A' => Some(GitFileStatus::Added),
b'D' => Some(GitFileStatus::Deleted),
b'?' => Some(GitFileStatus::Untracked),
_ => None,
}
}
}
pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
@ -453,6 +508,12 @@ impl RepoPath {
}
}
impl std::fmt::Display for RepoPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.to_string_lossy().fmt(f)
}
}
impl From<&Path> for RepoPath {
fn from(value: &Path) -> Self {
RepoPath::new(value.into())

View file

@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath};
use anyhow::{anyhow, Result};
use std::{path::Path, process::Stdio, sync::Arc};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GitStatusPair {
// Not both `None`.
pub index_status: Option<GitFileStatus>,
pub worktree_status: Option<GitFileStatus>,
}
impl GitStatusPair {
pub fn is_staged(&self) -> Option<bool> {
match (self.index_status, self.worktree_status) {
(Some(_), None) => Some(true),
(None, Some(_)) => Some(false),
(Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false),
(Some(_), Some(_)) => None,
(None, None) => unreachable!(),
}
}
// TODO reconsider uses of this
pub fn combined(&self) -> GitFileStatus {
self.index_status.or(self.worktree_status).unwrap()
}
}
#[derive(Clone)]
pub struct GitStatus {
pub entries: Arc<[(RepoPath, GitFileStatus)]>,
pub entries: Arc<[(RepoPath, GitStatusPair)]>,
}
impl GitStatus {
@ -20,6 +44,7 @@ impl GitStatus {
"status",
"--porcelain=v1",
"--untracked-files=all",
"--no-renames",
"-z",
])
.args(path_prefixes.iter().map(|path_prefix| {
@ -47,36 +72,32 @@ impl GitStatus {
let mut entries = stdout
.split('\0')
.filter_map(|entry| {
if entry.is_char_boundary(3) {
let (status, path) = entry.split_at(3);
let status = status.trim();
Some((
RepoPath(Path::new(path).into()),
match status {
"A" => GitFileStatus::Added,
"M" => GitFileStatus::Modified,
"D" => GitFileStatus::Deleted,
"??" => GitFileStatus::Untracked,
_ => return None,
},
))
} else {
None
let sep = entry.get(2..3)?;
if sep != " " {
return None;
};
let path = &entry[3..];
let status = entry[0..2].as_bytes();
let index_status = GitFileStatus::from_byte(status[0]);
let worktree_status = GitFileStatus::from_byte(status[1]);
if (index_status, worktree_status) == (None, None) {
return None;
}
let path = RepoPath(Path::new(path).into());
Some((
path,
GitStatusPair {
index_status,
worktree_status,
},
))
})
.collect::<Vec<_>>();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
Ok(Self {
entries: entries.into(),
})
}
pub fn get(&self, path: &Path) -> Option<GitFileStatus> {
self.entries
.binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path))
.ok()
.map(|index| self.entries[index].1)
}
}
impl Default for GitStatus {