дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
aa5821bb46
коміт
b2fec4ae30
16 files changed
+599
-79
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} <{commit.email}>
|
||||
{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} <{commit.email}>
|
||||
{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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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={[
|
||||
|
||||
Reference in new issue
Заблокувати користувача