feat(vcs): Show combined changes for multiple selected log commits (#52)

Root cause: the Git Log already supported multi-selection, but the Changes pane only read the last clicked commit and the backend had no "changes across several commits" query.
Fix (IDEA): ide-vcs Repo::commits_changes merges per-file changes across selected commits (before the first touching commit ↔ after the last, renames chained, add-then-delete dropped); the details pane shows "N files in M commits" with the commit list; exactly two commits get "Compare Versions" (version ↔ version dialog, also fixing added/deleted sides).
Siblings covered: Show History for file/folder (same log), Local History multi-select (oldest ↔ newest diff).
Verified: ide-vcs tests incl. temp-repo commits_changes, vcs vitest (143), in-app on macOS: 3 contiguous commits → "2 files in 3 commits"; Cmd-click 2 commits → Compare Versions lists the right files.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 10:39:01 +08:00
1 джерело aa5821bb46
коміт b2fec4ae30
16 files changed
+599 -79

No files matched your search

+79 -4
Переглянути файл
@@ -1,7 +1,7 @@
//! Commit history (topological walk) and per-commit changed files via gitoxide.
use crate::repo::{gx, resolve, to_string};
use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo};
use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo, RevisionChange};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::{IdeError, IdeResult};
@@ -423,11 +423,24 @@ fn path_entry(
pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let repo = r.gix()?;
let id = resolve(&repo, rev)?;
Ok(first_parent_changes(&repo, rev)?.files)
}
struct CommitChanges {
id: ObjectId,
parent: Option<ObjectId>,
files: Vec<ChangedFile>,
}
/// Files changed by `rev` relative to its first parent (renames detected), sorted by path.
fn first_parent_changes(repo: &gix::Repository, rev: &str) -> IdeResult<CommitChanges> {
let id = resolve(repo, rev)?;
let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?;
let id = commit.id;
let parent = commit.parent_ids().next().map(|p| p.detach());
let new_tree = commit.tree().map_err(gx)?;
let old_tree = match commit.parent_ids().next() {
Some(p) => Some(repo.find_commit(p.detach()).map_err(gx)?.tree().map_err(gx)?),
let old_tree = match parent {
Some(p) => Some(repo.find_commit(p).map_err(gx)?.tree().map_err(gx)?),
None => None,
};
let mut opts = gix::diff::Options::default();
@@ -456,5 +469,67 @@ pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
out.push(file);
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(CommitChanges { id, parent, files: out })
}
/// One file's change accumulated over several commits.
struct Merged {
/// Commit before the first change and the file's path there; `None` when it was added.
before: Option<(String, String)>,
/// Commit of the last change; `None` when it was deleted.
after: Option<String>,
path: String,
}
/// Combined changes of `commits` (oldest first), merged like IDEA merges a multi-commit Git
/// Log selection: per file, the state before the first selected commit that touched it ↔
/// after the last one, following renames. Files added and then deleted drop out.
pub(crate) fn commits_changes(r: &Repo, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
let repo = r.gix()?;
let mut merged: Vec<Merged> = Vec::new();
let mut seen = HashSet::new();
for rev in commits {
let c = first_parent_changes(&repo, rev)?;
if !seen.insert(c.id) {
continue;
}
let id = c.id.to_string();
for f in c.files {
let from = match f.kind {
ChangeKind::Renamed => f.old_path.as_deref().unwrap_or(&f.path),
_ => &f.path,
};
// A live entry at the path this change starts from, else a deleted one being re-added.
let existing = merged
.iter()
.position(|m| m.after.is_some() && m.path == from)
.or_else(|| merged.iter().position(|m| m.after.is_none() && m.path == f.path));
let i = existing.unwrap_or_else(|| {
let before = match (f.kind, c.parent) {
(ChangeKind::Added, _) | (_, None) => None,
(_, Some(p)) => Some((p.to_string(), from.to_owned())),
};
merged.push(Merged { before, after: None, path: f.path.clone() });
merged.len() - 1
});
let entry = &mut merged[i];
entry.after = (f.kind != ChangeKind::Deleted).then(|| id.clone());
entry.path = f.path;
}
}
let mut out: Vec<RevisionChange> = merged
.into_iter()
.filter_map(|m| {
let (kind, path, old_path) = match (&m.before, &m.after) {
(None, None) => return None,
(None, Some(_)) => (ChangeKind::Added, m.path, None),
(Some((_, old)), None) => (ChangeKind::Deleted, old.clone(), None),
(Some((_, old)), Some(_)) if *old != m.path => (ChangeKind::Renamed, m.path, Some(old.clone())),
(Some(_), Some(_)) => (ChangeKind::Modified, m.path, None),
};
Some(RevisionChange { path, kind, old_path, before: m.before.map(|(rev, _)| rev), after: m.after })
})
.collect();
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
+20
Переглянути файл
@@ -205,6 +205,21 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// A file changed across several commits: its content at `before` ↔ at `after`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RevisionChange {
/// Repository-relative path after the change (before it, for a deletion).
pub path: String,
pub kind: ChangeKind,
/// Path at `before`, for a rename.
pub old_path: Option<String>,
/// Commit holding the old content; `None` when the file was added.
pub before: Option<String>,
/// Commit holding the new content; `None` when the file was deleted.
pub after: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Stash {
@@ -546,6 +561,11 @@ impl Repo {
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
history::commit_files(self, commit)
}
/// Combined changes of several commits (oldest first): per file, before the first commit
/// that touched it ↔ after the last one (IDEA's multi-commit Git Log selection).
pub fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
history::commits_changes(self, commits)
}
/// Local and remote branches; the current branch first, then most recent first.
pub fn branches(&self) -> IdeResult<Vec<Branch>> {
refs::branches(self)
+61
Переглянути файл
@@ -543,6 +543,67 @@ fn commit_files_kinds() {
assert!(repo.commit_files("HEAD").unwrap().len() == 4);
}
/// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔
/// after the last one, contiguous or not.
#[test]
fn commits_changes_merge_selected_commits() {
let fx = Fixture::new();
fx.write("a.txt", "a0\n");
fx.write("b.txt", "b0\n");
fx.write("gone.txt", "g\n");
fx.write("move.txt", "a fairly long line of content\nanother line\nand a third\n");
let c0 = fx.commit_all("c0");
fx.write("a.txt", "a1\n");
fx.write("tmp.txt", "t\n");
let c1 = fx.commit_all("c1");
fx.write("b.txt", "b2\n");
fx.git(&["mv", "move.txt", "moved.txt"]);
let c2 = fx.commit_all("c2");
fx.write("a.txt", "a3\n");
std::fs::remove_file(fx.path("tmp.txt")).unwrap();
std::fs::remove_file(fx.path("gone.txt")).unwrap();
std::fs::create_dir(fx.path("dir")).unwrap();
fx.git(&["mv", "moved.txt", "dir/final.txt"]);
let c3 = fx.commit_all("c3");
let repo = fx.repo();
let changes = |ids: &[&String]| {
let ids: Vec<String> = ids.iter().map(|s| (*s).clone()).collect();
// Sorted by path.
repo.commits_changes(&ids)
.unwrap()
.into_iter()
.map(|c| (c.path, c.kind, c.old_path, c.before, c.after))
.collect::<Vec<_>>()
};
let s = |x: &String| Some(x.clone());
// Contiguous c1..c3: tmp.txt added then deleted drops out; the rename chain collapses.
assert_eq!(
changes(&[&c1, &c2, &c3]),
vec![
("a.txt".into(), ChangeKind::Modified, None, s(&c0), s(&c3)),
("b.txt".into(), ChangeKind::Modified, None, s(&c1), s(&c2)),
("dir/final.txt".into(), ChangeKind::Renamed, Some("move.txt".into()), s(&c1), s(&c3)),
("gone.txt".into(), ChangeKind::Deleted, None, s(&c2), None),
]
);
// Non-contiguous c1 + c3: each file from before its first selected change.
assert_eq!(
changes(&[&c1, &c3]),
vec![
("a.txt".into(), ChangeKind::Modified, None, s(&c0), s(&c3)),
("dir/final.txt".into(), ChangeKind::Renamed, Some("moved.txt".into()), s(&c2), s(&c3)),
("gone.txt".into(), ChangeKind::Deleted, None, s(&c2), None),
]
);
// The root commit has no parent: everything is added; a duplicate id counts once.
let root = changes(&[&c0, &c0]);
assert_eq!(root.len(), 4);
assert!(root.iter().all(|c| c.1 == ChangeKind::Added && c.3.is_none() && c.4 == s(&c0)));
// A single commit matches its own changed files.
assert_eq!(changes(&[&c2]).len(), repo.commit_files(&c2).unwrap().len());
}
#[test]
fn file_at_revisions_and_index() {
let fx = Fixture::new();
+1
Переглянути файл
@@ -157,6 +157,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_blame,
vcs::vcs_log,
vcs::vcs_commit_files,
vcs::vcs_commits_changes,
vcs::vcs_branches,
vcs::vcs_stashes,
vcs::vcs_stage,
+13
Переглянути файл
@@ -121,6 +121,19 @@ pub async fn vcs_commit_files(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
#[tauri::command]
#[specta::specta]
pub async fn vcs_commits_changes(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
+15
Переглянути файл
@@ -318,6 +318,8 @@ export const commands = {
vcsBlame: (path: string) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
/** Combined changes of several commits, `commits` oldest first. */
vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError<RevisionChange[], IdeError>(__TAURI_INVOKE("vcs_commits_changes", { repo, commits })),
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
@@ -3902,6 +3904,19 @@ export type ResetMode = "soft" | "mixed" | "hard" | "keep";
export type ReviewEvent = "approve" | "requestChanges" | "comment";
/** A file changed across several commits: its content at `before` ↔ at `after`. */
export type RevisionChange = {
/** Repository-relative path after the change (before it, for a deletion). */
path: string,
kind: ChangeKind,
/** Path at `before`, for a rename. */
oldPath: string | null,
/** Commit holding the old content; `None` when the file was added. */
before: string | null,
/** Commit holding the new content; `None` when the file was deleted. */
after: string | null,
};
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
+9 -1
Переглянути файл
@@ -326,6 +326,14 @@
color: var(--fg-subtle);
line-height: 1.5;
}
.commitEntry + .commitEntry {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.metaCompact {
margin-top: 2px;
}
.meta .refs {
margin-left: 0;
max-width: none;
@@ -366,7 +374,7 @@
font-size: var(--fs-small);
line-height: 18px;
-webkit-user-select: text;
cursor: text;
cursor: text;
-webkit-user-select: text;
user-select: text;
}
+77 -42
Переглянути файл
@@ -13,7 +13,7 @@ import {
} from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { ChangedFile, Commit } from "@/bindings";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import {
EmptyState,
@@ -31,7 +31,7 @@ import {
type MenuItem,
T,
} from "@/ui";
import { commitFileDiffData, openDiff, withChain } from "./diffInput";
import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain } from "./diffInput";
import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph";
import {
extendCommitSelection,
@@ -114,7 +114,7 @@ export function GitToolWindow() {
function LogView() {
const version = useVcs((s) => s.version);
const commits = useGitLog((s) => s.commits);
const selected = useGitLog((s) => s.selected);
const selection = useGitLog((s) => s.selection);
const root = useVcs((s) => s.root);
const repo = useVcs((s) => s.repo);
const lastRefs = useRef<string | null>(null);
@@ -151,7 +151,7 @@ function LogView() {
</SplitPanel>
<SplitHandle />
<SplitPanel id="details" defaultSize="28%" minSize="15%">
<CommitDetails key={selected ?? "none"} commits={commits} />
<CommitDetails key={selection.join(" ") || "none"} commits={commits} />
</SplitPanel>
</SplitGroup>
);
@@ -540,6 +540,9 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "action", action: "vcs.log.copyRevision", arg: selection, label: "Copy Revision Numbers" },
{ kind: "action", action: "vcs.log.createPatch", arg: selection },
{ kind: "action", action: "vcs.log.cherryPick", arg: selection },
...(selection.length === 2
? [{ kind: "action", action: "vcs.log.compareVersions", arg: selection } satisfies MenuItem]
: []),
{ kind: "separator" },
{ kind: "action", action: "vcs.log.revert", arg: selection, label: "Revert Commits" },
{ kind: "separator" },
@@ -784,7 +787,10 @@ const CommitRow = memo(function CommitRow({
<span className={styles.colSubject}>
{graph && <GraphCell row={graph} head={head} />}
{opts.refsOnLeft && refs}
<span className={clsx(styles.subject, head && styles.headSubject, merge && styles.mergeSubject)} title={c.message}>
<span
className={clsx(styles.subject, head && styles.headSubject, merge && styles.mergeSubject)}
title={c.message}
>
{c.summary}
</span>
{!opts.refsOnLeft && refs}
@@ -883,24 +889,39 @@ export function RefLabels({
);
}
function CommitDetails({ commits }: { commits: Commit[] }) {
/**
* The selected commits' changes: one commit's files against its first parent, or, for several
* (IDEA), their combined changes with every selected commit listed below.
*/
export function CommitDetails({ commits }: { commits: Commit[] }) {
const selected = useGitLog((s) => s.selected);
const selection = useGitLog((s) => s.selection);
// Newest first (log order).
const chosen = useMemo(() => {
const ids = new Set(selection);
return commits.filter((c) => ids.has(c.id));
}, [commits, selection]);
const commit = selected ? commits.find((c) => c.id === selected) : undefined;
const many = chosen.length > 1;
const idsKey = many ? chosen.map((c) => c.id).join(" ") : (selected ?? "");
const root = useVcs((s) => s.root) ?? "";
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const [files, setFiles] = useState<(ChangedFile | RevisionChange)[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string> | null>(null);
useEffect(() => {
if (!selected) return;
if (!idsKey) return;
const ids = idsKey.split(" ");
let alive = true;
call(commands.vcsCommitFiles(currentRepo(), selected))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
const load =
ids.length > 1
? call(commands.vcsCommitsChanges(currentRepo(), ids.reverse()))
: call(commands.vcsCommitFiles(currentRepo(), ids[0]!));
load.then((f) => alive && setFiles(f)).catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [selected]);
}, [idsKey]);
const tree = useMemo(() => buildFileTree(files ?? [], (f) => relTo(root, f.path)), [files, root]);
const allDirs = useMemo(() => dirKeys(tree), [tree]);
@@ -918,13 +939,11 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
const all = files ?? [f];
void openDiff(
withChain(
all.map((x) => commitFileDiffData(commit, x)),
all.map((x) => (many ? revisionChangeDiffData(x as RevisionChange) : commitFileDiffData(commit, x))),
Math.max(0, all.indexOf(f)),
),
);
};
const [subject, ...bodyLines] = commit.message.replace(/\s+$/, "").split("\n");
const body = bodyLines.join("\n").replace(/^\n+/, "");
return (
<div className={styles.details}>
@@ -934,6 +953,7 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
<div className={styles.detailsBar} role="toolbar" aria-label="Changed files">
<span className={styles.detailsCount}>
{files ? `${files.length} file${files.length === 1 ? "" : "s"}` : ""}
{many ? ` in ${chosen.length} commits` : ""}
</span>
<span className={styles.grow} />
<IconButton
@@ -976,33 +996,10 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
<SplitHandle />
<SplitPanel id="message" minSize="60px">
<div className={styles.messageBox}>
<div className={styles.subjectLine}>
<IssueText text={subject ?? ""} />
</div>
{body && (
<div className={styles.fullMessage}>
<IssueText text={body} />
</div>
)}
<div className={styles.meta}>
<button
type="button"
className={clsx(styles.linkButton, styles.hash)}
title="Copy revision number"
onClick={() => void navigator.clipboard?.writeText(commit.id)}
>
{commit.shortId}
</button>{" "}
{commit.author} &lt;{commit.email}&gt;
{commit.timeMs ? ` on ${new Date(commit.timeMs).toLocaleString()}` : ""}
</div>
{commit.parents.length > 1 && (
<div className={styles.meta}>Merge of {commit.parents.map((p) => p.slice(0, 8)).join(", ")}</div>
)}
{commit.refs.length > 0 && (
<div className={styles.meta}>
<RefLabels refs={commit.refs} />
</div>
{many ? (
chosen.map((c) => <CommitMessage key={c.id} commit={c} compact />)
) : (
<CommitMessage commit={commit} />
)}
</div>
</SplitPanel>
@@ -1011,6 +1008,44 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
);
}
/** A commit's message and metadata; `compact` (one of several selected): without the body. */
function CommitMessage({ commit, compact = false }: { commit: Commit; compact?: boolean }) {
const [subject, ...bodyLines] = commit.message.replace(/\s+$/, "").split("\n");
const body = compact ? "" : bodyLines.join("\n").replace(/^\n+/, "");
return (
<div className={clsx(compact && styles.commitEntry)}>
<div className={styles.subjectLine}>
<IssueText text={subject ?? ""} />
</div>
{body && (
<div className={styles.fullMessage}>
<IssueText text={body} />
</div>
)}
<div className={clsx(styles.meta, compact && styles.metaCompact)}>
<button
type="button"
className={clsx(styles.linkButton, styles.hash)}
title="Copy revision number"
onClick={() => void navigator.clipboard?.writeText(commit.id)}
>
{commit.shortId}
</button>{" "}
{commit.author} &lt;{commit.email}&gt;
{commit.timeMs ? ` on ${new Date(commit.timeMs).toLocaleString()}` : ""}
</div>
{commit.parents.length > 1 && (
<div className={styles.meta}>Merge of {commit.parents.map((p) => p.slice(0, 8)).join(", ")}</div>
)}
{commit.refs.length > 0 && (
<div className={styles.meta}>
<RefLabels refs={commit.refs} />
</div>
)}
</div>
);
}
function ChangedFileRow({ node }: { node: FileTreeNode<ChangedFile> }) {
if (!node.file)
return (
+79 -4
Переглянути файл
@@ -3,7 +3,7 @@
* file under it (deleted ones included) — plus labels and system events
* ("Before Git checkout"), with diff and revert.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HistoryEntry } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { relativePath, useProject } from "@/platform/project";
@@ -38,6 +38,22 @@ export function snapshotsBefore(entries: readonly HistoryEntry[], at: number): M
return out;
}
/**
* Several revisions selected (IDEA): the oldest and newest of them when they are snapshots of
* one file, to diff against each other. `entries` are newest first.
*/
export function revisionRange(
entries: readonly HistoryEntry[],
selected: Iterable<number>,
): { older: HistoryEntry; newer: HistoryEntry } | null {
const picked = [...selected]
.sort((a, b) => a - b)
.map((i) => entries[i])
.filter((e): e is HistoryEntry => !!e && !isLabel(e));
if (picked.length < 2 || picked.some((e) => e.path !== picked[0]!.path)) return null;
return { newer: picked[0]!, older: picked[picked.length - 1]! };
}
export interface HistoryView {
entries: HistoryEntry[];
deleted: ReadonlySet<string>;
@@ -51,6 +67,11 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
const [view, setView] = useState<HistoryView | null>(null);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState(0);
// Rows selected besides the active one: Cmd/Ctrl-click adds, Shift-click extends from `anchor`.
const [extra, setExtra] = useState<ReadonlySet<number>>(() => new Set());
const anchor = useRef(0);
const previous = useRef(0);
const pointer = useRef(false);
const load = useCallback(() => {
call(commands.historyListDir(path))
@@ -58,6 +79,7 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
const entries = [...h.entries].sort((a, b) => time(b) - time(a));
const folder = entries.some((e) => !isLabel(e) && e.path !== path);
setView({ entries, deleted: new Set(h.deleted), folder });
setExtra(new Set());
setError(null);
})
.catch((e) => setError(errorMessage(e)));
@@ -122,6 +144,9 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
/>
);
const rangeLabel = (e: HistoryEntry) => `${formatTime(e.timestampMs)} · ${e.label}`;
const range = extra.size ? revisionRange(entries, [active, ...extra]) : null;
let detail;
if (!current)
detail = (
@@ -129,6 +154,23 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
<T>Select a revision</T>
</div>
);
else if (range)
detail = (
<DiffEditor
key={range.older.id + range.newer.id + range.newer.path}
input={diffInput({
path: range.newer.path,
left: { type: "history", id: range.older.id, label: rangeLabel(range.older) },
right: { type: "history", id: range.newer.id, label: rangeLabel(range.newer) },
})}
/>
);
else if (extra.size)
detail = (
<div className={styles.message}>
<T>Select revisions of one file to compare them</T>
</div>
);
else if (!isLabel(current)) detail = diffFor(current, !!view?.deleted.has(current.path));
else if (fileAtLabel) detail = diffFor(fileAtLabel, !!view?.deleted.has(path));
else
@@ -145,7 +187,12 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
return (
<div className={styles.root}>
<div className={styles.sidebar}>
<div
className={styles.sidebar}
onMouseDownCapture={(e) => {
pointer.current = e.button === 0 && !!(e.target as Element).closest('[role="option"]');
}}
>
<div className={styles.header}>
<span className={styles.title}>
<T>Local History</T>
@@ -165,8 +212,36 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
items={entries}
getKey={(e) => `${e.kind ?? "snapshot"}:${e.id}:${e.path}:${e.timestampMs ?? ""}`}
activeIndex={Math.min(active, entries.length - 1)}
onActiveChange={setActive}
onActivate={(_e, i) => setActive(i)}
isSelected={(_e, i) => extra.has(i)}
onActiveChange={(i) => {
previous.current = active;
// Keyboard navigation selects one row; clicks are resolved in onActivate.
if (!pointer.current) {
anchor.current = i;
setExtra(new Set());
}
setActive(i);
}}
onActivate={(_e, i, ev) => {
if (!pointer.current) return setActive(i);
pointer.current = false;
setActive(i);
if (ev.shiftKey) {
const [from, to] = [Math.min(anchor.current, i), Math.max(anchor.current, i)];
setExtra(new Set(Array.from({ length: to - from + 1 }, (_, k) => from + k).filter((k) => k !== i)));
} else if (ev.metaKey || ev.ctrlKey) {
anchor.current = i;
setExtra((s) => {
const next = new Set(s);
if (next.has(i)) next.delete(i);
else if (previous.current !== i) next.add(previous.current);
return next;
});
} else {
anchor.current = i;
setExtra(new Set());
}
}}
rowHeight={view.folder ? 52 : 38}
aria-label="Revisions"
className={styles.list}
+25 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chainNeighbour, withChain, type DiffData } from "../diffInput";
import { chainNeighbour, revisionChangeDiffData, withChain, type DiffData } from "../diffInput";
const diff = (path: string): DiffData => ({ path, left: { type: "rev", rev: "HEAD" }, right: { type: "worktree" } });
@@ -23,3 +23,27 @@ describe("diff chain", () => {
expect(chainNeighbour(diff("/r/a"), 1)).toBeNull();
});
});
describe("combined changes of several commits", () => {
const base = { oldPath: null, before: "b".repeat(40), after: "a".repeat(40) };
it("diffs the state before the first commit with the state after the last", () => {
expect(revisionChangeDiffData({ ...base, path: "src/x.ts", kind: "modified" })).toMatchObject({
path: "src/x.ts",
left: { type: "rev", rev: base.before, label: "bbbbbbbb" },
right: { type: "rev", rev: base.after, label: "aaaaaaaa" },
});
});
it("reads a renamed file's old content at its old path", () => {
const d = revisionChangeDiffData({ ...base, path: "new.ts", oldPath: "old.ts", kind: "renamed" });
expect(d.left).toMatchObject({ type: "rev", rev: base.before, path: "old.ts" });
});
it("shows an empty side for added and deleted files", () => {
const added = revisionChangeDiffData({ ...base, path: "n.ts", kind: "added", before: null });
expect(added.left).toMatchObject({ type: "text", text: "" });
const deleted = revisionChangeDiffData({ ...base, path: "d.ts", kind: "deleted", after: null });
expect(deleted.right).toMatchObject({ type: "text", text: "" });
});
});
+9 -11
Переглянути файл
@@ -4,13 +4,7 @@ import { operationActions } from "../BranchWidget";
import { controlLabel, inProgress, operationControls, operationTitle, validateTagName } from "../operations";
import { suggestedPatchName } from "../patches";
import { commitMenu } from "../GitLog";
import {
extendCommitSelection,
selectCommit,
selectedCommits,
toggleCommitSelection,
useGitLog,
} from "../logStore";
import { extendCommitSelection, selectCommit, selectedCommits, toggleCommitSelection, useGitLog } from "../logStore";
const commit = (summary: string, id = "0123456789abcdef0123456789abcdef01234567"): Commit => ({
id,
@@ -89,8 +83,7 @@ describe("Git Log commit actions", () => {
const local = commit("Local", "1".repeat(40));
const pushed = commit("Pushed", "2".repeat(40));
useGitLog.setState({ outgoing: new Set([local.id]) });
const disabled = (c: Commit) =>
commitMenu(c).flatMap((i) => (i.kind === "action" && i.disabled ? [i.action] : []));
const disabled = (c: Commit) => commitMenu(c).flatMap((i) => (i.kind === "action" && i.disabled ? [i.action] : []));
expect(disabled(local)).not.toContain("vcs.log.editMessage");
expect(disabled(pushed)).toEqual(
expect.arrayContaining([
@@ -108,15 +101,20 @@ describe("Git Log commit actions", () => {
it("offers the multi-commit actions for a multiple selection", () => {
const a = commit("A", "a".repeat(40));
const b = commit("B", "b".repeat(40));
const ids = commitMenu(a, [a, b]).flatMap((i) => (i.kind === "action" ? [i.action] : []));
expect(ids).toEqual([
const c = commit("C", "c".repeat(40));
const ids = (sel: Commit[]) => commitMenu(a, sel).flatMap((i) => (i.kind === "action" ? [i.action] : []));
expect(ids([a, b])).toEqual([
"vcs.log.copyRevision",
"vcs.log.createPatch",
"vcs.log.cherryPick",
"vcs.log.compareVersions",
"vcs.log.revert",
"vcs.log.squashCommits",
"vcs.log.dropCommit",
]);
// Compare Versions needs exactly two commits (IDEA).
expect(ids([a, b, c])).not.toContain("vcs.log.compareVersions");
expect(ids([a, b, c])).toContain("vcs.log.cherryPick");
});
it("extends and toggles the log selection", () => {
+63 -4
Переглянути файл
@@ -19,10 +19,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
},
};
});
vi.mock("../DiffEditor", () => ({ DiffEditor: () => <div data-testid="diff" /> }));
vi.mock("../DiffEditor", () => ({
DiffEditor: ({ input }: { input: { data: { left: { label?: string }; right: { label?: string } } } }) => (
<div data-testid="diff">
{input.data.left.label} | {input.data.right.label}
</div>
),
}));
import { useProject } from "@/platform/project";
import { LocalHistoryEditor, localHistoryInput, snapshotsBefore } from "../LocalHistory";
import { LocalHistoryEditor, localHistoryInput, revisionRange, snapshotsBefore } from "../LocalHistory";
import { putLabel } from "../localHistoryActions";
import { VcsDialogHost } from "../dialogs";
import { MessagesHost } from "@/workbench/messages";
@@ -36,7 +42,14 @@ const snap = (path: string, t: number, label: string, c: string): HistoryEntry =
size: 3,
kind: "snapshot",
});
const label = (t: number, name: string): HistoryEntry => ({ id: "", path: "/p", timestampMs: t, label: name, size: 0, kind: "label" });
const label = (t: number, name: string): HistoryEntry => ({
id: "",
path: "/p",
timestampMs: t,
label: name,
size: 0,
kind: "label",
});
beforeEach(() => {
historyListDir.mockReset();
@@ -47,7 +60,12 @@ beforeEach(() => {
describe("Local History", () => {
it("finds each file's revision at a label", () => {
const entries = [snap("/p/a", 1, "Saved", "a"), snap("/p/a", 3, "Saved", "b"), snap("/p/b", 2, "Saved", "c"), label(4, "L")];
const entries = [
snap("/p/a", 1, "Saved", "a"),
snap("/p/a", 3, "Saved", "b"),
snap("/p/b", 2, "Saved", "c"),
label(4, "L"),
];
const at = snapshotsBefore(entries, 2.5);
expect([...at.keys()].sort()).toEqual(["/p/a", "/p/b"]);
expect(at.get("/p/a")!.id).toBe(id("a"));
@@ -93,6 +111,47 @@ describe("Local History", () => {
expect(screen.getByTestId("diff")).toBeInTheDocument();
});
it("picks the oldest and newest of several selected revisions of one file", () => {
const entries = [snap("/p/a", 3, "C", "c"), label(2, "L"), snap("/p/a", 1, "B", "b"), snap("/p/b", 0, "X", "x")];
expect(revisionRange(entries, [2, 0, 1])).toMatchObject({ newer: { label: "C" }, older: { label: "B" } });
expect(revisionRange(entries, [0])).toBeNull();
expect(revisionRange(entries, [0, 1])).toBeNull();
// Revisions of different files have nothing to diff.
expect(revisionRange(entries, [0, 3])).toBeNull();
});
it("diffs two revisions selected with Shift- or Cmd-click", async () => {
const data: DirectoryHistory = {
entries: [
snap("/p/a.ts", 30, "Third", "c"),
snap("/p/a.ts", 20, "Second", "b"),
snap("/p/a.ts", 10, "First", "a"),
],
deleted: [],
};
historyListDir.mockResolvedValue({ status: "ok", data });
render(<LocalHistoryEditor input={localHistoryInput("/p/a.ts")} />);
const list = await screen.findByRole("listbox", { name: "Revisions" });
const rows = () => within(list).getAllByRole("option");
const click = (i: number, mods: { shiftKey?: boolean; metaKey?: boolean } = {}) => {
fireEvent.mouseDown(rows()[i]!, { button: 0, ...mods });
fireEvent.click(rows()[i]!, mods);
};
expect(screen.getByTestId("diff")).toHaveTextContent(/Third \| Current/);
click(2, { shiftKey: true });
expect(rows().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(3);
expect(screen.getByTestId("diff")).toHaveTextContent(/First \| .*Third/);
click(1);
click(0, { metaKey: true });
expect(screen.getByTestId("diff")).toHaveTextContent(/Second \| .*Third/);
// Keyboard navigation goes back to a single revision.
fireEvent.keyDown(list, { key: "ArrowDown" });
expect(screen.getByTestId("diff")).toHaveTextContent(/Second \| Current/);
});
it("puts a label", async () => {
render(<VcsDialogHost />);
const done = putLabel();
+72
Переглянути файл
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Commit } from "@/bindings";
const vcsCommitFiles = vi.fn();
const vcsCommitsChanges = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id),
vcsCommitsChanges: (repo: unknown, ids: string[]) => vcsCommitsChanges(repo, ids),
},
};
});
import { CommitDetails } from "../GitLog";
import { useGitLog } from "../logStore";
const commit = (summary: string, d: string): Commit => ({
id: d.repeat(40),
shortId: d.repeat(8),
parents: [],
author: "A",
email: "[email protected]",
timeMs: 0,
summary,
message: `${summary}\n\nBody of ${summary}`,
refs: [],
});
// Log order: newest first.
const log = [commit("Third", "3"), commit("Second", "2"), commit("First", "1")];
afterEach(() => {
useGitLog.setState({ commits: [], selection: [], selected: null });
vcsCommitFiles.mockReset();
vcsCommitsChanges.mockReset();
});
describe("Git Log commit details", () => {
it("shows one commit's files and full message", async () => {
vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "a.ts", kind: "modified", oldPath: null }] });
useGitLog.setState({ commits: log, selected: log[1]!.id, selection: [log[1]!.id] });
render(<CommitDetails commits={log} />);
expect(await screen.findByText("1 file")).toBeInTheDocument();
expect(vcsCommitFiles).toHaveBeenCalledWith(null, log[1]!.id);
expect(screen.getByText("Body of Second")).toBeInTheDocument();
});
it("combines the changes of several selected commits, oldest first, and lists them", async () => {
vcsCommitsChanges.mockResolvedValue({
status: "ok",
data: [
{ path: "a.ts", kind: "modified", oldPath: null, before: "0".repeat(40), after: log[0]!.id },
{ path: "b.ts", kind: "added", oldPath: null, before: null, after: log[2]!.id },
],
});
// Selected in click order (Cmd-click), not log order; non-contiguous.
useGitLog.setState({ commits: log, selected: log[0]!.id, selection: [log[2]!.id, log[0]!.id] });
render(<CommitDetails commits={log} />);
expect(await screen.findByText(/2 files\s*in 2 commits/)).toBeInTheDocument();
expect(vcsCommitsChanges).toHaveBeenCalledWith(null, [log[2]!.id, log[0]!.id]);
expect(vcsCommitFiles).not.toHaveBeenCalled();
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Third")).toBeInTheDocument();
expect(screen.queryByText("Second")).toBeNull();
});
});
+24 -4
Переглянути файл
@@ -1,5 +1,5 @@
/** Diff editor inputs and helpers to open them. */
import type { ChangedFile } from "@/bindings";
import type { ChangedFile, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { baseName, effectiveKind } from "./model";
@@ -131,8 +131,7 @@ export function workingDiffData(path: string, line?: number): DiffData {
: st?.renamedFrom
? { type: "rev", rev: "HEAD", path: renamedFromPath(st) ?? undefined, label: `HEAD (${st.renamedFrom})` }
: { type: "rev", rev: "HEAD" };
const right: DiffSide =
kind === "deleted" ? { type: "text", text: "", label: "(deleted)" } : { type: "worktree" };
const right: DiffSide = kind === "deleted" ? { type: "text", text: "", label: "(deleted)" } : { type: "worktree" };
return { path, left, right, line };
}
@@ -143,7 +142,10 @@ function joinRoot(rel: string): string {
}
/** Diff of one file changed by `commit` (first parent ↔ commit). */
export function commitFileDiffData(commit: { id: string; shortId: string; parents: string[] }, file: ChangedFile): DiffData {
export function commitFileDiffData(
commit: { id: string; shortId: string; parents: string[] },
file: ChangedFile,
): DiffData {
const path = joinRoot(file.path);
const parent = commit.parents[0];
const left: DiffSide =
@@ -162,6 +164,24 @@ export function commitFileDiffData(commit: { id: string; shortId: string; parent
return { path, left, right };
}
/** Diff of one file of several commits' combined changes (`before` ↔ `after`). */
export function revisionChangeDiffData(change: RevisionChange): DiffData {
const path = joinRoot(change.path);
const { before, after } = change;
const left: DiffSide = before
? {
type: "rev",
rev: before,
path: change.oldPath ? joinRoot(change.oldPath) : undefined,
label: before.slice(0, 8),
}
: { type: "text", text: "", label: "(does not exist)" };
const right: DiffSide = after
? { type: "rev", rev: after, label: after.slice(0, 8) }
: { type: "text", text: "", label: "(deleted)" };
return { path, left, right };
}
export async function loadSide(path: string, side: DiffSide): Promise<string> {
switch (side.type) {
case "rev":
+16
Переглянути файл
@@ -28,6 +28,7 @@ import {
inProgress,
newTagOn,
revertCommits,
showCompareVersions,
showCompareWithLocal,
showResetDialog,
showTagDialog,
@@ -268,6 +269,21 @@ export function registerHistoryActions(): () => void {
when: VCS,
run: withCommit(showCompareWithLocal),
},
{
id: "vcs.log.compareVersions",
title: "Compare Versions",
category: "Git Log",
icon: "diff",
when: VCS,
run: withCommits((cs) => {
if (cs.length !== 2) {
notifications().notify({ group: "vcs", level: "info", title: "Select two commits to compare" });
return;
}
// Newest first, as the log shows them.
showCompareVersions(cs[1]!, cs[0]!);
}),
},
{
id: "vcs.log.resetToHere",
title: "Reset Current Branch to Here…",
+36 -8
Переглянути файл
@@ -9,7 +9,7 @@ import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { openDiff } from "./diffInput";
import { openDiff, type DiffSide } from "./diffInput";
import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { runGitOperation, runOp } from "./ops";
@@ -399,36 +399,64 @@ export function showCompareWithLocal(commit: Commit) {
/** Files that differ between `rev` (a commit or branch) and the working tree. */
export function showDiffWithWorkingTree(rev: string, label = rev) {
openDialog((close) => <CompareWithLocalDialog rev={rev} label={label} onClose={close} />);
openDialog((close) => <ChangesBetweenDialog from={{ rev, label }} to={null} onClose={close} />);
}
function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: string; onClose: () => void }) {
/** IDEA's Compare Versions of two Git Log commits: files that differ between them, older first. */
export function showCompareVersions(older: Commit, newer: Commit) {
openDialog((close) => (
<ChangesBetweenDialog
from={{ rev: older.id, label: short(older.id) }}
to={{ rev: newer.id, label: short(newer.id) }}
onClose={close}
/>
));
}
interface Revision {
rev: string;
label: string;
}
/** Files that differ between `from` and `to` (`null`: the working tree). */
function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revision | null; onClose: () => void }) {
const root = useVcs((s) => s.root) ?? "";
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState(0);
const fromRev = from.rev;
const toRev = to?.rev ?? null;
useEffect(() => {
let alive = true;
call(commands.vcsDiffWithWorktree(currentRepo(), rev))
const repo = currentRepo();
call(toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [rev]);
}, [fromRev, toRev]);
const open = (f: ChangedFile) => {
const repoRoot = currentRepoRoot() ?? root;
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
void openDiff({
path: `${repoRoot}/${f.path}`,
left: { type: "rev", rev, label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: { type: "worktree" },
left:
f.kind === "added" || f.kind === "untracked"
? missing("(does not exist)")
: { type: "rev", rev: from.rev, label: from.label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: !to
? { type: "worktree" }
: f.kind === "deleted"
? missing("(deleted)")
: { type: "rev", rev: to.rev, label: to.label },
});
};
const current = files?.[active];
return (
<Dialog
open
title={`Changes between ${label} and Local Working Tree`}
title={`Changes between ${from.label} and ${to ? to.label : "Local Working Tree"}`}
onClose={onClose}
width={620}
buttons={[