From f35026f31c8b055ce65a6f687acc2e9da46e2a98 Mon Sep 17 00:00:00 2001 From: liqiujiong Date: Thu, 5 Nov 2026 23:39:40 +0800 Subject: [PATCH] feat(vcs): let the Git log show lines added and deleted (#1564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Git log's changed-files pane can show the lines a commit added and deleted per file and as a total per directory and for the selection, from git diff --numstat -z -M over the same revision pairs as the file list (renames matched by either name). Off by default; turned on in Settings › Version Control › Log or the pane's View Options, so selecting a commit runs no extra diff unless asked. Co-authored-by: liqiujiong <5794205+liqiujiong@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-host/src/ops/vcs/commands.rs | 16 +++++ crates/ide-host/src/trust.rs | 1 + crates/ide-vcs/src/lib.rs | 18 +++++ crates/ide-vcs/src/ops.rs | 53 +++++++++++++- crates/ide-vcs/tests/repo.rs | 36 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/vcs.rs | 15 ++++ src/bindings.ts | 17 +++++ src/features/vcs/GitLog.module.css | 15 ++++ src/features/vcs/GitLog.tsx | 55 ++++++++++++++- src/features/vcs/LogSettingsPage.tsx | 1 + src/features/vcs/__tests__/logPanes.test.tsx | 69 +++++++++++++++++- src/features/vcs/logChanges.ts | 74 +++++++++++++++++++- src/features/vcs/logOptions.ts | 3 + src/platform/locales/text/en.json | 7 +- src/platform/locales/text/zh.json | 5 ++ 16 files changed, 379 insertions(+), 7 deletions(-) diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 9dc44f29a..82505d7e8 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -1007,6 +1007,19 @@ pub async fn vcs_diff_revisions( blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await } +/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file. +pub async fn vcs_diff_numstat( + host: &Host, + window: &str, + repo: Option, + from: String, + to: String, + paths: Vec, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull /// Request tab's three-dot diff. pub async fn vcs_merge_base( @@ -1357,6 +1370,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_diff_revisions", |c| async move { vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await }); + d.add("vcs_diff_numstat", |c| async move { + vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await + }); d.add("vcs_merge_base", |c| async move { vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 52af748ad..32e54b96f 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -338,6 +338,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_compare_scope" | "vcs_compare_paths" | "vcs_diff_revisions" + | "vcs_diff_numstat" | "vcs_merge_base" | "vcs_shelves" | "vcs_shelf_patch" diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index e21f689cc..7ebcaaa6a 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -302,6 +302,18 @@ pub struct ChangedFile { pub old_path: Option, } +/// Added and deleted lines of one file between two revisions (`git diff --numstat`). +/// A rename keeps the counts of the content change; a pure rename is `0`/`0`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileLineStats { + /// Path after the change (the only path, when the file was not renamed). + pub path: String, + pub old_path: Option, + pub added: u32, + pub removed: u32, +} + /// A file changed across several commits: its content at `before` ↔ at `after`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -1248,6 +1260,12 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::diff_revisions(self, from, to) } + /// Added and deleted lines between `from` and `to`. `paths` limits the diff to those + /// repository-relative paths (both names of a rename); empty diffs every file. + pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::diff_numstat(self, from, to, paths) + } /// Tracked files whose working-tree content differs from `rev`. pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index d3508f428..af172d06d 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -3,7 +3,7 @@ use crate::cli::{self, run}; use crate::repo::{gx, resolve}; -use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode}; +use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode}; use ide_core::{CancellationToken, IdeError, IdeResult}; /// The in-progress operation git can continue / abort / skip. @@ -394,6 +394,44 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> { .collect() } +/// Added and deleted lines between two revisions. `paths` limits the diff (pass both names of +/// a rename); an empty list, or one too long for the command line, diffs every file. +pub(crate) fn diff_numstat(r: &Repo, from: &str, to: &str, paths: &[String]) -> IdeResult> { + check_commits(&[from.to_string(), to.to_string()])?; + let mut args = vec!["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to]; + // Windows command lines are short; past this, the unfiltered diff still contains these paths. + let limited = !paths.is_empty() && paths.iter().map(|p| p.len() + 1).sum::() <= 16_000; + if limited { + args.push("--"); + args.extend(paths.iter().filter(|p| !p.is_empty()).map(String::as_str)); + } + let out = run(r.root(), &args, None)?; + Ok(parse_numstat_z(&out.stdout_str())) +} + +/// `git diff --numstat -z -M`: `added\tremoved\tpath\0`, or `added\tremoved\0old\0new\0` for a rename. +/// Binary files (`-`) count as 0. +pub(crate) fn parse_numstat_z(text: &str) -> Vec { + let mut parts = text.split('\0').filter(|s| !s.is_empty()); + let mut out = Vec::new(); + while let Some(rec) = parts.next() { + let mut fields = rec.splitn(3, '\t'); + let added = fields.next().unwrap_or("0").parse().unwrap_or(0); + let removed = fields.next().unwrap_or("0").parse().unwrap_or(0); + let (path, old_path) = if let Some(path) = fields.next().filter(|p| !p.is_empty()) { + (path.to_owned(), None) + } else { + let (Some(old), Some(new)) = (parts.next(), parts.next()) else { break }; + (new.to_owned(), Some(old.to_owned())) + }; + if !path.is_empty() { + out.push(FileLineStats { path, old_path, added, removed }); + } + } + out.sort_by(|a, b| a.path.cmp(&b.path).then(a.old_path.cmp(&b.old_path))); + out +} + /// Files that differ between two revisions (Update Project's "N files updated"). pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult> { check_commits(&[from.to_string(), to.to_string()])?; @@ -457,4 +495,17 @@ mod tests { vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)] ); } + + #[test] + fn numstat_z_parsing_counts_renames_and_binaries_as_zero() { + let text = "1\t0\timg.bin\u{0}2\t1\u{0}file.txt\u{0}renamed.txt\u{0}-\t-\tbin.dat\u{0}"; + assert_eq!( + parse_numstat_z(text), + vec![ + FileLineStats { path: "bin.dat".into(), old_path: None, added: 0, removed: 0 }, + FileLineStats { path: "img.bin".into(), old_path: None, added: 1, removed: 0 }, + FileLineStats { path: "renamed.txt".into(), old_path: Some("file.txt".into()), added: 2, removed: 1 }, + ] + ); + } } diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index db66e0d6e..c2db88f85 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -594,6 +594,42 @@ fn commit_files_kinds() { assert!(repo.commit_files("HEAD").unwrap().len() == 4); } +/// Line counts follow the same first-parent diff as the commit's file list, including renames. +#[test] +fn diff_numstat_counts_added_deleted_and_renames() { + let fx = Fixture::new(); + fx.write("keep.txt", "a\nb\nc\n"); + fx.write("mod.txt", "m\n"); + fx.write("old.txt", "a fairly long line of content\nanother line\nand a third\n"); + let root = fx.commit_all("root"); + fx.write("mod.txt", "m\nmore\n"); + std::fs::remove_file(fx.path("keep.txt")).unwrap(); + fx.write("fresh.txt", "n1\nn2\n"); + fx.git(&["mv", "old.txt", "new.txt"]); + fx.write("new.txt", "a fairly long line of content\nanother line\nchanged\n"); + let c = fx.commit_all("changes"); + let repo = fx.repo(); + + let stats = repo.diff_numstat(&root, &c, &[]).unwrap(); + let find = |p: &str| stats.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p}: {stats:#?}")); + assert_eq!((find("mod.txt").added, find("mod.txt").removed), (1, 0)); + assert_eq!((find("keep.txt").added, find("keep.txt").removed), (0, 3)); + assert_eq!((find("fresh.txt").added, find("fresh.txt").removed), (2, 0)); + assert_eq!(find("new.txt").old_path.as_deref(), Some("old.txt")); + assert_eq!((find("new.txt").added, find("new.txt").removed), (1, 1)); + + let only = repo.diff_numstat(&root, &c, &["mod.txt".into()]).unwrap(); + assert_eq!(only.iter().map(|f| f.path.as_str()).collect::>(), vec!["mod.txt"]); + // The log passes both names, which is what lets git still report the rename. + let renamed = repo.diff_numstat(&root, &c, &["old.txt".into(), "new.txt".into()]).unwrap(); + assert_eq!(renamed.len(), 1, "{renamed:#?}"); + assert_eq!(renamed[0].old_path.as_deref(), Some("old.txt")); + assert_eq!((renamed[0].added, renamed[0].removed), (1, 1)); + + let created = repo.diff_numstat("4b825dc642cb6eb9a060e54bf8d69288fbee4904", &root, &["mod.txt".into()]).unwrap(); + assert_eq!((created[0].added, created[0].removed), (1, 0)); +} + /// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔ /// after the last one, contiguous or not. #[test] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8a33b8ffd..aca0338af 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -309,6 +309,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_compare_scope, vcs::vcs_compare_paths, vcs::vcs_diff_revisions, + vcs::vcs_diff_numstat, vcs::vcs_merge_base, vcs::vcs_create_patch, vcs::vcs_patch_preview, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index ccb6c016b..e28096e2a 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -631,6 +631,21 @@ pub async fn vcs_diff_revisions( ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await } +/// Added and deleted lines between two revisions (Git Log › lines added and deleted). +/// `paths` limits the diff to those repository-relative paths; empty diffs every file. +#[tauri::command] +#[specta::specta] +pub async fn vcs_diff_numstat( + window: Window, + state: State<'_, AppState>, + repo: Option, + from: String, + to: String, + paths: Vec, +) -> IdeResult> { + ide_host::ops::vcs::vcs_diff_numstat(&state.host, window.label(), repo, from, to, paths).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index c355b8051..8fba64998 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -577,6 +577,11 @@ export const commands = { vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError(__TAURI_INVOKE("vcs_compare_paths", { paths, revision, repositories, request })), /** Files that differ between two revisions (the "N files updated" of Update Project). */ vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })), + /** + * Added and deleted lines between two revisions (Git Log › lines added and deleted). + * `paths` limits the diff to those repository-relative paths; empty diffs every file. + */ + vcsDiffNumstat: (repo: string | null, from: string, to: string, paths: string[]) => typedError(__TAURI_INVOKE("vcs_diff_numstat", { repo, from, to, paths })), /** The best common ancestor of `a` and `b` (`git merge-base`), if any. */ vcsMergeBase: (repo: string | null, a: string, b: string) => typedError(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })), vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { repo, source })), @@ -3576,6 +3581,18 @@ export type FileIdentifiers = { occurrences: IdentifierOccurrence[], }; +/** + * Added and deleted lines of one file between two revisions (`git diff --numstat`). + * A rename keeps the counts of the content change; a pure rename is `0`/`0`. + */ +export type FileLineStats = { + /** Path after the change (the only path, when the file was not renamed). */ + path: string, + oldPath: string | null, + added: number, + removed: number, +}; + export type FileMatch = { path: IpcPath, /** Path relative to the project root, as displayed. */ diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index fb89714b9..93f0d9e80 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -377,6 +377,21 @@ text-overflow: ellipsis; color: var(--fg-subtle); } +.lineStats { + display: inline-flex; + gap: 6px; + flex: none; + font-variant-numeric: tabular-nums; +} +.fileRow .lineStats { + margin-left: auto; +} +.lineAdded { + color: var(--vcs-added); +} +.lineRemoved { + color: var(--vcs-deleted); +} .messageBox { height: 100%; overflow: auto; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index b2964ea7d..dc20982ed 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -104,6 +104,7 @@ import { showChangesDiff, toLogChanges, useLogChanges, + withLineStats, type LogChange, } from "./logChanges"; import { canDeleteBranch, canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions"; @@ -2175,8 +2176,9 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { const onlyAffected = useLogOptions((s) => s.showOnlyAffected); const preview = useLogOptions((s) => s.diffPreview); const diffLocation = useLogOptions((s) => s.diffLocation); + const showLineStats = useLogOptions((s) => s.showLineStats); const filters = useGitLog((s) => s.filters); - const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot]); + const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot, showLineStats]); const files = result?.key === filesKey ? result.files : null; const error = result?.key === filesKey ? result.error : null; @@ -2225,6 +2227,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { return toLogChanges(files, c, commitRoot, readOnly); })(); load + .then((files) => (showLineStats ? withLineStats(files).catch(() => files) : files)) .then((files) => alive && setResult({ key: filesKey, files, error: null })) .catch( (e) => alive && setResult({ key: filesKey, files: null, error: e instanceof Error ? e.message : String(e) }), @@ -2232,7 +2235,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { return () => { alive = false; }; - }, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly]); + }, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly, showLineStats]); const groupByDirectory = useLogOptions((s) => s.groupByDirectory); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); @@ -2254,6 +2257,14 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { ) : changes; }, [files, commit, many, filters.path, filters.paths, onlyAffected]); + const lineTotals = useMemo(() => { + if (!showLineStats || !changes.length) return null; + if (changes.some((f) => f.added === undefined || f.removed === undefined)) return null; + return changes.reduce((sum, f) => ({ added: sum.added + f.added!, removed: sum.removed + f.removed! }), { + added: 0, + removed: 0, + }); + }, [changes, showLineStats]); const multipleRoots = new Set(chosen.map(commitRepo)).size > 1; const changeTreePath = useCallback( (f: LogChange) => `${multipleRoots ? `${repoName(f.repo ?? "")}/` : ""}${f.group ? `${f.group}/` : ""}${f.path}`, @@ -2341,6 +2352,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { { kind: "separator" }, { kind: "action", action: "vcs.log.changes.diffPreview" }, toggle("Show Only Affected Changes", logOptions(), "showOnlyAffected"), + toggle("Show lines added and deleted", logOptions(), "showLineStats"), toggle("Show Changes from Parents", logOptions(), "changesFromParents"), { label: "Diff Preview Location", @@ -2404,6 +2416,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { {files ? fileCount(changes.length) : ""} {many ? " " + tx`in ${chosen.length} commits` : ""} + {lineTotals && } ): { added: number; removed: number } | null { + if (node.file) return lineCount(node.file); + let added = 0; + let removed = 0; + for (const child of node.children) { + const count = nodeLineCount(child); + if (!count) return null; + added += count.added; + removed += count.removed; + } + return node.children.length ? { added, removed } : null; +} + +function LineStats({ added, removed }: { added: number; removed: number }) { + return ( + + + +{added} + + + −{removed} + + + ); +} + function ChangedFileRow({ node, flat = false }: { node: FileTreeNode; flat?: boolean }) { + const stats = nodeLineCount(node); if (!node.file) return ( {node.name} {fileCount(node.count)} + {stats && } ); const f = node.file; @@ -2718,6 +2768,7 @@ function ChangedFileRow({ node, flat = false }: { node: FileTreeNode {flat && dirName(f.path) && {dirName(f.path)}} {f.oldPath && {tx`renamed from ${baseName(f.oldPath)}`}} + {stats && } ); } diff --git a/src/features/vcs/LogSettingsPage.tsx b/src/features/vcs/LogSettingsPage.tsx index 05823ec57..35ff0ce31 100644 --- a/src/features/vcs/LogSettingsPage.tsx +++ b/src/features/vcs/LogSettingsPage.tsx @@ -11,6 +11,7 @@ const OPTIONS: { key: Flag; label: string }[] = [ { key: "tagNames", label: "Show tag names" }, { key: "commitTimestamp", label: "Show commit time rather than author time" }, { key: "refsOnLeft", label: "Show references on the left" }, + { key: "showLineStats", label: "Show lines added and deleted" }, ]; const COLUMNS: { key: Flag; label: string }[] = [ diff --git a/src/features/vcs/__tests__/logPanes.test.tsx b/src/features/vcs/__tests__/logPanes.test.tsx index e61e700ab..58b945216 100644 --- a/src/features/vcs/__tests__/logPanes.test.tsx +++ b/src/features/vcs/__tests__/logPanes.test.tsx @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { Branch, Commit } from "@/bindings"; const vcsCommitFiles = vi.fn(); +const vcsDiffNumstat = vi.fn(); const vcsTags = vi.fn(); vi.mock("@/platform/ipc", async (importOriginal) => { @@ -12,6 +13,8 @@ vi.mock("@/platform/ipc", async (importOriginal) => { commands: { ...real.commands, vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id), + vcsDiffNumstat: (repo: unknown, from: string, to: string, paths: string[]) => + vcsDiffNumstat(repo, from, to, paths), vcsTags: (repo: unknown) => vcsTags(repo), vcsLog: () => Promise.resolve({ status: "ok", data: [] }), vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }), @@ -21,7 +24,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => { import { buildBranchTree, filterBranchTree } from "../branches"; import { buildFileTree } from "../fileTree"; -import { filesUnder, filterPatch, flatFileNodes, toLogChanges, useLogChanges } from "../logChanges"; +import { + EMPTY_TREE, + assignLineStats, + filesUnder, + filterPatch, + flatFileNodes, + lineStatRanges, + toLogChanges, + useLogChanges, +} from "../logChanges"; import { canDeleteBranch, canUpdateBranch, useBranchesPane } from "../logPaneActions"; import { BranchesPanel, CommitDetails, changesMenu, graphOptionsMenu } from "../GitLog"; import { LogSettingsPage } from "../LogSettingsPage"; @@ -61,6 +73,7 @@ afterEach(() => { useGitLog.setState({ commits: [], selection: [], selected: null, filters: emptyFilters() }); useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS }); vcsCommitFiles.mockReset(); + vcsDiffNumstat.mockReset(); vcsTags.mockReset(); }); @@ -77,6 +90,28 @@ describe("changed files model", () => { expect([mod!.before, mod!.after]).toEqual([commit.parents[0], commit.id]); expect([add!.before, add!.after]).toEqual([null, commit.id]); expect([del!.before, del!.after]).toEqual([commit.parents[0], null]); + // Added and deleted files still count against the parent diff, not the empty tree. + expect([add!.statsFrom, add!.statsTo, del!.statsTo]).toEqual([commit.parents[0], commit.id, commit.id]); + const [rootFile] = toLogChanges([{ path: "a.ts", kind: "added", oldPath: null }], { ...commit, parents: [] }); + expect(rootFile!.statsFrom).toBe(EMPTY_TREE); + }); + + it("asks for one numstat per revision pair and matches a rename by either name", () => { + const files = toLogChanges( + [ + { path: "a.ts", kind: "modified", oldPath: null }, + { path: "b.ts", kind: "added", oldPath: null }, + { path: "new.ts", kind: "renamed", oldPath: "old.ts" }, + ], + commit, + ); + expect(lineStatRanges(files).map(({ from, to, paths }) => ({ from, to, paths }))).toEqual([ + { from: commit.parents[0], to: commit.id, paths: ["a.ts", "b.ts", "new.ts", "old.ts"] }, + ]); + const [renamed] = assignLineStats(files, [ + { ...lineStatRanges(files)[0]!, stats: [{ path: "new.ts", oldPath: "old.ts", added: 1, removed: 2 }] }, + ]).filter((f) => f.path === "new.ts"); + expect([renamed!.added, renamed!.removed]).toEqual([1, 2]); }); it("lists files without directories by name, and the files under selected directories", () => { @@ -128,6 +163,34 @@ describe("changed files pane", () => { unmount(); expect(useLogChanges.getState().selected).toEqual([]); }); + + it("shows added and deleted lines when the log option is on", async () => { + vcsCommitFiles.mockResolvedValue({ + status: "ok", + data: [ + { path: "src/a.ts", kind: "modified", oldPath: null }, + { path: "src/b.ts", kind: "added", oldPath: null }, + ], + }); + vcsDiffNumstat.mockResolvedValue({ + status: "ok", + data: [ + { path: "src/a.ts", oldPath: null, added: 2, removed: 1 }, + { path: "src/b.ts", oldPath: null, added: 4, removed: 0 }, + ], + }); + useLogOptions.setState({ showLineStats: true }); + useGitLog.setState({ commits: [commit], selected: commit.id, selection: [commit.id] }); + render(); + expect(await screen.findByLabelText("2 lines added")).toBeInTheDocument(); + // The file, its directory and the toolbar each report the deleted line. + expect(screen.getAllByLabelText("1 line deleted")).toHaveLength(3); + expect(screen.getAllByLabelText("6 lines added")).toHaveLength(2); + expect(vcsDiffNumstat.mock.calls[0]!.slice(1)).toEqual([commit.parents[0], commit.id, ["src/a.ts", "src/b.ts"]]); + + useLogOptions.setState({ showLineStats: false }); + await waitFor(() => expect(screen.queryByLabelText("2 lines added")).not.toBeInTheDocument()); + }); }); describe("Branches pane", () => { @@ -190,5 +253,7 @@ describe("Settings › Version Control › Log", () => { expect(useLogOptions.getState().tagNames).toBe(!DEFAULT_LOG_OPTIONS.tagNames); fireEvent.click(screen.getByLabelText("Hash")); expect(useLogOptions.getState().showHash).toBe(!DEFAULT_LOG_OPTIONS.showHash); + fireEvent.click(screen.getByLabelText("Show lines added and deleted")); + expect(useLogOptions.getState().showLineStats).toBe(true); }); }); diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 7a07b3c1c..c54879f5f 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -4,7 +4,7 @@ * context menu do with them. */ import { create } from "zustand"; -import type { ChangedFile, Commit, RevisionChange } from "@/bindings"; +import type { ChangedFile, Commit, FileLineStats, RevisionChange } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { editorService } from "@/workbench/contributions"; import { tx } from "@/platform/i18n"; @@ -18,6 +18,9 @@ import { commitRepo } from "./multiRoot"; import { repositoryPath } from "./repositoryPaths"; import { confirmDialog } from "./dialogs"; +/** Git's empty tree: a file added in a root commit is diffed against this. */ +export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + /** A file of the pane, with the revisions holding its content before and after the change. */ export interface LogChange { /** Repository-relative path (after the change). */ @@ -29,12 +32,28 @@ export interface LogChange { before: string | null; /** `null`: the change deleted the file. */ after: string | null; + /** + * Revisions whose diff holds this file's line counts. For one commit that is its + * first parent (or the empty tree) and the commit itself, added files included. + */ + statsFrom: string; + statsTo: string; + added?: number; + removed?: number; diff: DiffData; readOnly?: boolean; group?: string; mergeParents?: Array<{ rev: string; path: string }>; } +/** One `git diff --numstat` covering every file that shares its endpoints. */ +export interface LineStatRange { + repo: string | null; + from: string; + to: string; + paths: string[]; +} + /** One commit's files (`commit` given) or several commits' combined changes. */ export function toLogChanges( files: readonly (ChangedFile | RevisionChange)[], @@ -53,6 +72,8 @@ export function toLogChanges( kind: f.kind, before: f.kind === "added" ? null : parent, after: f.kind === "deleted" ? null : commit.id, + statsFrom: parent ?? EMPTY_TREE, + statsTo: commit.id, diff: commitFileDiffData({ ...commit, repo }, f), }; } @@ -65,11 +86,62 @@ export function toLogChanges( kind: r.kind, before: r.before, after: r.after, + statsFrom: r.before ?? EMPTY_TREE, + statsTo: r.after ?? EMPTY_TREE, diff: revisionChangeDiffData(r, repo), }; }); } +/** Groups files that share a diff, so one numstat covers a commit's additions and edits. */ +export function lineStatRanges(files: readonly LogChange[]): LineStatRange[] { + const groups = new Map(); + for (const f of files) { + if (!f.statsFrom || !f.statsTo || f.statsFrom === f.statsTo) continue; + const repo = f.repo ?? null; + const key = `${repo ?? ""}\0${f.statsFrom}\0${f.statsTo}`; + let group = groups.get(key); + if (!group) groups.set(key, (group = { repo, from: f.statsFrom, to: f.statsTo, paths: [] })); + if (!group.paths.includes(f.path)) group.paths.push(f.path); + if (f.oldPath && !group.paths.includes(f.oldPath)) group.paths.push(f.oldPath); + } + return [...groups.values()]; +} + +/** Writes numstat rows onto the files of the same revision pair, matching a rename by either name. */ +export function assignLineStats( + files: readonly LogChange[], + loaded: readonly (LineStatRange & { stats: readonly FileLineStats[] })[], +): LogChange[] { + const byPath = new Map(); + for (const range of loaded) + for (const stat of range.stats) { + const put = (path: string) => byPath.set(`${range.repo ?? ""}\0${range.from}\0${range.to}\0${path}`, stat); + put(stat.path); + if (stat.oldPath) put(stat.oldPath); + } + return files.map((f) => { + const repo = f.repo ?? ""; + const stat = + byPath.get(`${repo}\0${f.statsFrom}\0${f.statsTo}\0${f.path}`) ?? + (f.oldPath ? byPath.get(`${repo}\0${f.statsFrom}\0${f.statsTo}\0${f.oldPath}`) : undefined); + return stat ? { ...f, added: stat.added, removed: stat.removed } : f; + }); +} + +/** Fills `added` / `removed` from `git diff --numstat`. A failed diff leaves the files uncounted. */ +export async function withLineStats(files: LogChange[]): Promise { + const ranges = lineStatRanges(files); + if (!ranges.length) return files; + const loaded = await Promise.all( + ranges.map(async (range) => ({ + ...range, + stats: await call(commands.vcsDiffNumstat(range.repo, range.from, range.to, range.paths)), + })), + ); + return assignLineStats(files, loaded); +} + /** Files without directory grouping: by name, each node's path shown next to it. */ export function flatFileNodes(files: readonly F[], relPath: (f: F) => string): FileTreeNode[] { return files diff --git a/src/features/vcs/logOptions.ts b/src/features/vcs/logOptions.ts index 835b4a096..9d6f40b85 100644 --- a/src/features/vcs/logOptions.ts +++ b/src/features/vcs/logOptions.ts @@ -33,6 +33,8 @@ export interface LogOptions { groupByDirectory: boolean; /** Selecting a changed file shows its diff in the editor's preview tab. */ diffPreview: boolean; + /** Changed files show how many lines the commit added and deleted. */ + showLineStats: boolean; showDetails: boolean; showRepository: boolean; showOnlyAffected: boolean; @@ -65,6 +67,7 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = { showBranches: true, groupByDirectory: true, diffPreview: true, + showLineStats: false, showDetails: true, showRepository: true, showOnlyAffected: false, diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 675492bcd..774307a76 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -12,5 +12,10 @@ "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.", "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon", "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.", - "Select PHPStan Configuration File": "Select PHPStan Configuration File" + "Select PHPStan Configuration File": "Select PHPStan Configuration File", + "Show lines added and deleted": "Show lines added and deleted", + "{added} line added": "{added} line added", + "{added} lines added": "{added} lines added", + "{removed} line deleted": "{removed} line deleted", + "{removed} lines deleted": "{removed} lines deleted" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 772b07344..111271f21 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4855,6 +4855,11 @@ "Revert Selected Changes": "还原所选更改", "Show only the first reference": "仅显示第一个引用", "Show tag names": "显示标签名称", + "Show lines added and deleted": "显示增加和删除的行数", + "{added} line added": "增加了 {added} 行", + "{added} lines added": "增加了 {added} 行", + "{removed} line deleted": "删除了 {removed} 行", + "{removed} lines deleted": "删除了 {removed} 行", "Show commit time rather than author time": "显示提交时间而非作者时间", "Show references on the left": "在左侧显示引用", "Visible columns": "可见列",