diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 5d0c9e782..b2125fd29 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -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> { let repo = r.gix()?; - let id = resolve(&repo, rev)?; + Ok(first_parent_changes(&repo, rev)?.files) +} + +struct CommitChanges { + id: ObjectId, + parent: Option, + files: Vec, +} + +/// Files changed by `rev` relative to its first parent (renames detected), sorted by path. +fn first_parent_changes(repo: &gix::Repository, rev: &str) -> IdeResult { + 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> { 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, + 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> { + let repo = r.gix()?; + let mut merged: Vec = 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 = 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) } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 44e2dae9a..cb3774434 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -205,6 +205,21 @@ pub struct ChangedFile { pub old_path: Option, } +/// 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, + /// Commit holding the old content; `None` when the file was added. + pub before: Option, + /// Commit holding the new content; `None` when the file was deleted. + pub after: Option, +} + #[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> { 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> { + history::commits_changes(self, commits) + } /// Local and remote branches; the current branch first, then most recent first. pub fn branches(&self) -> IdeResult> { refs::branches(self) diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 40f6a95bc..64f8a64ec 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -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 = 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::>() + }; + 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(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 19de6527b..c30394997 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -157,6 +157,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_blame, vcs::vcs_log, vcs::vcs_commit_files, + vcs::vcs_commits_changes, vcs::vcs_branches, vcs::vcs_stashes, vcs::vcs_stage, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 740762892..7f3a8ebfd 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -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, + commits: Vec, +) -> IdeResult> { + 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) -> IdeResult> { diff --git a/src/bindings.ts b/src/bindings.ts index e5b502124..e2af9937e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -318,6 +318,8 @@ export const commands = { vcsBlame: (path: string) => typedError(__TAURI_INVOKE("vcs_blame", { path })), vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), vcsCommitFiles: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { repo, commit })), + /** Combined changes of several commits, `commits` oldest first. */ + vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError(__TAURI_INVOKE("vcs_commits_changes", { repo, commits })), vcsBranches: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_branches", { repo })), vcsStashes: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_stashes", { repo })), vcsStage: (paths: string[]) => typedError(__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; diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 257ab1def..c3566418d 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -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; } diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 21093f1b6..1c67d88ed 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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(null); @@ -151,7 +151,7 @@ function LogView() { - + ); @@ -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({ {graph && } {opts.refsOnLeft && refs} - + {c.summary} {!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(null); + const [files, setFiles] = useState<(ChangedFile | RevisionChange)[] | null>(null); const [error, setError] = useState(null); const [expanded, setExpanded] = useState | 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 (
@@ -934,6 +953,7 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
{files ? `${files.length} file${files.length === 1 ? "" : "s"}` : ""} + {many ? ` in ${chosen.length} commits` : ""}
-
- -
- {body && ( -
- -
- )} -
- {" "} - {commit.author} <{commit.email}> - {commit.timeMs ? ` on ${new Date(commit.timeMs).toLocaleString()}` : ""} -
- {commit.parents.length > 1 && ( -
Merge of {commit.parents.map((p) => p.slice(0, 8)).join(", ")}
- )} - {commit.refs.length > 0 && ( -
- -
+ {many ? ( + chosen.map((c) => ) + ) : ( + )}
@@ -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 ( +
+
+ +
+ {body && ( +
+ +
+ )} +
+ {" "} + {commit.author} <{commit.email}> + {commit.timeMs ? ` on ${new Date(commit.timeMs).toLocaleString()}` : ""} +
+ {commit.parents.length > 1 && ( +
Merge of {commit.parents.map((p) => p.slice(0, 8)).join(", ")}
+ )} + {commit.refs.length > 0 && ( +
+ +
+ )} +
+ ); +} + function ChangedFileRow({ node }: { node: FileTreeNode }) { if (!node.file) return ( diff --git a/src/features/vcs/LocalHistory.tsx b/src/features/vcs/LocalHistory.tsx index 82b7f3dba..b12652d52 100644 --- a/src/features/vcs/LocalHistory.tsx +++ b/src/features/vcs/LocalHistory.tsx @@ -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, +): { 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; @@ -51,6 +67,11 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) { const [view, setView] = useState(null); const [error, setError] = useState(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>(() => 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 }) { Select a revision
); + else if (range) + detail = ( + + ); + else if (extra.size) + detail = ( +
+ Select revisions of one file to compare them +
+ ); 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 (
-
+
{ + pointer.current = e.button === 0 && !!(e.target as Element).closest('[role="option"]'); + }} + >
Local History @@ -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} diff --git a/src/features/vcs/__tests__/diffChain.test.ts b/src/features/vcs/__tests__/diffChain.test.ts index 4b3e36983..2efc2457b 100644 --- a/src/features/vcs/__tests__/diffChain.test.ts +++ b/src/features/vcs/__tests__/diffChain.test.ts @@ -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: "" }); + }); +}); diff --git a/src/features/vcs/__tests__/historyOps.test.ts b/src/features/vcs/__tests__/historyOps.test.ts index 7a9d27c01..87427edf8 100644 --- a/src/features/vcs/__tests__/historyOps.test.ts +++ b/src/features/vcs/__tests__/historyOps.test.ts @@ -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", () => { diff --git a/src/features/vcs/__tests__/localHistory.test.tsx b/src/features/vcs/__tests__/localHistory.test.tsx index 6b40a17af..9dc298364 100644 --- a/src/features/vcs/__tests__/localHistory.test.tsx +++ b/src/features/vcs/__tests__/localHistory.test.tsx @@ -19,10 +19,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => { }, }; }); -vi.mock("../DiffEditor", () => ({ DiffEditor: () =>
})); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: { data: { left: { label?: string }; right: { label?: string } } } }) => ( +
+ {input.data.left.label} | {input.data.right.label} +
+ ), +})); 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(); + 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(); const done = putLabel(); diff --git a/src/features/vcs/__tests__/logDetails.test.tsx b/src/features/vcs/__tests__/logDetails.test.tsx new file mode 100644 index 000000000..0fb74bbe3 --- /dev/null +++ b/src/features/vcs/__tests__/logDetails.test.tsx @@ -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(); + 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: "a@example.com", + 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(); + 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(); + 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(); + }); +}); diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 4700a3810..cd4a4d776 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -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 { switch (side.type) { case "rev": diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 94825bc83..2ffcb2e66 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -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…", diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index fa967e28f..f1f17559f 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -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) => ); + openDialog((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) => ( + + )); +} + +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(null); const [error, setError] = useState(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 (