feat(vcs): let the Git log show lines added and deleted (#1564)

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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-05 23:39:40 +08:00
1 джерело 875e049dcd
коміт f35026f31c
16 files changed
+379 -7

No files matched your search

+16
Переглянути файл
@@ -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<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
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
});
+1
Переглянути файл
@@ -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"
+18
Переглянути файл
@@ -302,6 +302,18 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// 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<String>,
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<Vec<FileLineStats>> {
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<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+52 -1
Переглянути файл
@@ -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<Vec<FileLineStats>> {
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::<usize>() <= 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<FileLineStats> {
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<Vec<ChangedFile>> {
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 },
]
);
}
}
+36
Переглянути файл
@@ -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<_>>(), 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]
+1
Переглянути файл
@@ -309,6 +309,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+15
Переглянути файл
@@ -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<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
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]
+17
Переглянути файл
@@ -577,6 +577,11 @@ export const commands = {
vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError<CompareResult, IdeError>(__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<ChangedFile[], IdeError>(__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<FileLineStats[], IdeError>(__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<string | null, IdeError>(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })),
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__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. */
+15
Переглянути файл
@@ -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;
+53 -2
Переглянути файл
@@ -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<ReadonlySet<string>>(() => 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` : ""}
</span>
{lineTotals && <LineStats added={lineTotals.added} removed={lineTotals.removed} />}
<span className={styles.grow} />
<IconButton
icon="expand-all"
@@ -2699,13 +2712,50 @@ export function changesMenu(changes: LogChange[]): MenuItem[] {
];
}
function lineCount(file: ChangedFile | LogChange): { added: number; removed: number } | null {
if (!("added" in file) || typeof file.added !== "number" || typeof file.removed !== "number") return null;
return { added: file.added, removed: file.removed };
}
/** Sum of a directory once every file under it has counts; otherwise the row stays without them. */
function nodeLineCount(node: FileTreeNode<ChangedFile | LogChange>): { 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 (
<span className={styles.lineStats}>
<span className={styles.lineAdded} aria-label={added === 1 ? tx`${added} line added` : tx`${added} lines added`}>
+{added}
</span>
<span
className={styles.lineRemoved}
aria-label={removed === 1 ? tx`${removed} line deleted` : tx`${removed} lines deleted`}
>
−{removed}
</span>
</span>
);
}
function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile | LogChange>; flat?: boolean }) {
const stats = nodeLineCount(node);
if (!node.file)
return (
<span className={styles.fileRow}>
<Icon name="folder" size={16} className={styles.fileIcon} />
<span className={styles.fileName}>{node.name}</span>
<span className={styles.fileDir}>{fileCount(node.count)}</span>
{stats && <LineStats added={stats.added} removed={stats.removed} />}
</span>
);
const f = node.file;
@@ -2718,6 +2768,7 @@ function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile
</span>
{flat && dirName(f.path) && <span className={styles.fileDir}>{dirName(f.path)}</span>}
{f.oldPath && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
{stats && <LineStats added={stats.added} removed={stats.removed} />}
</span>
);
}
+1
Переглянути файл
@@ -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 }[] = [
+67 -2
Переглянути файл
@@ -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(<CommitDetails commits={[commit]} />);
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);
});
});
+73 -1
Переглянути файл
@@ -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<string, LineStatRange>();
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<string, FileLineStats>();
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<LogChange[]> {
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<F>(files: readonly F[], relPath: (f: F) => string): FileTreeNode<F>[] {
return files
+3
Переглянути файл
@@ -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,
+6 -1
Переглянути файл
@@ -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"
}
+5
Переглянути файл
@@ -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": "可见列",