дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
16 files changed
+379
-7
No files matched your search
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "可见列",
|
||||
|
||||
Reference in new issue
Заблокувати користувача