add commit panel
This commit is contained in:
Generated
+34
@@ -1612,6 +1612,20 @@ dependencies = [
|
|||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -2691,8 +2705,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc"
|
checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bstr",
|
"bstr",
|
||||||
|
"gix-command",
|
||||||
|
"gix-filter",
|
||||||
|
"gix-fs",
|
||||||
"gix-hash",
|
"gix-hash",
|
||||||
|
"gix-imara-diff",
|
||||||
"gix-object",
|
"gix-object",
|
||||||
|
"gix-path",
|
||||||
|
"gix-tempfile",
|
||||||
|
"gix-trace",
|
||||||
|
"gix-traverse",
|
||||||
|
"gix-worktree",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2821,6 +2844,16 @@ dependencies = [
|
|||||||
"unicode-bom",
|
"unicode-bom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gix-imara-diff"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
||||||
|
dependencies = [
|
||||||
|
"bstr",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gix-index"
|
name = "gix-index"
|
||||||
version = "0.54.0"
|
version = "0.54.0"
|
||||||
@@ -3143,6 +3176,7 @@ version = "24.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e"
|
checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dashmap",
|
||||||
"gix-fs",
|
"gix-fs",
|
||||||
"libc",
|
"libc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ publish.workspace = true
|
|||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
gix = { workspace = true, features = ["revision"] }
|
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||||
use gix::interrupt::IS_INTERRUPTED;
|
use gix::interrupt::IS_INTERRUPTED;
|
||||||
use gix::progress::Discard;
|
use gix::progress::Discard;
|
||||||
use signed_core::RepoAddr;
|
use signed_core::RepoAddr;
|
||||||
@@ -153,6 +154,9 @@ pub struct FileCommit {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// First line of the commit message.
|
/// First line of the commit message.
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
|
/// Rest of the commit message after the title; `None` when there is no
|
||||||
|
/// body (single-line commit messages).
|
||||||
|
pub description: Option<String>,
|
||||||
/// Author name.
|
/// Author name.
|
||||||
pub author: String,
|
pub author: String,
|
||||||
/// Author time, seconds since the Unix epoch.
|
/// Author time, seconds since the Unix epoch.
|
||||||
@@ -243,6 +247,10 @@ fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
|||||||
Ok(FileCommit {
|
Ok(FileCommit {
|
||||||
id: commit.id().shorten_or_id().to_string(),
|
id: commit.id().shorten_or_id().to_string(),
|
||||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||||
|
description: message
|
||||||
|
.body
|
||||||
|
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||||
|
.filter(|body| !body.is_empty()),
|
||||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||||
time: author.time()?.seconds,
|
time: author.time()?.seconds,
|
||||||
})
|
})
|
||||||
@@ -385,6 +393,287 @@ pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
|||||||
all_commits(&open_with_cache(workdir)?)
|
all_commits(&open_with_cache(workdir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind of a [`DiffLine`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DiffLineKind {
|
||||||
|
/// An unchanged context line, present on both sides.
|
||||||
|
Context,
|
||||||
|
/// A line added by the commit.
|
||||||
|
Addition,
|
||||||
|
/// A line removed by the commit.
|
||||||
|
Deletion,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of a file diff.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffLine {
|
||||||
|
pub kind: DiffLineKind,
|
||||||
|
/// 1-based line number in the old version, if the line exists there.
|
||||||
|
pub old: Option<u32>,
|
||||||
|
/// 1-based line number in the new version, if the line exists there.
|
||||||
|
pub new: Option<u32>,
|
||||||
|
/// Line content without the trailing newline.
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hunk of a file diff, like `@@ -a,b +c,d @@`, with the lines between the
|
||||||
|
/// two headers (context around the change, then removals and additions).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffHunk {
|
||||||
|
/// 1-based start line in the old version.
|
||||||
|
pub old_start: u32,
|
||||||
|
/// Number of old lines covered by the hunk.
|
||||||
|
pub old_lines: u32,
|
||||||
|
/// 1-based start line in the new version.
|
||||||
|
pub new_start: u32,
|
||||||
|
/// Number of new lines covered by the hunk.
|
||||||
|
pub new_lines: u32,
|
||||||
|
pub lines: Vec<DiffLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a file changed in a commit.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DiffStatus {
|
||||||
|
Added,
|
||||||
|
Modified,
|
||||||
|
Deleted,
|
||||||
|
Renamed,
|
||||||
|
Copied,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diff of one file in a commit.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileDiff {
|
||||||
|
/// Path of the file relative to the repo root (the destination path for
|
||||||
|
/// renames and copies).
|
||||||
|
pub path: String,
|
||||||
|
/// Previous path, for renames and copies.
|
||||||
|
pub old_path: Option<String>,
|
||||||
|
pub status: DiffStatus,
|
||||||
|
/// Number of added lines; 0 for binary files.
|
||||||
|
pub insertions: usize,
|
||||||
|
/// Number of removed lines; 0 for binary files.
|
||||||
|
pub deletions: usize,
|
||||||
|
/// True if either version is binary (then `hunks` is empty).
|
||||||
|
pub binary: bool,
|
||||||
|
pub hunks: Vec<DiffHunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changes of one commit: every file it added, modified, deleted or
|
||||||
|
/// renamed, with line-level hunks for text files.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommitDiff {
|
||||||
|
pub files: Vec<FileDiff>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changes of the commit `id` (short or full) in the repository at
|
||||||
|
/// `workdir`, compared against its first parent (the empty tree for the root
|
||||||
|
/// commit), like `git show`. Directory entries and submodules are skipped;
|
||||||
|
/// their contents are reported as individual file changes. Files are sorted
|
||||||
|
/// by path.
|
||||||
|
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||||
|
commit_diff(&open_with_cache(workdir)?, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||||
|
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||||
|
use gix::object::tree::diff::Change;
|
||||||
|
use gix::objs::tree::EntryKind;
|
||||||
|
|
||||||
|
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||||
|
let commit = commit_id.object()?.into_commit();
|
||||||
|
let new_tree = commit.tree()?;
|
||||||
|
let old_tree = match commit.parent_ids().next() {
|
||||||
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let changes = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?;
|
||||||
|
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for change in changes {
|
||||||
|
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||||
|
|
||||||
|
// The tree diff also reports directory entries; only their contents
|
||||||
|
// are listed, so skip trees and submodule gitlinks.
|
||||||
|
let (path, old_path, status) = match attached {
|
||||||
|
Change::Addition {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Added)
|
||||||
|
}
|
||||||
|
Change::Deletion {
|
||||||
|
location,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||||
|
(location.to_owned(), None, DiffStatus::Deleted)
|
||||||
|
}
|
||||||
|
Change::Modification {
|
||||||
|
location,
|
||||||
|
previous_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
previous_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
(location.to_owned(), None, DiffStatus::Modified)
|
||||||
|
}
|
||||||
|
Change::Rewrite {
|
||||||
|
location,
|
||||||
|
source_location,
|
||||||
|
source_entry_mode,
|
||||||
|
entry_mode,
|
||||||
|
copy,
|
||||||
|
..
|
||||||
|
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||||
|
&& !matches!(
|
||||||
|
source_entry_mode.kind(),
|
||||||
|
EntryKind::Tree | EntryKind::Commit
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
let status = if copy {
|
||||||
|
DiffStatus::Copied
|
||||||
|
} else {
|
||||||
|
DiffStatus::Renamed
|
||||||
|
};
|
||||||
|
(
|
||||||
|
location.to_owned(),
|
||||||
|
Some(source_location.to_owned()),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always diff with the built-in algorithm: external diff drivers
|
||||||
|
// would shell out, which is out of scope for a read-only viewer.
|
||||||
|
let platform = attached.diff(&mut cache)?;
|
||||||
|
platform
|
||||||
|
.resource_cache
|
||||||
|
.options
|
||||||
|
.skip_internal_diff_if_external_is_configured = true;
|
||||||
|
let outcome = platform.resource_cache.prepare_diff()?;
|
||||||
|
|
||||||
|
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||||
|
Operation::InternalDiff { algorithm } => {
|
||||||
|
let input = outcome.interned_input();
|
||||||
|
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||||
|
|
||||||
|
let mut hunks = Vec::new();
|
||||||
|
let mut insertions = 0usize;
|
||||||
|
let mut deletions = 0usize;
|
||||||
|
let collector = HunkCollector {
|
||||||
|
hunks: &mut hunks,
|
||||||
|
insertions: &mut insertions,
|
||||||
|
deletions: &mut deletions,
|
||||||
|
};
|
||||||
|
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||||
|
.consume()?;
|
||||||
|
(false, hunks, insertions, deletions)
|
||||||
|
}
|
||||||
|
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||||
|
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||||
|
};
|
||||||
|
|
||||||
|
files.push(FileDiff {
|
||||||
|
path: String::from_utf8_lossy(&path).into_owned(),
|
||||||
|
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||||
|
status,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
binary,
|
||||||
|
hunks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
Ok(CommitDiff { files })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||||
|
///
|
||||||
|
/// The unified-diff headers give the 1-based start line of the hunk in each
|
||||||
|
/// file; context lines advance both counters, removals only the old one and
|
||||||
|
/// additions only the new one, so each line ends up with its real line
|
||||||
|
/// numbers in both versions.
|
||||||
|
struct HunkCollector<'a> {
|
||||||
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
|
insertions: &'a mut usize,
|
||||||
|
deletions: &'a mut usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsumeHunk for HunkCollector<'_> {
|
||||||
|
type Out = ();
|
||||||
|
|
||||||
|
fn consume_hunk(
|
||||||
|
&mut self,
|
||||||
|
header: HunkHeader,
|
||||||
|
lines: &[(GixLineKind, &[u8])],
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut old_ln = header.before_hunk_start;
|
||||||
|
let mut new_ln = header.after_hunk_start;
|
||||||
|
let mut out = Vec::with_capacity(lines.len());
|
||||||
|
|
||||||
|
for (kind, content) in lines {
|
||||||
|
let text = String::from_utf8_lossy(content).into_owned();
|
||||||
|
let line = match kind {
|
||||||
|
GixLineKind::Context => {
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Context,
|
||||||
|
old: Some(old_ln),
|
||||||
|
new: Some(new_ln),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
old_ln += 1;
|
||||||
|
new_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
GixLineKind::Remove => {
|
||||||
|
*self.deletions += 1;
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Deletion,
|
||||||
|
old: Some(old_ln),
|
||||||
|
new: None,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
old_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
GixLineKind::Add => {
|
||||||
|
*self.insertions += 1;
|
||||||
|
let line = DiffLine {
|
||||||
|
kind: DiffLineKind::Addition,
|
||||||
|
old: None,
|
||||||
|
new: Some(new_ln),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
new_ln += 1;
|
||||||
|
line
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hunks.push(DiffHunk {
|
||||||
|
old_start: header.before_hunk_start,
|
||||||
|
old_lines: header.before_hunk_len,
|
||||||
|
new_start: header.after_hunk_start,
|
||||||
|
new_lines: header.after_hunk_len,
|
||||||
|
lines: out,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) {}
|
||||||
|
}
|
||||||
|
|
||||||
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
||||||
/// repository without commits yet (unborn HEAD).
|
/// repository without commits yet (unborn HEAD).
|
||||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||||
@@ -397,6 +686,10 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
|||||||
Ok(Some(FileCommit {
|
Ok(Some(FileCommit {
|
||||||
id: commit.id().shorten_or_id().to_string(),
|
id: commit.id().shorten_or_id().to_string(),
|
||||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||||
|
description: message
|
||||||
|
.body
|
||||||
|
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||||
|
.filter(|body| !body.is_empty()),
|
||||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||||
time: author.time()?.seconds,
|
time: author.time()?.seconds,
|
||||||
}))
|
}))
|
||||||
@@ -929,4 +1222,161 @@ mod tests {
|
|||||||
.any(|p| p.to_string_lossy() == "b.txt")
|
.any(|p| p.to_string_lossy() == "b.txt")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_diff_lists_added_modified_and_deleted_files() {
|
||||||
|
let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write");
|
||||||
|
std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write");
|
||||||
|
std::fs::remove_file(dir.path().join("keep.txt")).expect("remove");
|
||||||
|
commit_all(&repo, "changes");
|
||||||
|
|
||||||
|
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||||
|
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
|
||||||
|
|
||||||
|
let by_path: HashMap<&str, &FileDiff> = diff
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| (file.path.as_str(), file))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(by_path.len(), 3);
|
||||||
|
|
||||||
|
let added = by_path["new.txt"];
|
||||||
|
assert_eq!(added.status, DiffStatus::Added);
|
||||||
|
assert_eq!(added.insertions, 1);
|
||||||
|
assert_eq!(added.deletions, 0);
|
||||||
|
assert_eq!(added.hunks.len(), 1);
|
||||||
|
assert_eq!(added.hunks[0].lines.len(), 1);
|
||||||
|
assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition);
|
||||||
|
assert_eq!(added.hunks[0].lines[0].old, None);
|
||||||
|
assert_eq!(added.hunks[0].lines[0].new, Some(1));
|
||||||
|
assert_eq!(added.hunks[0].lines[0].text, "hello");
|
||||||
|
|
||||||
|
let modified = by_path["mod.txt"];
|
||||||
|
assert_eq!(modified.status, DiffStatus::Modified);
|
||||||
|
assert_eq!(modified.insertions, 1);
|
||||||
|
assert_eq!(modified.deletions, 1);
|
||||||
|
assert!(!modified.binary);
|
||||||
|
let lines = &modified.hunks[0].lines;
|
||||||
|
// One hunk with context around the single-line change: the removed
|
||||||
|
// line is old 2, the added line is new 2.
|
||||||
|
assert!(lines.iter().any(|line| {
|
||||||
|
line.kind == DiffLineKind::Deletion
|
||||||
|
&& line.old == Some(2)
|
||||||
|
&& line.new.is_none()
|
||||||
|
&& line.text == "two"
|
||||||
|
}));
|
||||||
|
assert!(lines.iter().any(|line| {
|
||||||
|
line.kind == DiffLineKind::Addition
|
||||||
|
&& line.old.is_none()
|
||||||
|
&& line.new == Some(2)
|
||||||
|
&& line.text == "two!"
|
||||||
|
}));
|
||||||
|
assert!(lines.iter().any(|line| {
|
||||||
|
line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let deleted = by_path["keep.txt"];
|
||||||
|
assert_eq!(deleted.status, DiffStatus::Deleted);
|
||||||
|
assert_eq!(deleted.deletions, 1);
|
||||||
|
assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion);
|
||||||
|
assert_eq!(deleted.hunks[0].lines[0].old, Some(1));
|
||||||
|
assert_eq!(deleted.hunks[0].lines[0].new, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_diff_reports_binary_files_without_hunks() {
|
||||||
|
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
|
std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write");
|
||||||
|
commit_all(&repo, "binary change");
|
||||||
|
|
||||||
|
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||||
|
let diff = worktree_commit_diff(_dir.path(), &head).expect("diff");
|
||||||
|
let file = diff
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "blob.bin")
|
||||||
|
.expect("file");
|
||||||
|
assert!(file.binary);
|
||||||
|
assert!(file.hunks.is_empty());
|
||||||
|
assert_eq!(file.insertions, 0);
|
||||||
|
assert_eq!(file.deletions, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_diff_resolves_short_ids_and_root_commit() {
|
||||||
|
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
|
// The root commit diffs against the empty tree: everything is added.
|
||||||
|
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||||
|
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
|
||||||
|
assert_eq!(diff.files.len(), 1);
|
||||||
|
assert_eq!(diff.files[0].path, "a.txt");
|
||||||
|
assert_eq!(diff.files[0].status, DiffStatus::Added);
|
||||||
|
assert_eq!(diff.files[0].insertions, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_commit_includes_message_body() {
|
||||||
|
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
commit_all(&repo, "title");
|
||||||
|
|
||||||
|
// A single-line message has no body.
|
||||||
|
let head = head_commit(&repo).expect("head").expect("commit");
|
||||||
|
assert_eq!(head.summary, "title");
|
||||||
|
assert_eq!(head.description, None);
|
||||||
|
|
||||||
|
// A message with a body exposes it, trimmed.
|
||||||
|
let dir = _dir.path();
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(dir)
|
||||||
|
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.args([
|
||||||
|
"commit",
|
||||||
|
"--allow-empty",
|
||||||
|
"-m",
|
||||||
|
"title two",
|
||||||
|
"-m",
|
||||||
|
"line one\n\nline two",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.expect("spawn git");
|
||||||
|
assert!(status.success(), "git commit failed");
|
||||||
|
|
||||||
|
let head = head_commit(&repo).expect("head").expect("commit");
|
||||||
|
assert_eq!(head.summary, "title two");
|
||||||
|
assert_eq!(head.description.as_deref(), Some("line one\n\nline two"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_diff_reports_renames() {
|
||||||
|
let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
|
std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename");
|
||||||
|
commit_all(&repo, "rename");
|
||||||
|
|
||||||
|
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||||
|
let diff = worktree_commit_diff(_dir.path(), &head).expect("diff");
|
||||||
|
let file = diff
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == "new.txt")
|
||||||
|
.expect("file");
|
||||||
|
assert_eq!(file.status, DiffStatus::Renamed);
|
||||||
|
assert_eq!(file.old_path.as_deref(), Some("old.txt"));
|
||||||
|
// A pure rename has no content change; the file is still listed.
|
||||||
|
assert!(file.hunks.is_empty());
|
||||||
|
assert_eq!(file.insertions, 0);
|
||||||
|
assert_eq!(file.deletions, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! as a badge on the tab.
|
//! as a badge on the tab.
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{AnyElement, App, Context, div, px};
|
use gpui::{AnyElement, App, Context, WeakEntity, div, px};
|
||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::spinner::Spinner;
|
use gpui_component::spinner::Spinner;
|
||||||
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
||||||
@@ -17,7 +17,16 @@ use super::helpers::placeholder;
|
|||||||
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||||
|
|
||||||
/// One row of the commit list: id, summary, author and relative time.
|
/// One row of the commit list: id, summary, author and relative time.
|
||||||
fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement {
|
/// Clicking a row opens the diff of that commit in a new panel.
|
||||||
|
fn commit_row(
|
||||||
|
ix: usize,
|
||||||
|
commit: &FileCommit,
|
||||||
|
view: &WeakEntity<RepoDetailView>,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
let view = view.clone();
|
||||||
|
let commit = commit.clone();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.px_4()
|
.px_4()
|
||||||
@@ -65,6 +74,11 @@ fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement {
|
|||||||
.child(relative_time_secs(commit.time)),
|
.child(relative_time_secs(commit.time)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
if let Some(view) = view.upgrade() {
|
||||||
|
view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +128,10 @@ impl RepoDetailView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|list| list.commits.as_slice())
|
.map(|list| list.commits.as_slice())
|
||||||
.unwrap_or(&[]);
|
.unwrap_or(&[]);
|
||||||
range.map(|ix| commit_row(ix, &commits[ix], cx)).collect()
|
let view = cx.entity().downgrade();
|
||||||
|
range
|
||||||
|
.map(|ix| commit_row(ix, &commits[ix], &view, cx))
|
||||||
|
.collect()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.track_scroll(&scroll_handle)
|
.track_scroll(&scroll_handle)
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
//! Commit diff viewer: a panel showing every file a commit changed, with a
|
||||||
|
//! tree of the changed files on the left and the line diff of the selected
|
||||||
|
//! file on the right. Opened from the repository detail view by clicking a
|
||||||
|
//! commit in the Commits tab or the latest-commit button in the header.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||||
|
WeakEntity, Window, div, px,
|
||||||
|
};
|
||||||
|
use gpui_component::clipboard::Clipboard;
|
||||||
|
use gpui_component::dock::{Panel, PanelEvent};
|
||||||
|
use gpui_component::list::ListItem;
|
||||||
|
use gpui_component::spinner::Spinner;
|
||||||
|
use gpui_component::tag::Tag;
|
||||||
|
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||||
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
|
||||||
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
|
use super::helpers::{build_tree_items, placeholder, tree_items};
|
||||||
|
|
||||||
|
/// Width of the changed-files column.
|
||||||
|
const TREE_WIDTH: f32 = 260.;
|
||||||
|
/// Width of one line-number gutter in a diff row.
|
||||||
|
const GUTTER_WIDTH: f32 = 44.;
|
||||||
|
|
||||||
|
/// Detail panel showing the diff of one commit.
|
||||||
|
pub struct CommitDiffView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
/// Local clone the commit lives in.
|
||||||
|
worktree: PathBuf,
|
||||||
|
/// Display name of the repository the commit belongs to.
|
||||||
|
repo_name: SharedString,
|
||||||
|
/// The commit being shown (header and tab title).
|
||||||
|
commit: FileCommit,
|
||||||
|
/// Loaded diff; `None` while loading or after a failure.
|
||||||
|
diff: Option<CommitDiff>,
|
||||||
|
/// The diff is being computed on a background task.
|
||||||
|
loading: bool,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
/// Changed-files explorer state.
|
||||||
|
tree_state: Entity<TreeState>,
|
||||||
|
/// Path of the file whose diff is shown in the detail column.
|
||||||
|
selected_file: Option<SharedString>,
|
||||||
|
/// In-flight tasks; pruned on every push (see [`Self::track`]).
|
||||||
|
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommitDiffView {
|
||||||
|
pub fn new(
|
||||||
|
worktree: PathBuf,
|
||||||
|
repo_name: SharedString,
|
||||||
|
commit: FileCommit,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
|
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||||
|
|
||||||
|
// Defer until the window is ready, like the repository detail view.
|
||||||
|
cx.defer_in(window, |this, window, cx| {
|
||||||
|
this.load(window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
worktree,
|
||||||
|
repo_name,
|
||||||
|
commit,
|
||||||
|
diff: None,
|
||||||
|
loading: true,
|
||||||
|
error: None,
|
||||||
|
tree_state,
|
||||||
|
selected_file: None,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the commit diff on a background task and populate the tree.
|
||||||
|
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.loading = true;
|
||||||
|
self.error = None;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let worktree = self.worktree.clone();
|
||||||
|
let id = self.commit.id.clone();
|
||||||
|
|
||||||
|
let task = cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, _window, cx| {
|
||||||
|
this.loading = false;
|
||||||
|
match result {
|
||||||
|
Ok(diff) => {
|
||||||
|
let mut paths: Vec<PathBuf> = diff
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| PathBuf::from(&file.path))
|
||||||
|
.collect();
|
||||||
|
paths.sort();
|
||||||
|
let items = tree_items(build_tree_items(&paths), true);
|
||||||
|
let first = diff
|
||||||
|
.files
|
||||||
|
.first()
|
||||||
|
.map(|file| SharedString::from(file.path.as_str()));
|
||||||
|
this.tree_state.update(cx, |state, cx| {
|
||||||
|
state.set_items(items.clone(), cx);
|
||||||
|
let item = find_item(&items, first.as_deref());
|
||||||
|
state.set_selected_item(item, cx);
|
||||||
|
});
|
||||||
|
this.selected_file = first;
|
||||||
|
this.diff = Some(diff);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
this.error = Some(error.to_string().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
self.track(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the diff of the file at `path` (selected in the tree).
|
||||||
|
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||||
|
self.selected_file = Some(path.into());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track `task` until it completes; finished tasks are pruned on every
|
||||||
|
/// push so the vec stays bounded by the number of in-flight loads.
|
||||||
|
fn track(&mut self, task: gpui::Task<Result<(), anyhow::Error>>) {
|
||||||
|
self.tasks.retain(|task| !task.is_ready());
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the changed-files tree: icon + name, indented by depth.
|
||||||
|
fn render_tree_item(
|
||||||
|
ix: usize,
|
||||||
|
entry: &TreeEntry,
|
||||||
|
selected: bool,
|
||||||
|
view: &WeakEntity<Self>,
|
||||||
|
) -> ListItem {
|
||||||
|
let item = entry.item();
|
||||||
|
let id = item.id.clone();
|
||||||
|
let is_folder = entry.is_folder();
|
||||||
|
|
||||||
|
let icon = if is_folder {
|
||||||
|
if entry.is_expanded() {
|
||||||
|
IconName::FolderOpen
|
||||||
|
} else {
|
||||||
|
IconName::FolderClosed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconName::File
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = view.clone();
|
||||||
|
|
||||||
|
ListItem::new(ix)
|
||||||
|
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||||
|
.selected(selected)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(Icon::new(icon).small())
|
||||||
|
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||||
|
)
|
||||||
|
.on_click(move |_event, _window, cx| {
|
||||||
|
// Folders expand/collapse via the tree itself.
|
||||||
|
if is_folder {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(view) = view.upgrade() {
|
||||||
|
view.update(cx, |this, cx| this.select_file(&id, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left column: the changed-files tree.
|
||||||
|
fn render_tree_column(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let tree_state = self.tree_state.clone();
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.h_full()
|
||||||
|
.w(px(TREE_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.when(self.diff.is_some(), |this| {
|
||||||
|
this.child(tree(
|
||||||
|
&tree_state,
|
||||||
|
move |ix, entry, selected, _window, _cx| {
|
||||||
|
Self::render_tree_item(ix, entry, selected, &view)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.when(self.diff.is_none() && !self.loading, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.p_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Failed to load diff"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Right column: header of the selected file plus its diff.
|
||||||
|
fn render_detail_column(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
if self.loading {
|
||||||
|
return v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Spinner::new().small())
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
if let Some(error) = self.error.clone() {
|
||||||
|
return placeholder(&error, cx);
|
||||||
|
}
|
||||||
|
let Some(diff) = self.diff.as_ref() else {
|
||||||
|
return placeholder("Failed to load diff", cx);
|
||||||
|
};
|
||||||
|
let Some(path) = self.selected_file.clone() else {
|
||||||
|
return if diff.files.is_empty() {
|
||||||
|
placeholder("No files changed in this commit", cx)
|
||||||
|
} else {
|
||||||
|
placeholder("Select a file", cx)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||||
|
return placeholder("File not found", cx);
|
||||||
|
};
|
||||||
|
self.render_file_diff(file, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The diff of one file: a header with status and stats, then the hunks.
|
||||||
|
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement {
|
||||||
|
let status_label = match file.status {
|
||||||
|
DiffStatus::Added => "A",
|
||||||
|
DiffStatus::Modified => "M",
|
||||||
|
DiffStatus::Deleted => "D",
|
||||||
|
DiffStatus::Renamed => "R",
|
||||||
|
DiffStatus::Copied => "C",
|
||||||
|
};
|
||||||
|
let status_color = match file.status {
|
||||||
|
DiffStatus::Added => cx.theme().success,
|
||||||
|
DiffStatus::Modified => cx.theme().info,
|
||||||
|
DiffStatus::Deleted => cx.theme().danger,
|
||||||
|
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().muted_foreground,
|
||||||
|
};
|
||||||
|
let title = match &file.old_path {
|
||||||
|
Some(old) => format!("{old} → {}", file.path),
|
||||||
|
None => file.path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body: AnyElement = if file.binary {
|
||||||
|
placeholder("Binary file — diff not available", cx)
|
||||||
|
} else if file.hunks.is_empty() {
|
||||||
|
placeholder("No content changes", cx)
|
||||||
|
} else {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.font_family(cx.theme().mono_font_family.clone())
|
||||||
|
.text_xs()
|
||||||
|
.children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx)))
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.h_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.px_3()
|
||||||
|
.h_9()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(status_color)
|
||||||
|
.child(status_label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
.when(!file.binary, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().success)
|
||||||
|
.child(format!("+{}", file.insertions)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().danger)
|
||||||
|
.child(format!("-{}", file.deletions)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("commit-diff-body")
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.overflow_scroll()
|
||||||
|
.child(body),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines.
|
||||||
|
fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.w_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.px_2()
|
||||||
|
.py_0p5()
|
||||||
|
.w_full()
|
||||||
|
.bg(cx.theme().muted)
|
||||||
|
.border_y(px(1.))
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(format!(
|
||||||
|
"@@ -{},{} +{},{} @@",
|
||||||
|
hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
.children(
|
||||||
|
hunk.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| Self::render_diff_line(line, cx)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One diff line: old and new line numbers in gutters, then the content,
|
||||||
|
/// tinted by kind (addition / deletion / context).
|
||||||
|
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||||
|
let bg = match line.kind {
|
||||||
|
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
|
||||||
|
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
|
||||||
|
DiffLineKind::Context => None,
|
||||||
|
};
|
||||||
|
let gutter = cx.theme().muted_foreground;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.when_some(bg, |this, bg| this.bg(bg))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(GUTTER_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.pr_2()
|
||||||
|
.text_right()
|
||||||
|
.text_color(gutter)
|
||||||
|
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(GUTTER_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.pr_2()
|
||||||
|
.text_right()
|
||||||
|
.text_color(gutter)
|
||||||
|
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.child(line.text.clone()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header: commit id, summary, author/time and overall change stats.
|
||||||
|
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let commit = &self.commit;
|
||||||
|
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| {
|
||||||
|
(
|
||||||
|
diff.files.len(),
|
||||||
|
diff.files.iter().map(|file| file.insertions).sum(),
|
||||||
|
diff.files.iter().map(|file| file.deletions).sum(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.px_4()
|
||||||
|
.py_2()
|
||||||
|
.w_full()
|
||||||
|
.gap_4()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(commit.summary.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2p5()
|
||||||
|
.text_sm()
|
||||||
|
.child(h_flex().child(format!("{} committed", commit.author)))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_0p5()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(SharedString::from(&commit.id))
|
||||||
|
.child(Clipboard::new("commit").value(&commit.id)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(relative_time_secs(commit.time)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when_some(commit.description.as_ref(), |this, description| {
|
||||||
|
this.child(div().text_sm().child(SharedString::from(description)))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.child(
|
||||||
|
Tag::primary()
|
||||||
|
.outline()
|
||||||
|
.small()
|
||||||
|
.child(format!("{files} files changed")),
|
||||||
|
)
|
||||||
|
.when(insertions > 0, |this| {
|
||||||
|
this.child(
|
||||||
|
Tag::success()
|
||||||
|
.outline()
|
||||||
|
.small()
|
||||||
|
.child(format!("+ {insertions}")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(deletions > 0, |this| {
|
||||||
|
this.child(
|
||||||
|
Tag::success()
|
||||||
|
.outline()
|
||||||
|
.small()
|
||||||
|
.child(format!("- {insertions}")),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a tree item by id, searching into nested children.
|
||||||
|
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
|
||||||
|
let id = id?;
|
||||||
|
items.iter().find_map(|item| {
|
||||||
|
if item.id.as_ref() == id {
|
||||||
|
Some(item)
|
||||||
|
} else {
|
||||||
|
find_item(&item.children, Some(id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for CommitDiffView {
|
||||||
|
fn panel_name(&self) -> &'static str {
|
||||||
|
"commit_diff"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().text_sm().child(SharedString::from(format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.repo_name, self.commit.id
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for CommitDiffView {}
|
||||||
|
|
||||||
|
impl Focusable for CommitDiffView {
|
||||||
|
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for CommitDiffView {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.id("commit-diff")
|
||||||
|
.size_full()
|
||||||
|
.child(self.render_header(cx))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.min_h_0()
|
||||||
|
.child(self.render_tree_column(cx))
|
||||||
|
.child(self.render_detail_column(cx)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,32 @@ impl From<TreeItemSeed> for TreeItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert tree seeds into [`TreeItem`]s, expanding every folder when
|
||||||
|
/// `expand_folders` is set.
|
||||||
|
///
|
||||||
|
/// The commit diff explorer shows only changed files, which is typically a
|
||||||
|
/// handful of paths, so its folders start expanded; the worktree explorer
|
||||||
|
/// starts collapsed instead.
|
||||||
|
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
|
||||||
|
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
|
||||||
|
let mut item = TreeItem::new(seed.id, seed.label);
|
||||||
|
if expand_folders && !seed.children.is_empty() {
|
||||||
|
item = item.expanded(true);
|
||||||
|
}
|
||||||
|
item.children = seed
|
||||||
|
.children
|
||||||
|
.into_iter()
|
||||||
|
.map(|seed| convert(seed, expand_folders))
|
||||||
|
.collect();
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds
|
||||||
|
.into_iter()
|
||||||
|
.map(|seed| convert(seed, expand_folders))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
||||||
///
|
///
|
||||||
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||||
Render, SharedString, Size, Subscription, Task, Window, div, px, size,
|
Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
||||||
use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx};
|
use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx};
|
||||||
use gpui_component::dock::{Panel, PanelEvent};
|
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
|
||||||
use gpui_component::menu::PopupMenuItem;
|
use gpui_component::menu::PopupMenuItem;
|
||||||
use gpui_component::searchable_list::SearchableVec;
|
use gpui_component::searchable_list::SearchableVec;
|
||||||
use gpui_component::tab::{Tab, TabBar};
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
@@ -26,6 +27,7 @@ use signed_state::{GitStore, RepoStore};
|
|||||||
|
|
||||||
mod browser;
|
mod browser;
|
||||||
mod commits;
|
mod commits;
|
||||||
|
mod diff;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use browser::{
|
use browser::{
|
||||||
@@ -33,6 +35,7 @@ use browser::{
|
|||||||
MarkdownView,
|
MarkdownView,
|
||||||
};
|
};
|
||||||
use commits::COMMIT_ROW_HEIGHT;
|
use commits::COMMIT_ROW_HEIGHT;
|
||||||
|
use diff::CommitDiffView;
|
||||||
use helpers::{build_tree_items, is_markdown_path};
|
use helpers::{build_tree_items, is_markdown_path};
|
||||||
|
|
||||||
/// What kind of ref the header selectors switch to.
|
/// What kind of ref the header selectors switch to.
|
||||||
@@ -47,6 +50,10 @@ enum RefKind {
|
|||||||
/// Detail view of a repository: header, stats, a file explorer with README
|
/// Detail view of a repository: header, stats, a file explorer with README
|
||||||
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
||||||
pub struct RepoDetailView {
|
pub struct RepoDetailView {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
/// Dock area the detail view lives in; new panels (commit diffs) are
|
||||||
|
/// added there.
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
/// Snapshot taken at open time, shown until the store's first refresh
|
/// Snapshot taken at open time, shown until the store's first refresh
|
||||||
/// completes (and as a fallback while the store has no announcement).
|
/// completes (and as a fallback while the store has no announcement).
|
||||||
initial: Announcement,
|
initial: Announcement,
|
||||||
@@ -107,7 +114,6 @@ pub struct RepoDetailView {
|
|||||||
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
||||||
/// older generation are discarded when they complete.
|
/// older generation are discarded when they complete.
|
||||||
ref_generation: u64,
|
ref_generation: u64,
|
||||||
focus_handle: FocusHandle,
|
|
||||||
/// In-flight tasks; finished tasks are pruned on every push, so the vec
|
/// In-flight tasks; finished tasks are pruned on every push, so the vec
|
||||||
/// stays bounded by the number of concurrent loads.
|
/// stays bounded by the number of concurrent loads.
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
@@ -117,7 +123,12 @@ pub struct RepoDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoDetailView {
|
impl RepoDetailView {
|
||||||
pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
initial: Announcement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
|
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
|
||||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||||
|
|
||||||
@@ -215,6 +226,7 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
initial,
|
initial,
|
||||||
|
dock_area,
|
||||||
announcement: None,
|
announcement: None,
|
||||||
relays,
|
relays,
|
||||||
web,
|
web,
|
||||||
@@ -568,6 +580,36 @@ impl RepoDetailView {
|
|||||||
self.track(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a new panel showing the diff of `commit` (all files it changed,
|
||||||
|
/// with the line diff of each). Called from the Commits tab rows and the
|
||||||
|
/// latest-commit button in the header.
|
||||||
|
fn open_commit_diff(
|
||||||
|
&mut self,
|
||||||
|
commit: &FileCommit,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(worktree) = self.worktree.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Same display name as the repo detail panel's title.
|
||||||
|
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
|
||||||
|
let repo_name = announcement
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||||
|
|
||||||
|
let panel =
|
||||||
|
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx));
|
||||||
|
if let Some(dock_area) = self.dock_area.upgrade() {
|
||||||
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
|
// The diff viewer lives in the bottom dock, leaving the
|
||||||
|
// central explorer open while browsing a commit.
|
||||||
|
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check out `name` (a branch or tag picked in the header) and refresh
|
/// Check out `name` (a branch or tag picked in the header) and refresh
|
||||||
/// the explorer once the switch completes.
|
/// the explorer once the switch completes.
|
||||||
fn switch_ref(
|
fn switch_ref(
|
||||||
@@ -1057,7 +1099,13 @@ impl Render for RepoDetailView {
|
|||||||
.map_or_else(SharedString::default, |commit| {
|
.map_or_else(SharedString::default, |commit| {
|
||||||
commit.summary.clone().into()
|
commit.summary.clone().into()
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
if let Some(commit) = &this.head_commit {
|
||||||
|
let commit = commit.clone();
|
||||||
|
this.open_commit_diff(&commit, window, cx);
|
||||||
|
}
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ impl RepoListView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let dock_area = self.dock_area.clone();
|
let dock_area = self.dock_area.clone();
|
||||||
let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx));
|
let detail =
|
||||||
|
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
|
||||||
|
|
||||||
if let Some(dock_area) = dock_area.upgrade() {
|
if let Some(dock_area) = dock_area.upgrade() {
|
||||||
dock_area.update(cx, |dock_area, cx| {
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
@@ -171,7 +172,7 @@ impl Panel for RepoListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
"Explore"
|
div().text_sm().child(SharedString::from("Explore"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use assets::CustomIconName;
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
|
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::dock::{DockArea, DockItem, PanelStyle};
|
use gpui_component::dock::{DockArea, DockItem};
|
||||||
use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex};
|
||||||
use signed_state::{Backend, BackendEvent};
|
use signed_state::{Backend, BackendEvent};
|
||||||
|
|
||||||
@@ -21,8 +21,7 @@ pub struct Workspace {
|
|||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let style = PanelStyle::TabBar;
|
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx));
|
||||||
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style));
|
|
||||||
let weak_dock = dock.downgrade();
|
let weak_dock = dock.downgrade();
|
||||||
|
|
||||||
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@ fn main() {
|
|||||||
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
||||||
|
|
||||||
// Set up the window bounds
|
// Set up the window bounds
|
||||||
let bounds = Bounds::centered(None, size(px(1120.0), px(720.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
|
||||||
|
|
||||||
// Set up the window options
|
// Set up the window options
|
||||||
let opts = WindowOptions {
|
let opts = WindowOptions {
|
||||||
@@ -50,7 +50,7 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
let _ = cx.open_window(opts, workspace::root);
|
cx.open_window(opts, workspace::root).ok();
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user