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

+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":