diff --git a/Cargo.lock b/Cargo.lock index 1e79761..f9868ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,20 @@ dependencies = [ "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]] name = "data-encoding" version = "2.11.1" @@ -2691,8 +2705,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" dependencies = [ "bstr", + "gix-command", + "gix-filter", + "gix-fs", "gix-hash", + "gix-imara-diff", "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", "thiserror 2.0.20", ] @@ -2821,6 +2844,16 @@ dependencies = [ "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]] name = "gix-index" version = "0.54.0" @@ -3143,6 +3176,7 @@ version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" dependencies = [ + "dashmap", "gix-fs", "libc", "parking_lot", diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index 35cbaa0..3a17301 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -8,7 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } nostr.workspace = true -gix = { workspace = true, features = ["revision"] } +gix = { workspace = true, features = ["revision", "blob-diff"] } anyhow.workspace = true [dev-dependencies] diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index b2cd14c..e0684d2 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; +use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; use signed_core::RepoAddr; @@ -153,6 +154,9 @@ pub struct FileCommit { pub id: String, /// First line of the commit message. pub summary: String, + /// Rest of the commit message after the title; `None` when there is no + /// body (single-line commit messages). + pub description: Option, /// Author name. pub author: String, /// Author time, seconds since the Unix epoch. @@ -243,6 +247,10 @@ fn file_commit(commit: &gix::Commit<'_>) -> Result { Ok(FileCommit { id: commit.id().shorten_or_id().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(), time: author.time()?.seconds, }) @@ -385,6 +393,287 @@ pub fn worktree_all_commits(workdir: &Path) -> Result { 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, + /// 1-based line number in the new version, if the line exists there. + pub new: Option, + /// 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, +} + +/// 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, + 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, +} + +/// 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, +} + +/// 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 { + commit_diff(&open_with_cache(workdir)?, id) +} + +fn commit_diff(repo: &gix::Repository, id: &str) -> Result { + 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, + 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 /// repository without commits yet (unborn HEAD). pub fn head_commit(repo: &gix::Repository) -> Result> { @@ -397,6 +686,10 @@ pub fn head_commit(repo: &gix::Repository) -> Result> { Ok(Some(FileCommit { id: commit.id().shorten_or_id().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(), time: author.time()?.seconds, })) @@ -929,4 +1222,161 @@ mod tests { .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); + } } diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index c1b051f..bd5107b 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -3,7 +3,7 @@ //! as a badge on the tab. 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::spinner::Spinner; 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.; /// 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, + cx: &App, +) -> AnyElement { + let view = view.clone(); + let commit = commit.clone(); + h_flex() .id(ix) .px_4() @@ -65,6 +74,11 @@ fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement { .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() } @@ -114,7 +128,10 @@ impl RepoDetailView { .as_ref() .map(|list| list.commits.as_slice()) .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) diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs new file mode 100644 index 0000000..d6e034f --- /dev/null +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -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, + /// The diff is being computed on a background task. + loading: bool, + error: Option, + /// Changed-files explorer state. + tree_state: Entity, + /// Path of the file whose diff is shown in the detail column. + selected_file: Option, + /// In-flight tasks; pruned on every push (see [`Self::track`]). + tasks: Vec>>, +} + +impl CommitDiffView { + pub fn new( + worktree: PathBuf, + repo_name: SharedString, + commit: FileCommit, + window: &mut Window, + cx: &mut Context, + ) -> 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.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 = 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.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>) { + 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, + ) -> 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) -> 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) -> 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) -> 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) -> impl IntoElement { + div().text_sm().child(SharedString::from(format!( + "{}/{}", + self.repo_name, self.commit.id + ))) + } +} + +impl EventEmitter 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) -> 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)), + ) + } +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 93b6a6e..050771d 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -28,6 +28,32 @@ impl From 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, expand_folders: bool) -> Vec { + 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. /// /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 46a00e8..1558ff4 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,17 +1,18 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use anyhow::Error; use assets::CustomIconName; use gpui::prelude::*; use gpui::{ 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::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::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; @@ -26,6 +27,7 @@ use signed_state::{GitStore, RepoStore}; mod browser; mod commits; +mod diff; mod helpers; use browser::{ @@ -33,6 +35,7 @@ use browser::{ MarkdownView, }; use commits::COMMIT_ROW_HEIGHT; +use diff::CommitDiffView; use helpers::{build_tree_items, is_markdown_path}; /// 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 /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { + focus_handle: FocusHandle, + /// Dock area the detail view lives in; new panels (commit diffs) are + /// added there. + dock_area: WeakEntity, /// Snapshot taken at open time, shown until the store's first refresh /// completes (and as a fallback while the store has no announcement). initial: Announcement, @@ -107,7 +114,6 @@ pub struct RepoDetailView { /// Bumped on every branch/tag switch; in-flight loads tagged with an /// older generation are discarded when they complete. ref_generation: u64, - focus_handle: FocusHandle, /// In-flight tasks; finished tasks are pruned on every push, so the vec /// stays bounded by the number of concurrent loads. tasks: Vec>>, @@ -117,7 +123,12 @@ pub struct RepoDetailView { } impl RepoDetailView { - pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + dock_area: WeakEntity, + initial: Announcement, + window: &mut Window, + cx: &mut Context, + ) -> Self { let store = cx.new(|cx| RepoStore::new(initial.addr(), cx)); let tree_state = cx.new(|cx| TreeState::new(cx)); @@ -215,6 +226,7 @@ impl RepoDetailView { Self { initial, + dock_area, announcement: None, relays, web, @@ -568,6 +580,36 @@ impl RepoDetailView { 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, + ) { + 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 /// the explorer once the switch completes. fn switch_ref( @@ -1057,7 +1099,13 @@ impl Render for RepoDetailView { .map_or_else(SharedString::default, |commit| { 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); + } + })), ), ), ), diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 3f3ae21..656449e 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -63,7 +63,8 @@ impl RepoListView { cx: &mut Context, ) { 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() { 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) -> impl IntoElement { - "Explore" + div().text_sm().child(SharedString::from("Explore")) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7ea4d16..c4808c5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4,7 +4,7 @@ use assets::CustomIconName; use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; 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 signed_state::{Backend, BackendEvent}; @@ -21,8 +21,7 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let style = PanelStyle::TabBar; - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style)); + let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); diff --git a/desktop/src/main.rs b/desktop/src/main.rs index c289c7d..2bc535b 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -31,7 +31,7 @@ fn main() { signed_state::GitStore::set_global(paths::repos_dir().clone(), cx); // 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 let opts = WindowOptions { @@ -50,7 +50,7 @@ fn main() { }; cx.spawn(async move |cx| { - let _ = cx.open_window(opts, workspace::root); + cx.open_window(opts, workspace::root).ok(); }) .detach();