feat(vcs): support multiple repositories in the Git tool window

The Git tool window followed one globally selected repository even when multiple roots were registered. Aggregate repository logs and branches, preserve repository identity in selections, history tabs, stashes, diffs and patch commands, and capture repository targets for delayed operations and dialogs. Validate tag names against the captured repository.

Fixes #195
This commit is contained in:
l0ng-ai
2026-10-29 14:30:32 +08:00
committed by GitHub
parent 75b1704f78
commit 4d503ef976
31 changed files with 1084 additions and 313 deletions
+31 -10
View File
@@ -680,13 +680,17 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
pub async fn vcs_create_patch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
source: PatchSource,
) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || {
project.with_repo(|r| match source {
project.with_repo_at(repo.as_deref(), |r| match source {
PatchSource::Changes { paths, reverse } => {
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
in_primary_repo(&project, &paths)?;
r.create_patch(&paths, reverse)
}
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
@@ -695,14 +699,24 @@ pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) ->
.await
}
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
pub async fn vcs_patch_preview(
host: &Host,
window: &str,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<Vec<PatchFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await
}
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
pub async fn vcs_apply_patch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<ApplyOutcome> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
@@ -901,9 +915,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_create_patch", |c| async move {
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
});
d.add("vcs_patch_preview", |c| async move {
vcs_patch_preview(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await
});
d.add(
"vcs_apply_patch",
|c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await },
);
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
+65
View File
@@ -0,0 +1,65 @@
//! Repository-scoped patch operations must never fall back to the primary root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn patches_read_and_write_the_requested_repository() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let project_root = base.join("project");
let roots = [project_root.join("front"), project_root.join("back")];
let mut revisions = Vec::new();
for (i, root) in roots.iter().enumerate() {
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-b", "main"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "test@example.com"]);
std::fs::write(root.join("same.txt"), format!("root {i}\n")).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-m", "initial"]);
std::fs::write(root.join("same.txt"), format!("changed {i}\n")).unwrap();
git(root, &["commit", "-am", "change"]);
revisions.push(git(root, &["rev-parse", "HEAD"]));
}
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &project_root).unwrap();
for _ in 0..500 {
if project.repos().all().len() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.repos().all().len(), 2);
for (i, root) in roots.iter().enumerate() {
let patch = vcs::vcs_create_patch(
&host,
"main",
Some(root.clone()),
vcs::PatchSource::Commits { commits: vec![revisions[i].clone()], reverse: true },
)
.await
.unwrap();
assert!(patch.contains(&format!("-changed {i}")));
assert_eq!(vcs::vcs_patch_preview(&host, "main", Some(root.clone()), patch.clone()).await.unwrap().len(), 1);
vcs::vcs_apply_patch(&host, "main", Some(root.clone()), patch).await.unwrap();
assert_eq!(std::fs::read_to_string(root.join("same.txt")).unwrap(), format!("root {i}\n"));
}
assert!(vcs::vcs_apply_patch(&host, "main", Some(base.join("outside")), String::new()).await.is_err());
host.close_project("main");
}
+21 -6
View File
@@ -520,20 +520,35 @@ pub async fn vcs_diff_revisions(
#[tauri::command]
#[specta::specta]
pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source: PatchSource) -> IdeResult<String> {
ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), source).await
pub async fn vcs_create_patch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
source: PatchSource,
) -> IdeResult<String> {
ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), repo, source).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_patch_preview(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<Vec<PatchFile>> {
ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), patch).await
pub async fn vcs_patch_preview(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<Vec<PatchFile>> {
ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), repo, patch).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_apply_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<ApplyOutcome> {
ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), patch).await
pub async fn vcs_apply_patch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<ApplyOutcome> {
ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), repo, patch).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
+3 -3
View File
@@ -474,9 +474,9 @@ export const commands = {
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
vcsCreatePatch: (source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { source })),
vcsPatchPreview: (patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { patch })),
vcsApplyPatch: (patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { patch })),
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { repo, source })),
vcsPatchPreview: (repo: string | null, patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { repo, patch })),
vcsApplyPatch: (repo: string | null, patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { repo, patch })),
/** Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. */
vcsShelve: (name: string, paths: string[]) => typedError<ShelvedChangeList, IdeError>(__TAURI_INVOKE("vcs_shelve", { name, paths })),
vcsShelves: () => typedError<ShelvedChangeList[], IdeError>(__TAURI_INVOKE("vcs_shelves")),
+4 -8
View File
@@ -1,3 +1,4 @@
import { branchesByRepo } from "./multiRoot";
/** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
import clsx from "clsx";
@@ -533,14 +534,9 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
// Every repository's branches (common ones, submenus, search); refreshed after operations.
useEffect(() => {
let live = true;
void Promise.all(
roots.map((r) =>
call(commands.vcsBranches(r)).then(
(list) => [r, list] as const,
() => [r, [] as Branch[]] as const,
),
),
).then((entries) => live && setAll(Object.fromEntries(entries)));
void branchesByRepo(roots)
.then((entries) => live && setAll(entries))
.catch(() => undefined);
return () => {
live = false;
};
+70 -18
View File
@@ -15,6 +15,7 @@ import {
IconButton,
List,
Menu,
Select,
SplitGroup,
SplitHandle,
SplitPanel,
@@ -52,7 +53,7 @@ import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash }
import { call, commands } from "@/platform/ipc";
import { OperationBanner } from "./operations";
import { ShelfPanel, loadShelves, useShelf } from "./shelf";
import { loadStashes, refreshNow, useVcs } from "./store";
import { refreshNow, repoName, selectRepo, useVcs } from "./store";
import styles from "./CommitView.module.css";
/** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */
@@ -76,7 +77,20 @@ export function tabItems(tabs: readonly ContentTab[]): TabItem[] {
/** Shelf and stash counts for the tabs (loads the shelves once VCS is available). */
export function useStashTabCounts(available: boolean) {
const stashCount = useVcs((s) => s.stashes.length);
const roots = useVcs((s) => s.roots);
const version = useVcs((s) => s.version);
const [stashCount, setStashCount] = useState(0);
useEffect(() => {
let live = true;
if (available)
void Promise.all(roots.map((r) => call(commands.vcsStashes(r))))
.then((lists) => live && setStashCount(lists.reduce((n, list) => n + list.length, 0)))
.catch(() => undefined);
else setStashCount(0);
return () => {
live = false;
};
}, [available, roots, version]);
const shelfCount = useShelf((s) => s.shelves.length);
useEffect(() => {
if (available) void loadShelves().catch(() => undefined);
@@ -825,17 +839,52 @@ function CommitPanel({ included }: { included: number }) {
// ---------------------------------------------------------------------------
export function StashesPanel() {
const stashes = useVcs((s) => s.stashes);
const loaded = useVcs((s) => s.stashesLoaded);
const repo = useVcs((s) => s.repo);
const repos = useVcs((s) => s.roots);
const version = useVcs((s) => s.version);
const [stashes, setStashes] = useState<(import("@/bindings").Stash & { repo: string | null })[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const [active, setActive] = useState(0);
useEffect(() => {
void loadStashes().catch(() => undefined);
}, []);
let live = true;
setLoaded(false);
void Promise.all(
(repos.length ? repos : [repo]).map(async (root) =>
(await call(commands.vcsStashes(root))).map((s) => ({ ...s, repo: root })),
),
)
.then((lists) => {
if (live) {
setStashes(lists.flat());
setError(null);
setLoaded(true);
}
})
.catch((e) => {
if (live) {
setError(String(e));
setLoaded(true);
}
});
return () => {
live = false;
};
}, [repos, repo, version, tick]);
const current = stashes[active];
return (
<div className={styles.changes}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Stashes")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void loadStashes()} />
{repos.length > 1 && (
<Select
aria-label="Repository"
value={repo}
onChange={(root) => void selectRepo(root)}
options={repos.map((root) => ({ value: root, label: repoName(root) }))}
/>
)}
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => setTick((n) => n + 1)} />
<IconButton icon="plus" title="Stash Changes…" size="small" onClick={() => void stashChanges()} />
<span className={styles.sep} />
<IconButton
@@ -843,52 +892,55 @@ export function StashesPanel() {
title="Apply Stash"
size="small"
disabled={!current}
onClick={() => current && void applyStash(current.index, false)}
onClick={() => current && void applyStash(current.index, false, current.repo)}
/>
<IconButton
icon="undo"
title="Pop Stash (Apply and Drop)"
size="small"
disabled={!current}
onClick={() => current && void applyStash(current.index, true)}
onClick={() => current && void applyStash(current.index, true, current.repo)}
/>
<IconButton
icon="trash"
title="Drop Stash"
size="small"
disabled={!current}
onClick={() => current && void dropStash(current.index, current.message)}
onClick={() => current && void dropStash(current.index, current.message, current.repo)}
/>
</div>
<List
items={stashes}
getKey={(s) => String(s.index)}
getKey={(s) => `${s.repo}:${s.index}`}
activeIndex={Math.min(active, Math.max(0, stashes.length - 1))}
onActiveChange={setActive}
rowHeight={22}
aria-label="Stashes"
className={styles.stashList}
empty={<span className={styles.empty}>{loaded ? tx("No stashes") : tx("Loading…")}</span>}
empty={<span className={styles.empty}>{error ?? (loaded ? tx("No stashes") : tx("Loading…"))}</span>}
onActivate={(s, _i, e) => {
if (e.type === "click" && (e as MouseEvent).detail < 2) return;
void applyStash(s.index, false);
void applyStash(s.index, false, s.repo);
}}
onContextMenu={(s, i, e) => {
e.preventDefault();
setActive(i);
showContextMenu(e, [
{ label: "Apply Stash", icon: "check", run: () => void applyStash(s.index, false) },
{ label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true) },
{ label: "Unstash…", run: () => showUnstashDialog(s.index) },
{ label: "Apply Stash", icon: "check", run: () => void applyStash(s.index, false, s.repo) },
{ label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true, s.repo) },
{ label: "Unstash…", run: () => showUnstashDialog(s.index, s.repo) },
{ kind: "separator" },
{ label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message) },
{ label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message, s.repo) },
]);
}}
renderItem={(s) => {
const { branch, text } = stashLabel(s.message);
return (
<span className={styles.stashRow} title={`stash@{${s.index}}: ${s.message}`}>
<span className={styles.stashMessage}>{text}</span>
<span className={styles.stashMessage}>
{repos.length > 1 && s.repo ? `${repoName(s.repo)} · ` : ""}
{text}
</span>
{branch && <span className={styles.detail}>{branch}</span>}
<span className={styles.detail}>{formatTime(s.timeMs)}</span>
</span>
+4 -3
View File
@@ -24,7 +24,7 @@ const commitsBetween = (from: string, to: string, repo: string | null = currentR
skip: 0,
limit: LIMIT,
}),
);
).then((commits) => commits.map((c) => ({ ...c, repo })));
export function showCompareBranches(branch: string) {
const current = useVcs.getState().branch?.head ?? "HEAD";
@@ -89,7 +89,7 @@ function CommitList({
}
onActivate={(c) => {
onClose();
void executeAction("vcs.showCommit", c.id);
void executeAction("vcs.showCommit", c);
}}
renderItem={(c) => (
<span className={styles.commitRow} style={{ flex: 1, padding: 0 }}>
@@ -106,12 +106,13 @@ function CommitList({
}
function CompareDialog({ branch, current, onClose }: { branch: string; current: string; onClose: () => void }) {
const [repo] = useState(currentRepo);
const [data, setData] = useState<{ incoming: Commit[]; outgoing: Commit[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState("incoming");
useEffect(() => {
let alive = true;
Promise.all([commitsBetween("HEAD", branch), commitsBetween(branch, "HEAD")])
Promise.all([commitsBetween("HEAD", branch, repo), commitsBetween(branch, "HEAD", repo)])
.then(([incoming, outgoing]) => {
if (alive) setData({ incoming, outgoing });
})
+8
View File
@@ -500,3 +500,11 @@
min-height: 0;
overflow: auto;
}
.colRepository {
flex: 0 0 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-secondary);
}
+182 -51
View File
@@ -1,3 +1,4 @@
import { branchesByRepo, commitKey, commitRepo, graphCommit, type RepoCommit } from "./multiRoot";
/** Git tool window: Log (graph, filters, commit details) and Console tabs. */
import { IssueText } from "./issueLinks";
import { BranchFilterPopup } from "./BranchFilterPopup";
@@ -41,6 +42,7 @@ import {
goToChild,
goToParent,
initLogBranchFilter,
isPushed,
isMine,
loadMore,
ME,
@@ -59,13 +61,14 @@ import {
import { logOptions, refLabels, useLogOptions, type LogOptions } from "./logOptions";
import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { currentRepo, loadBranches, useVcs } from "./store";
import { currentRepo, loadBranches, repoName, useVcs } from "./store";
import { loadFavoriteBranches, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchMenu, inRepo, tagMenu } from "./branchMenu";
import {
branchFolderKeys,
branchGroupKeys,
buildBranchTree,
buildRepositoryBranchTree,
filterBranchTree,
isFavoriteBranch,
type BranchTreeNode,
@@ -164,15 +167,31 @@ function LogView() {
const commits = useGitLog((s) => s.commits);
const selection = useGitLog((s) => s.selection);
const root = useVcs((s) => s.root);
const repo = useVcs((s) => s.repo);
const repo = useVcs((s) => s.roots);
const showBranches = useLogOptions((s) => s.showBranches);
const branchesLoaded = useVcs((s) => s.branchesLoaded);
const lastRefs = useRef<string | null>(null);
const allRefs = useRef<string | null>(null);
useEffect(() => {
let live = true;
void branchesByRepo(repo)
.then((all) => {
if (!live) return;
const signature = JSON.stringify(all);
if (allRefs.current !== null && allRefs.current !== signature) void reloadLog();
allRefs.current = signature;
})
.catch(() => undefined);
return () => {
live = false;
};
}, [repo, version]);
// On mount, when the window switches to another project and when another of its
// repositories is selected (its branches are needed to notice its refs moving).
useEffect(() => {
lastRefs.current = null;
allRefs.current = null;
if (!useGitLog.getState().commits.length) void reloadLog();
void loadBranches().catch(() => undefined);
}, [root, repo]);
@@ -183,7 +202,7 @@ function LogView() {
if (!branchesLoaded) return;
// The refs the log is loaded with, so the first checkout or commit after this reloads it.
const { branch, branches } = useVcs.getState();
lastRefs.current = refsSignature(branch, branches);
lastRefs.current = refsSignature(branch, branches) + JSON.stringify(useVcs.getState().heads);
const settled = initLogBranchFilter();
const { commits, loading } = useGitLog.getState();
if (settled || (!commits.length && !loading)) void reloadLog();
@@ -193,7 +212,7 @@ function LogView() {
useEffect(() => {
const { branch, branches, branchesLoaded } = useVcs.getState();
if (!branchesLoaded) return;
const refs = refsSignature(branch, branches);
const refs = refsSignature(branch, branches) + JSON.stringify(useVcs.getState().heads);
const previous = lastRefs.current;
lastRefs.current = refs;
if (previous !== null && previous !== refs && useGitLog.getState().commits.length) void reloadLog();
@@ -239,12 +258,18 @@ function branchDetail(n: BranchTreeNode): string | undefined {
}
export function BranchesPanel() {
const branches = useVcs((s) => s.branches);
const selectedBranches = useVcs((s) => s.branches);
const repositories = useVcs((s) => s.roots);
const version = useVcs((s) => s.version);
const [allBranches, setAllBranches] = useState<Record<string, Branch[]>>({});
const [allTags, setAllTags] = useState<Record<string, string[]>>({});
const branches = selectedBranches;
const root = useVcs((s) => s.root);
const branchesLoaded = useVcs((s) => s.branchesLoaded);
const current = useVcs((s) => s.branch?.head ?? null);
const favorites = useFavoriteBranches((s) => s.names);
const filterBranches = useGitLog((s) => s.filters.branches);
const filterRepositories = useGitLog((s) => s.filters.repositories);
const [query, setQuery] = useState("");
const [tags, setTags] = useState<string[]>([]);
useEffect(() => loadFavoriteBranches(), [root]);
@@ -259,14 +284,45 @@ export function BranchesPanel() {
live = false;
};
}, [root, branches]);
const allRoots = useMemo(() => buildBranchTree(branches, favorites, tags), [branches, favorites, tags]);
useEffect(() => {
let live = true;
void branchesByRepo(repositories)
.then((value) => live && setAllBranches(value))
.catch(() => undefined);
void Promise.all(repositories.map(async (r) => [r, await call(commands.vcsTags(r))] as const))
.then((value) => live && setAllTags(Object.fromEntries(value)))
.catch(() => undefined);
return () => {
live = false;
};
}, [repositories, version]);
const allRoots = useMemo(() => {
if (repositories.length < 2) return buildBranchTree(branches, favorites, tags);
return buildRepositoryBranchTree(
repositories.map((repo) => ({
root: repo,
name: repoName(repo),
branches: allBranches[repo] ?? [],
tags: allTags[repo] ?? [],
})),
favorites,
);
}, [repositories, allBranches, allTags, branches, favorites, tags]);
const roots = useMemo(() => filterBranchTree(allRoots, query), [allRoots, query]);
// While searching every match is shown expanded.
const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]);
// Highlighted rows: a click only selects (IDEA), and only a double click or
// Enter switches the log, so the selection is the pane's own state. It still
// follows the filter when something else changes it (Show Log, the filter bar).
const filterKeys = useMemo(() => filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`)), [filterBranches]);
const filterKeys = useMemo(
() =>
filterBranches.flatMap((b) => {
if (b === "HEAD") return ["head"];
if (repositories.length < 2) return [`b:${b}`];
return (filterRepositories?.length ? filterRepositories : repositories).map((r) => `${r}:b:${b}`);
}),
[filterBranches, filterRepositories, repositories],
);
const filterKey = filterKeys.join("\n");
const [shownKey, setShownKey] = useState(filterKey);
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(filterKeys));
@@ -278,10 +334,10 @@ export function BranchesPanel() {
// Refs in tree order, for Copy: full refs, so the folder path comes along
// (`feat/x`, `origin/main`).
const refs = useMemo(() => {
const out: { key: string; ref: string; branch?: Branch }[] = [];
const out: { key: string; ref: string; branch?: Branch; repo?: string }[] = [];
const walk = (nodes: readonly BranchTreeNode[]) => {
for (const n of nodes) {
if (n.ref) out.push({ key: n.key, ref: n.ref, branch: n.branch });
if (n.ref) out.push({ key: n.key, ref: n.ref, branch: n.branch, repo: n.repo });
walk(n.children ?? []);
}
};
@@ -289,7 +345,8 @@ export function BranchesPanel() {
return out;
}, [allRoots]);
const targets = useMemo(
(): BranchTarget[] => refs.filter((r) => selected.has(r.key)).map(({ ref, branch }) => ({ ref, branch })),
(): BranchTarget[] =>
refs.filter((r) => selected.has(r.key)).map(({ ref, branch, repo }) => ({ ref, branch, repo })),
[refs, selected],
);
useEffect(() => {
@@ -367,6 +424,7 @@ export function BranchesPanel() {
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultExpanded={[
...branchGroupKeys(allRoots),
"g:local",
"g:remote",
...roots.flatMap((r) => r.children?.filter((c) => c.kind === "remote").map((c) => c.key) ?? []),
@@ -393,11 +451,16 @@ export function BranchesPanel() {
// IDEA: double click (or Enter) switches the log to that ref, or to every
// selected one when it is part of a multi-selection; checking out stays in the context menu.
if (!n.ref) return;
const sel = selected.has(n.key) ? targets.map((r) => r.ref) : [n.ref];
setLogBranches(sel);
const sel = selected.has(n.key) ? targets.filter((r) => r.repo === n.repo).map((r) => r.ref) : [n.ref];
setLogFilters({ branches: sel, repositories: n.repo ? [n.repo] : [] });
}}
onContextMenu={(n, _s, e) => {
const items = n?.branch ? branchMenu(n.branch, current) : [];
const menu = n?.branch
? branchMenu(n.branch, n.repo ? (useVcs.getState().heads[n.repo]?.head ?? null) : current)
: n?.kind === "tag"
? tagMenu(n.ref!)
: [];
const items = n?.repo ? inRepo(n.repo, menu) : menu;
if (!items.length) return;
e.preventDefault();
showContextMenu(e, items);
@@ -485,9 +548,31 @@ function FilterButton({
function FilterBar() {
const filters = useGitLog((s) => s.filters);
const commits = useGitLog((s) => s.commits);
const branches = useVcs((s) => s.branches);
const selectedBranches = useVcs((s) => s.branches);
const [branches, setBranches] = useState(selectedBranches);
const version = useVcs((s) => s.version);
const root = useVcs((s) => s.root);
const repositories = useVcs((s) => s.roots);
const showBranches = useLogOptions((s) => s.showBranches);
useEffect(() => {
let live = true;
const roots = filters.repositories?.length ? filters.repositories : repositories;
void branchesByRepo(roots)
.then((all) => {
if (live)
setBranches([
...new Map(
Object.values(all)
.flat()
.map((b) => [b.name, b]),
).values(),
]);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [repositories, filters.repositories, version]);
const [text, setText] = useState(filters.text);
const [branchAnchor, setBranchAnchor] = useState<HTMLElement | null>(null);
@@ -602,6 +687,23 @@ function FilterBar() {
},
]}
/>
{repositories.length > 1 && (
<FilterButton
label="Repository"
value={filters.repositories?.map(repoName).join(", ") || null}
onOpen={(e) =>
menuBelow(
e,
repositories.map((repo) => ({
label: repoName(repo),
checked: filters.repositories?.includes(repo) ?? false,
run: () => setLogFilters({ repositories: [repo] }),
})),
)
}
onClear={() => setLogFilters({ repositories: [] })}
/>
)}
<FilterButton
label="Branch"
value={filters.branches.join(", ") || null}
@@ -706,10 +808,11 @@ export function viewOptionsMenu(o: LogOptions): MenuItem[] {
/** IDEA's Git Log commit context menu (Git.Log.ContextMenu); `selection` = all selected commits, newest first. */
export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
const many = selection.length > 1 && selection.some((x) => x.id === c.id);
const head = useVcs.getState().branch?.head ?? null;
const many = selection.length > 1 && selection.some((x) => commitKey(x) === commitKey(c));
const head = (commitRepo(c) ? useVcs.getState().heads[commitRepo(c)!]?.head : useVcs.getState().branch?.head) ?? null;
const { outgoing } = useGitLog.getState();
const pushed = (x: Commit) => !!outgoing && !outgoing.has(x.id);
const pushed = (x: Commit) =>
commitRepo(x) === undefined ? !!outgoing && !outgoing.has(x.id) : isPushed(commitKey(x));
if (many) {
const anyPushed = selection.some(pushed);
return [
@@ -726,9 +829,13 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "action", action: "vcs.log.dropCommit", arg: selection, label: "Drop Commits", disabled: anyPushed },
];
}
const isPushed = pushed(c);
const pushedCommit = pushed(c);
// Without an upstream there is no outgoing set: allow pushing up to any commit of HEAD.
const canPushHere = !!head && (outgoing ? outgoing.has(c.id) : true);
const canPushHere =
!!head &&
((commitRepo(c) === undefined ? !!outgoing : useGitLog.getState().tracked.has(commitRepo(c) ?? ""))
? !!outgoing?.has(commitKey(c))
: true);
return [
{ kind: "action", action: "vcs.log.copyRevision", arg: c },
{ label: "Copy Commit Message", run: () => void navigator.clipboard?.writeText(c.message) },
@@ -742,11 +849,11 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "action", action: "vcs.log.revert", arg: c },
{ kind: "action", action: "vcs.log.undoCommit", arg: c },
{ kind: "separator" },
{ kind: "action", action: "vcs.log.editMessage", arg: c, disabled: isPushed },
{ kind: "action", action: "vcs.log.fixup", arg: c, disabled: isPushed },
{ kind: "action", action: "vcs.log.squashInto", arg: c, disabled: isPushed },
{ kind: "action", action: "vcs.log.dropCommit", arg: c, disabled: isPushed },
{ kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: isPushed },
{ kind: "action", action: "vcs.log.editMessage", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.fixup", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.squashInto", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.dropCommit", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.pushUpToHere", arg: c, disabled: !canPushHere },
{ kind: "separator" },
{
@@ -776,21 +883,34 @@ function CommitList() {
const remoteNames = useMemo(() => new Set(branches.filter((b) => b.remote).map((b) => b.name)), [branches]);
// Date, No Merges and First Parent are applied by the backend with parent rewriting,
// so the graph stays connected under them.
const collapsed = useMemo(
() => (opts.collapseLinear ? collapseLinear(all, new Set(headCommit ? [headCommit] : [])) : null),
[all, opts.collapseLinear, headCommit],
const heads = useVcs((s) => s.heads);
const headKeys = useMemo(
() =>
new Set(
all.filter((c) => c.id === (commitRepo(c) ? heads[commitRepo(c)!]?.headCommit : headCommit)).map(commitKey),
),
[all, heads, headCommit],
);
const commits = collapsed ? collapsed.commits : all;
const collapsed = useMemo(
() => (opts.collapseLinear ? collapseLinear(all.map(graphCommit), headKeys) : null),
[all, opts.collapseLinear, headKeys],
);
const commits = useMemo(() => {
if (!collapsed) return all;
const byKey = new Map(all.map((c) => [commitKey(c), c]));
return collapsed.commits.map((c) => byKey.get(c.id)!);
}, [all, collapsed]);
const rows = useMemo(() => {
const laid = collapsed ? computeGraph(collapsed.graph).rows : allRows;
return opts.longEdges ? laid : hideLongEdges(laid);
}, [collapsed, allRows, opts.longEdges]);
const onBranch = useMemo(() => {
if (!opts.highlightCurrentBranch) return null;
const reach = reachableFrom(all, headCommit);
const reach = new Set<string>();
for (const head of headKeys) for (const id of reachableFrom(all.map(graphCommit), head)) reach.add(id);
// Nothing to tell apart when only the current branch is shown.
return all.every((c) => reach.has(c.id)) ? null : reach;
}, [all, headCommit, opts.highlightCurrentBranch]);
return all.every((c) => reach.has(commitKey(c))) ? null : reach;
}, [all, headKeys, opts.highlightCurrentBranch]);
const loading = useGitLog((s) => s.loading);
const done = useGitLog((s) => s.done);
const error = useGitLog((s) => s.error);
@@ -813,7 +933,7 @@ function CommitList() {
if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore();
}, [last, commits.length, finished, loading]);
const selectedIndex = selected ? commits.findIndex((c) => c.id === selected) : -1;
const selectedIndex = selected ? commits.findIndex((c) => commitKey(c) === selected) : -1;
useLayoutEffect(() => {
if (selectedIndex >= 0) virtualizer.scrollToIndex(selectedIndex, { align: "center" });
// Only on explicit reveal requests.
@@ -850,8 +970,8 @@ function CommitList() {
e.preventDefault();
const c = commits[next];
if (c) {
if (e.shiftKey) extendCommitSelection(c.id, commits);
else selectCommit(c.id);
if (e.shiftKey) extendCommitSelection(commitKey(c), commits);
else selectCommit(commitKey(c));
virtualizer.scrollToIndex(next, { align: "auto" });
}
};
@@ -860,6 +980,11 @@ function CommitList() {
return (
<div className={styles.listWrap}>
{error && (
<div role="alert" className={styles.message}>
{error}
</div>
)}
<div
ref={scrollRef}
className={styles.list}
@@ -887,17 +1012,17 @@ function CommitList() {
);
return (
<CommitRow
key={c.id}
key={commitKey(c)}
id={`commit-${v.index}`}
commit={c}
graph={rows[v.index]}
top={v.start}
selected={selectedSet.has(c.id)}
head={c.id === headCommit}
selected={selectedSet.has(commitKey(c))}
head={headKeys.has(commitKey(c))}
mine={opts.highlightMine && isMine(c, user)}
onBranch={!!onBranch?.has(c.id)}
onBranch={!!onBranch?.has(commitKey(c))}
opts={opts}
remoteNames={remoteNames}
remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)}
onPick={pick}
/>
);
@@ -953,13 +1078,18 @@ const CommitRow = memo(function CommitRow({
style={{ transform: `translateY(${top}px)` }}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault(); // no text selection on range clicks
onPick(c.id, e);
onPick(commitKey(c), e);
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, commitMenu(c, selectedCommits()));
}}
>
{commitRepo(c) && useVcs.getState().roots.length > 1 && (
<span className={styles.colRepository} title={commitRepo(c)!}>
{repoName(commitRepo(c)!)}
</span>
)}
<span className={styles.colSubject}>
{graph && <GraphCell row={graph} head={head} />}
{opts.refsOnLeft && refs}
@@ -1075,11 +1205,12 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
// Newest first (log order).
const chosen = useMemo(() => {
const ids = new Set(selection);
return commits.filter((c) => ids.has(c.id));
return commits.filter((c) => ids.has(commitKey(c)));
}, [commits, selection]);
const commit = selected ? commits.find((c) => c.id === selected) : undefined;
const commit = selected ? commits.find((c) => commitKey(c) === selected) : undefined;
const many = chosen.length > 1;
const idsKey = many ? chosen.map((c) => c.id).join(" ") : (selected ?? "");
const idsKey = chosen.map(commitKey).join(" ");
const commitRoot = commit ? commitRepo(commit) : null;
const root = useVcs((s) => s.root) ?? "";
const [files, setFiles] = useState<(ChangedFile | RevisionChange)[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -1087,12 +1218,12 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
useEffect(() => {
if (!idsKey) return;
const ids = idsKey.split(" ");
const ids = chosen.map((c) => c.id);
let alive = true;
const load =
ids.length > 1
? call(commands.vcsCommitsChanges(currentRepo(), ids.reverse()))
: call(commands.vcsCommitFiles(currentRepo(), ids[0]!));
? call(commands.vcsCommitsChanges(commitRoot ?? currentRepo(), ids.reverse()))
: call(commands.vcsCommitFiles(commitRoot ?? currentRepo(), ids[0]!));
load.then((f) => alive && setFiles(f)).catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
@@ -1102,8 +1233,8 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
const groupByDirectory = useLogOptions((s) => s.groupByDirectory);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
const changes = useMemo(
() => (files && commit ? toLogChanges(files, many ? null : commit) : []),
[files, commit, many],
() => (files && commit ? toLogChanges(files, many ? null : commit, commitRoot) : []),
[files, commit, many, commitRoot],
);
const tree = useMemo(
() =>
@@ -1234,7 +1365,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
<SplitPanel id="message" minSize="60px">
<div className={styles.messageBox}>
{many ? (
chosen.map((c) => <CommitMessage key={c.id} commit={c} compact />)
chosen.map((c) => <CommitMessage key={commitKey(c)} commit={c} compact />)
) : (
<CommitMessage commit={commit} />
)}
@@ -1304,7 +1435,7 @@ export function changesMenu(changes: LogChange[]): MenuItem[] {
{
kind: "action",
action: "vcs.showHistory",
arg: one ? absolutePath(one.path) : undefined,
arg: one ? absolutePath(one.path, one.repo) : undefined,
disabled: !one?.after,
},
];
+11 -6
View File
@@ -4,7 +4,8 @@ import { Dialog, HighlightedText, Icon, List, SearchField, fuzzyMatch, listNavig
import { sortLocal, sortRemote } from "./branches";
import { openDialog } from "./dialogs";
import { call, commands } from "@/platform/ipc";
import { currentRepo, loadBranches, useVcs } from "./store";
import type { Branch } from "@/bindings";
import { currentRepo } from "./store";
import styles from "./common.module.css";
interface Item {
@@ -43,17 +44,21 @@ function Chooser({
options: ChooseRevisionOptions;
onDone: (rev: string | null) => void;
}) {
const branches = useVcs((s) => s.branches);
const [repo] = useState(currentRepo);
const [branches, setBranches] = useState<Branch[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
useEffect(() => {
void loadBranches().catch(() => undefined);
if (!options.tags) return;
let alive = true;
call(commands.vcsTags(currentRepo()))
.then((t) => alive && setTags(t))
void call(commands.vcsBranches(repo))
.then((b) => alive && setBranches(b))
.catch(() => undefined);
if (options.tags) {
void call(commands.vcsTags(repo))
.then((t) => alive && setTags(t))
.catch(() => undefined);
}
return () => {
alive = false;
};
@@ -1,3 +1,4 @@
import { commitKey } from "../multiRoot";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Commit } from "@/bindings";
@@ -69,4 +70,14 @@ describe("Git Log commit details", () => {
expect(screen.getByText("Third")).toBeInTheDocument();
expect(screen.queryByText("Second")).toBeNull();
});
it("loads the selected root even when two repositories contain the same SHA", async () => {
const front = { ...commit("Front", "1"), repo: "/front" };
const back = { ...commit("Back", "1"), repo: "/back" };
vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "same.ts", kind: "modified", oldPath: null }] });
useGitLog.setState({ commits: [front, back], selected: commitKey(back), selection: [commitKey(back)] });
render(<CommitDetails commits={[front, back]} />);
expect(await screen.findByText("1 file")).toBeInTheDocument();
expect(vcsCommitFiles).toHaveBeenCalledWith("/back", back.id);
expect(screen.getByText("Body of Back")).toBeInTheDocument();
});
});
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Branch, Commit, LogQuery } from "@/bindings";
const log = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsLog: (repo: string | null, query: LogQuery) => log(repo, query),
vcsBranchInfo: () => Promise.resolve({ status: "ok", data: { upstream: null } }),
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
},
};
});
import { commitKey, graphCommit, mergeRepoLogs, type RepoCommit } from "../multiRoot";
import { buildRepositoryBranchTree } from "../branches";
import {
emptyFilters,
installGitLog,
loadMore,
openLogTab,
PAGE,
reloadLog,
selectCommit,
selectedCommits,
selectGitTab,
toggleCommitSelection,
useGitLog,
} from "../logStore";
import { useVcs } from "../store";
import { commitFileDiffData } from "../diffInput";
const commit = (id: string, timeMs = 0): Commit => ({
id,
shortId: id,
parents: [],
refs: [],
author: "a",
email: "a@test",
summary: id,
message: id,
timeMs,
});
const branch = (name: string, remote = false): Branch => ({
name,
remote,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: 0,
commit: "same",
});
beforeEach(() => {
useVcs.setState({ root: "/project", roots: ["/project/front", "/project/back"], repo: "/project/front" });
useGitLog.setState({
filters: emptyFilters(),
commits: [],
done: false,
selection: [],
selected: null,
shownLog: "log",
tab: "log",
logTabs: [],
generation: useGitLog.getState().generation + 1,
});
log.mockReset().mockResolvedValue({ status: "ok", data: [] });
});
describe("multi-root Git log", () => {
it("merges streams by time without violating each root's topological order", () => {
const child = { ...commit("child", 1), repo: "a", parents: ["parent"] };
const parent = { ...commit("parent", 100), repo: "a" };
const other = { ...commit("other", 50), repo: "b" };
expect(mergeRepoLogs([[child, parent], [other]], 3)).toEqual([other, child, parent]);
expect(graphCommit(child).parents).toEqual([commitKey(parent)]);
});
it("keeps identical SHAs distinct in selection and file paths", () => {
const front = { ...commit("same"), repo: "/project/front" };
const back = { ...commit("same"), repo: "/project/back" };
useGitLog.setState({ commits: [front, back] });
selectCommit(commitKey(front));
expect(selectedCommits()).toEqual([front]);
toggleCommitSelection(commitKey(back));
expect(selectedCommits()).toEqual([back]);
expect(commitFileDiffData(back, { path: "main.ts", oldPath: null, kind: "added" }).path).toBe(
"/project/back/main.ts",
);
});
it("pages each root by the number of its emitted commits, without gaps or duplicates", async () => {
const data: Record<string, Commit[]> = Object.fromEntries(
useVcs
.getState()
.roots.map((r, index) => [
r,
Array.from({ length: PAGE + 5 }, (_, i) => commit(`${index}-${i}`, 10000 - i * 2 - index)),
]),
);
log.mockImplementation(async (repo: string, q: LogQuery) => ({
status: "ok",
data: data[repo]!.slice(q.skip, q.skip + q.limit),
}));
await reloadLog();
expect(useGitLog.getState().commits).toHaveLength(PAGE);
await loadMore();
await loadMore();
const rows = useGitLog.getState().commits;
expect(rows).toHaveLength((PAGE + 5) * 2);
expect(new Set(rows.map(commitKey)).size).toBe(rows.length);
expect(rows.map((c) => c.timeMs)).toEqual([...rows.map((c) => c.timeMs)].sort((a, b) => b! - a!));
});
it("filters repositories and keeps history tabs pinned when the selected root changes", async () => {
log.mockImplementation(async () => ({ status: "ok", data: [commit("same")] }));
useGitLog.setState({ filters: { ...emptyFilters(), repositories: ["/project/back"] } });
await reloadLog();
expect((useGitLog.getState().commits as RepoCommit[]).map((c) => c.repo)).toEqual(["/project/back"]);
const off = installGitLog();
const id = openLogTab("history", "/project/back/main.ts");
await reloadLog();
useVcs.setState({ repo: "/project/front" });
selectGitTab("log");
useVcs.setState({ repo: "/project/back" });
selectGitTab(id);
expect(useGitLog.getState().filters.path).toBe("/project/back/main.ts");
expect((useGitLog.getState().commits as RepoCommit[])[0]?.repo).toBe("/project/back");
off();
});
it("groups identical local and remote names under distinct repository nodes", () => {
const roots = buildRepositoryBranchTree(
["front", "back"].map((name) => ({
root: `/project/${name}`,
name,
branches: [branch("main"), branch("origin/main", true)],
tags: ["v1"],
})),
[],
);
for (const group of roots.filter((r) => r.kind === "group")) {
expect(group.children?.map((c) => c.label)).toEqual(["front", "back"]);
const children = group.children!.flatMap((r) => r.children!);
expect(new Set(children.map((c) => c.key)).size).toBe(children.length);
expect(children.map((c) => c.repo)).toEqual(["/project/front", "/project/back"]);
}
});
});
@@ -0,0 +1,38 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
const apply = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsStashes: (repo: string) =>
Promise.resolve({
status: "ok",
data: [{ index: 0, message: `On main: ${repo.endsWith("front") ? "First" : "Second"}`, timeMs: 0 }],
}),
},
};
});
vi.mock("../changeOps", async (original) => ({
...(await original<typeof import("../changeOps")>()),
applyStash: apply,
}));
import { StashesPanel } from "../CommitView";
import { useVcs } from "../store";
beforeEach(() => {
apply.mockReset();
useVcs.setState({ root: "/p", roots: ["/p/front", "/p/back"], repo: "/p/front" });
});
it("lists stashes from every root and applies the selected row in its own repository", async () => {
render(<StashesPanel />);
expect(await screen.findByText("front · First")).toBeInTheDocument();
expect(await screen.findByText("back · Second")).toBeInTheDocument();
act(() => useVcs.setState({ repo: "/p/back" }));
await screen.findByText("front · First");
fireEvent.click(screen.getByRole("button", { name: "Apply Stash" }));
expect(apply).toHaveBeenCalledWith(0, false, "/p/front");
});
@@ -0,0 +1,53 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { Commit } from "@/bindings";
const mocks = vi.hoisted(() => ({ tags: vi.fn(), create: vi.fn(), prompt: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: { ...real.commands, vcsTags: mocks.tags, vcsCreateTag: mocks.create },
};
});
vi.mock("../dialogs", async (original) => ({
...(await original<typeof import("../dialogs")>()),
promptText: mocks.prompt,
}));
vi.mock("../ops", async (original) => ({
...(await original<typeof import("../ops")>()),
runOp: async (_label: string, run: () => Promise<unknown>) => {
await run();
},
}));
import { newTagOn } from "../operations";
import { useVcs } from "../store";
beforeEach(() => {
vi.clearAllMocks();
useVcs.setState({ root: "/p", roots: ["/p/front", "/p/back", "/p/third"], repo: "/p/front" });
mocks.tags.mockResolvedValue({ status: "ok", data: ["back-only"] });
mocks.create.mockResolvedValue({ status: "ok", data: null });
});
it("validates and creates a tag in the commit repository even if selection changes during the prompt", async () => {
mocks.prompt.mockImplementation(async (options) => {
expect(options.validate("back-only")).toContain("already exists");
expect(options.validate("front-only")).toBeNull();
useVcs.setState({ repo: "/p/third" });
return { value: "front-only" };
});
const commit: Commit & { repo: string } = {
id: "1234567890",
shortId: "1234567",
parents: [],
refs: [],
author: "A",
email: "a@test",
summary: "Change",
message: "Change",
timeMs: 0,
repo: "/p/back",
};
await newTagOn(commit);
expect(mocks.tags).toHaveBeenCalledWith("/p/back");
expect(mocks.create).toHaveBeenCalledWith("/p/back", "front-only", commit.id, null, false);
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { commitRepo } from "./multiRoot";
/** VCS actions (ids match keymaps/intellij-macos.ts where bound). */
import { registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
@@ -448,9 +449,11 @@ export function registerVcsActions(): () => void {
when: VCS,
hidden: true,
run: ({ arg }) => {
if (typeof arg !== "string") return;
const commit = arg && typeof arg === "object" && "id" in arg ? (arg as import("@/bindings").Commit) : null;
const id = typeof arg === "string" ? arg : commit?.id;
if (!id) return;
showGitWindow();
void revealCommit(arg);
void revealCommit(id, commit ? commitRepo(commit) : undefined);
},
},
{
+8 -4
View File
@@ -144,6 +144,7 @@ export async function checkoutBranch(name: string) {
/** New Branch: creates a branch from HEAD (or `from`) and, by default, checks it out. */
export async function newBranch(from?: string) {
const repo = currentRepo();
const suggestion = from ? (from.includes("/") ? shortRemoteName(from) : `${from}-copy`) : "";
const r = await promptText({
title: from ? tx`Create New Branch from ${from}` : "Create New Branch",
@@ -158,7 +159,7 @@ export async function newBranch(from?: string) {
const checkout = r.checked;
const previous = useVcs.getState().branch?.head;
if (checkout) await saveAll();
const create = () => call(commands.vcsCreateBranch(currentRepo(), r.value, from ?? null, checkout));
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout));
const ok = await runOp(`Create branch ${r.value}`, create, {
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
});
@@ -198,9 +199,10 @@ export async function openConflictsInstead(): Promise<boolean> {
}
export async function mergeIntoCurrent(branch: string) {
const repo = currentRepo();
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(currentRepo(), branch)), {
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), {
success: `Merged ${branch} into ${current}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -208,9 +210,10 @@ export async function mergeIntoCurrent(branch: string) {
}
export async function rebaseOnto(branch: string) {
const repo = currentRepo();
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(currentRepo(), branch)), {
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), {
success: `Rebased ${current} onto ${branch}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -218,6 +221,7 @@ export async function rebaseOnto(branch: string) {
}
export async function renameBranch(name: string) {
const repo = currentRepo();
const r = await promptText({
title: tx`Rename Branch ${name}`,
label: "New branch name:",
@@ -228,7 +232,7 @@ export async function renameBranch(name: string) {
});
if (!r) return;
const to = r.value.trim();
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(currentRepo(), name, to)), {
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), {
success: `Renamed branch ${name} to ${to}`,
});
if (ok !== undefined) {
+39 -1
View File
@@ -297,7 +297,8 @@ export function isFavoriteBranch(b: Pick<Branch, "name" | "remote">, stored: rea
export interface BranchTreeNode {
key: string;
label: string;
kind: "head" | "group" | "remote" | "folder" | "branch" | "tag";
repo?: string;
kind: "repository" | "head" | "group" | "remote" | "folder" | "branch" | "tag";
/** Branch name for `branch` / `head` (`HEAD`), tag name for `tag`. */
ref: string | null;
branch?: Branch;
@@ -396,6 +397,43 @@ export function buildBranchTree(
return out;
}
/** Local / remote / tag groups contain a subtree for each registered repository. */
export function buildRepositoryBranchTree(
repositories: readonly { root: string; name: string; branches: Branch[]; tags: string[] }[],
favorites: readonly string[] | null,
): BranchTreeNode[] {
const scope = (nodes: BranchTreeNode[], repo: string): BranchTreeNode[] =>
nodes.map((n) => ({
...n,
repo,
key: `${repo}:${n.key}`,
children: n.children && scope(n.children, repo),
}));
const trees = repositories.map((r) => ({ ...r, nodes: buildBranchTree(r.branches, favorites, r.tags) }));
const groups = [
{ key: "g:local", label: "Local" },
{ key: "g:remote", label: "Remote" },
{ key: "g:tags", label: "Tags" },
];
return [
{ key: "head", label: "HEAD (Current Branch)", kind: "head", ref: "HEAD" },
...groups.map(({ key, label }): BranchTreeNode => ({
key,
label,
kind: "group",
ref: null,
children: trees.map((r) => ({
key: `${key}:${r.root}`,
label: r.name,
kind: "repository",
ref: null,
repo: r.root,
children: scope(r.nodes.find((n) => n.key === key)?.children ?? [], r.root),
})),
})),
];
}
/** Keys of the nodes on the way to branch `name` (to reveal it). */
export function branchFolderKeys(nodes: readonly BranchTreeNode[], name: string): string[] {
const walk = (list: readonly BranchTreeNode[]): string[] | null => {
+24 -16
View File
@@ -1,6 +1,6 @@
/** Local change operations: commit, rollback, add, stash. */
import { useState } from "react";
import type { FileStatus, PartialCommitFile } from "@/bindings";
import { useEffect, useState } from "react";
import type { FileStatus, PartialCommitFile, Stash } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, T, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
@@ -290,7 +290,7 @@ export async function addToVcs(paths: readonly string[]) {
}
/** Stash Changes dialog: message, Keep index (staged changes stay), include untracked files. */
export function stashChanges(): Promise<void> {
export function stashChanges(repo = currentRepo()): Promise<void> {
return new Promise((resolve) => {
const close = openDialog(() => (
<StashDialog
@@ -300,7 +300,7 @@ export function stashChanges(): Promise<void> {
await saveAll();
await runOp(
"Stash changes",
() => call(commands.vcsStashPush(currentRepo(), r.message || null, r.includeUntracked, r.keepIndex)),
() => call(commands.vcsStashPush(repo, r.message || null, r.includeUntracked, r.keepIndex)),
{ success: "Changes stashed" },
);
void loadStashes().catch(() => undefined);
@@ -378,27 +378,35 @@ export function unstashTitles(o: UnstashOptions): { title: string; success: stri
: { title: tx`Apply ${ref}`, success: `Applied ${ref}` };
}
export async function unstash(o: UnstashOptions) {
export async function unstash(o: UnstashOptions, repo = currentRepo()) {
await saveAll();
const { title, success } = unstashTitles(o);
await runOp(title, () => call(commands.vcsStashApply(currentRepo(), o.index, o.pop, o.reinstateIndex, o.branch)), {
await runOp(title, () => call(commands.vcsStashApply(repo, o.index, o.pop, o.reinstateIndex, o.branch)), {
success,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
}
export function applyStash(index: number, pop: boolean) {
return unstash({ index, pop, reinstateIndex: false, branch: null });
export function applyStash(index: number, pop: boolean, repo = currentRepo()) {
return unstash({ index, pop, reinstateIndex: false, branch: null }, repo);
}
/** Unstash Changes dialog: pick a stash; Pop, Reinstate index, or As new branch. */
export function showUnstashDialog(index = 0) {
void loadStashes().catch(() => undefined);
openDialog((close) => <UnstashDialog initial={index} onClose={close} />);
export function showUnstashDialog(index = 0, repo = currentRepo()) {
openDialog((close) => <UnstashDialog initial={index} repo={repo} onClose={close} />);
}
function UnstashDialog({ initial, onClose }: { initial: number; onClose: () => void }) {
const stashes = useVcs((s) => s.stashes);
function UnstashDialog({ initial, repo, onClose }: { initial: number; repo: string | null; onClose: () => void }) {
const [stashes, setStashes] = useState<Stash[]>([]);
useEffect(() => {
let live = true;
void call(commands.vcsStashes(repo))
.then((value) => live && setStashes(value))
.catch((e) => logConsole("err", String(e)));
return () => {
live = false;
};
}, [repo]);
const [index, setIndex] = useState(initial);
const [pop, setPop] = useState(false);
const [reinstateIndex, setReinstateIndex] = useState(false);
@@ -408,7 +416,7 @@ function UnstashDialog({ initial, onClose }: { initial: number; onClose: () => v
const submit = () => {
if (!chosen) return;
onClose();
void unstash({ index: chosen.index, pop, reinstateIndex, branch: asBranch || null });
void unstash({ index: chosen.index, pop, reinstateIndex, branch: asBranch || null }, repo);
};
return (
<Dialog
@@ -464,7 +472,7 @@ function UnstashDialog({ initial, onClose }: { initial: number; onClose: () => v
);
}
export async function dropStash(index: number, message: string) {
export async function dropStash(index: number, message: string, repo = currentRepo()) {
const ok = await confirmDialog({
title: "Drop Stash",
message: message
@@ -474,7 +482,7 @@ export async function dropStash(index: number, message: string) {
danger: true,
});
if (!ok) return;
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(currentRepo(), index)), {
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(repo, index)), {
success: `Dropped stash@{${index}}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
+7 -8
View File
@@ -136,17 +136,16 @@ export function workingDiffData(path: string, line?: number): DiffData {
}
/** A path of the selected repository's log (repository-relative) made absolute. */
function joinRoot(rel: string): string {
const root = currentRepoRoot();
function joinRoot(rel: string, root = currentRepoRoot()): string {
return root ? `${root}/${rel}` : rel;
}
/** Diff of one file changed by `commit` (first parent ↔ commit). */
export function commitFileDiffData(
commit: { id: string; shortId: string; parents: string[] },
commit: { id: string; shortId: string; parents: string[]; repo?: string | null },
file: ChangedFile,
): DiffData {
const path = joinRoot(file.path);
const path = joinRoot(file.path, commit.repo ?? currentRepoRoot());
const parent = commit.parents[0];
const left: DiffSide =
!parent || file.kind === "added"
@@ -154,7 +153,7 @@ export function commitFileDiffData(
: {
type: "rev",
rev: parent,
path: file.oldPath ? joinRoot(file.oldPath) : undefined,
path: file.oldPath ? joinRoot(file.oldPath, commit.repo ?? currentRepoRoot()) : undefined,
label: parent.slice(0, 8),
};
const right: DiffSide =
@@ -165,14 +164,14 @@ export function commitFileDiffData(
}
/** Diff of one file of several commits' combined changes (`before` ↔ `after`). */
export function revisionChangeDiffData(change: RevisionChange): DiffData {
const path = joinRoot(change.path);
export function revisionChangeDiffData(change: RevisionChange, root = currentRepoRoot()): DiffData {
const path = joinRoot(change.path, root);
const { before, after } = change;
const left: DiffSide = before
? {
type: "rev",
rev: before,
path: change.oldPath ? joinRoot(change.oldPath) : undefined,
path: change.oldPath ? joinRoot(change.oldPath, root) : undefined,
label: before.slice(0, 8),
}
: { type: "text", text: "", label: "(does not exist)" };
+38 -18
View File
@@ -10,7 +10,7 @@ import { sortLocal, sortRemote } from "./branches";
import { conflictActions, openConflictsInstead, validateBranchName } from "./branchOps";
import { confirmDialog, openDialog } from "./dialogs";
import { errorMessage, runOp, runRemote, saveAll } from "./ops";
import { currentRepo, loadBranches, refreshNow, useVcs } from "./store";
import { currentRepo, loadBranches, refreshNow, repoName, selectRepo, useVcs } from "./store";
import styles from "./common.module.css";
import { tx } from "@/platform/i18n";
@@ -82,9 +82,16 @@ function MergeFlags({
}
function useBranches(): Branch[] {
const branches = useVcs((s) => s.branches);
const [repo] = useState(currentRepo);
const [branches, setBranches] = useState<Branch[]>([]);
useEffect(() => {
void loadBranches().catch(() => undefined);
let live = true;
void call(commands.vcsBranches(repo))
.then((b) => live && setBranches(b))
.catch(() => undefined);
return () => {
live = false;
};
}, []);
return branches;
}
@@ -107,7 +114,8 @@ export function showPullDialog() {
}
function PullDialog({ onClose }: { onClose: () => void }) {
const info = useVcs((s) => s.branch);
const [repo] = useState(currentRepo);
const [info] = useState(() => useVcs.getState().branch);
const branches = useBranches();
const [remotes, setRemotes] = useState<Remote[] | null>(null);
const [remote, setRemote] = useState<string | null>(null);
@@ -117,7 +125,7 @@ function PullDialog({ onClose }: { onClose: () => void }) {
useEffect(() => {
let alive = true;
call(commands.vcsRemotes(currentRepo()))
call(commands.vcsRemotes(repo))
.then((list) => {
if (!alive) return;
setRemotes(list);
@@ -154,6 +162,7 @@ function PullDialog({ onClose }: { onClose: () => void }) {
options: mergeOptionsOf(rebase ? new Set() : flags),
},
`${info?.head ?? "HEAD"} ← ${remote}${branch ? `/${branch}` : ""}`,
[repo],
);
};
@@ -211,7 +220,8 @@ export function showMergeDialog() {
}
function MergeDialog({ onClose }: { onClose: () => void }) {
const head = useVcs((s) => s.branch?.head ?? null);
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches();
const [branch, setBranch] = useState<string | null>(null);
const [flags, setFlags] = useState<Set<MergeFlag>>(() => new Set());
@@ -229,7 +239,7 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
const target = head ?? "HEAD";
await runOp(
`Merge ${branch} into ${target}`,
() => call(commands.vcsMergeWith(currentRepo(), [branch], mergeOptionsOf(flags, withMessage ? message : null))),
() => call(commands.vcsMergeWith(repo, [branch], mergeOptionsOf(flags, withMessage ? message : null))),
{ success: `Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead },
);
await refreshNow();
@@ -282,7 +292,8 @@ export function showRebaseDialog() {
}
function RebaseDialog({ onClose }: { onClose: () => void }) {
const head = useVcs((s) => s.branch?.head ?? null);
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches();
const [upstream, setUpstream] = useState<string | null>(null);
const [useOnto, setUseOnto] = useState(false);
@@ -305,7 +316,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
`Rebase ${current} onto ${target}`,
() =>
call(
commands.vcsRebaseWith(currentRepo(), upstream, {
commands.vcsRebaseWith(repo, upstream, {
onto: useOnto ? onto : null,
rebaseMerges,
keepEmpty,
@@ -368,11 +379,12 @@ export function showManageRemotes() {
}
function RemotesDialog({ onClose }: { onClose: () => void }) {
const [repo] = useState(currentRepo);
const [remotes, setRemotes] = useState<Remote[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = () =>
call(commands.vcsRemotes(currentRepo()))
call(commands.vcsRemotes(repo))
.then((list) => {
setRemotes(list);
setSelected((s) => (s && list.some((r) => r.name === s) ? s : (list[0]?.name ?? null)));
@@ -384,7 +396,7 @@ function RemotesDialog({ onClose }: { onClose: () => void }) {
const apply = async (edit: Parameters<typeof commands.vcsEditRemote>[1]) => {
try {
await call(commands.vcsEditRemote(currentRepo(), edit));
await call(commands.vcsEditRemote(repo, edit));
setError(null);
} catch (e) {
setError(errorMessage(e));
@@ -563,11 +575,12 @@ function WorktreesDialog({ onClose }: { onClose: () => void }) {
*/
export function WorktreesPanel({ className, listClassName }: { className?: string; listClassName?: string }) {
const repo = useVcs((s) => s.repo);
const repos = useVcs((s) => s.roots);
const [list, setList] = useState<Worktree[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = () =>
call(commands.vcsWorktrees(currentRepo()))
call(commands.vcsWorktrees(repo))
.then((l) => {
setList(l);
setError(null);
@@ -588,7 +601,7 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
});
if (!ok) return;
try {
await call(commands.vcsRemoveWorktree(currentRepo(), current.path, false));
await call(commands.vcsRemoveWorktree(repo, current.path, false));
} catch (e) {
const force = await confirmDialog({
title: "Remove Worktree",
@@ -597,9 +610,7 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
danger: true,
});
if (force)
await call(commands.vcsRemoveWorktree(currentRepo(), current.path, true)).catch((x) =>
setError(errorMessage(x)),
);
await call(commands.vcsRemoveWorktree(repo, current.path, true)).catch((x) => setError(errorMessage(x)));
}
setSelected(null);
await reload();
@@ -608,6 +619,14 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
return (
<div className={className}>
<div className={styles.manageBar} role="toolbar" aria-label={tx("Worktrees")}>
{repos.length > 1 && (
<Select
aria-label="Repository"
value={repo}
options={repos.map((r) => ({ value: r, label: repoName(r) }))}
onChange={(r) => void selectRepo(r)}
/>
)}
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
<IconButton
icon="plus"
@@ -655,7 +674,8 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
}
function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void }) {
const root = useVcs((s) => s.repo ?? s.root) ?? "";
const [repo] = useState(currentRepo);
const [root] = useState(() => useVcs.getState().repo ?? useVcs.getState().root ?? "");
const branches = useBranches();
const [source, setSource] = useState<string | null>(null);
const [createBranch, setCreateBranch] = useState(true);
@@ -683,7 +703,7 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
const create = async () => {
if (invalid || !source) return;
try {
await call(commands.vcsAddWorktree(currentRepo(), target.trim(), source, createBranch ? branchName : null));
await call(commands.vcsAddWorktree(repo, target.trim(), source, createBranch ? branchName : null));
notifications().notify({ group: "vcs", level: "info", title: `Created worktree at ${target.trim()}` });
void loadBranches().catch(() => undefined);
onClose(true);
+10 -4
View File
@@ -1,3 +1,4 @@
import { commitKey, commitRepo } from "./multiRoot";
/**
* Actions for operations in progress, Git Log commits, change lists, the shelf
* and patches (ids are bound in keymaps/intellij-macos.ts where IDEA binds them).
@@ -45,7 +46,7 @@ import {
} from "./rebase";
import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches";
import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf";
import { currentRepo, useVcs } from "./store";
import { currentRepo, focusRepo, useVcs } from "./store";
const VCS = "vcsAvailable";
const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git";
@@ -57,7 +58,7 @@ export function commitArg(arg: unknown): Commit | null {
if (arg && typeof arg === "object" && "id" in arg && "summary" in arg) return arg as Commit;
const id = typeof arg === "string" ? arg : selected;
if (!id) return null;
return commits.find((c) => c.id === id) ?? null;
return commits.find((c) => commitKey(c) === id) ?? null;
}
/** Commits an action refers to: its argument (commit list or commit), else the Git Log selection. */
@@ -72,12 +73,15 @@ export function commitsArg(arg: unknown): Commit[] {
}
function withCommits(fn: (cs: Commit[]) => unknown) {
return ({ arg }: { arg?: unknown }) => {
return async ({ arg }: { arg?: unknown }) => {
const cs = commitsArg(arg);
if (!cs.length) {
notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" });
return;
}
if (new Set(cs.map(commitRepo)).size > 1) return;
const repo = commitRepo(cs[0]!);
if (repo) await focusRepo(repo);
void fn(cs);
};
}
@@ -93,12 +97,14 @@ async function openCommitInBrowser(c: Commit) {
}
function withCommit(fn: (c: Commit) => unknown) {
return ({ arg }: { arg?: unknown }) => {
return async ({ arg }: { arg?: unknown }) => {
const c = commitArg(arg);
if (!c) {
notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" });
return;
}
const repo = commitRepo(c);
if (repo) await focusRepo(repo);
void fn(c);
};
}
+23 -11
View File
@@ -20,6 +20,7 @@ import { confirmDialog } from "./dialogs";
export interface LogChange {
/** Repository-relative path (after the change). */
path: string;
repo?: string | null;
oldPath: string | null;
kind: ChangedFile["kind"];
/** `null`: the file did not exist before. */
@@ -30,27 +31,33 @@ export interface LogChange {
}
/** One commit's files (`commit` given) or several commits' combined changes. */
export function toLogChanges(files: readonly (ChangedFile | RevisionChange)[], commit: Commit | null): LogChange[] {
export function toLogChanges(
files: readonly (ChangedFile | RevisionChange)[],
commit: Commit | null,
repo = currentRepoRoot(),
): LogChange[] {
return files.map((f) => {
if (commit) {
const parent = commit.parents[0] ?? null;
return {
repo,
path: f.path,
oldPath: f.oldPath,
kind: f.kind,
before: f.kind === "added" ? null : parent,
after: f.kind === "deleted" ? null : commit.id,
diff: commitFileDiffData(commit, f),
diff: commitFileDiffData({ ...commit, repo }, f),
};
}
const r = f as RevisionChange;
return {
repo,
path: r.path,
oldPath: r.oldPath,
kind: r.kind,
before: r.before,
after: r.after,
diff: revisionChangeDiffData(r),
diff: revisionChangeDiffData(r, repo),
};
});
}
@@ -101,8 +108,7 @@ export function filterPatch(patch: string, paths: ReadonlySet<string>): string {
}
/** Absolute path of a repository-relative one. */
export function absolutePath(rel: string): string {
const root = currentRepoRoot();
export function absolutePath(rel: string, root = currentRepoRoot()): string {
return root ? `${root}/${rel}` : rel;
}
@@ -146,9 +152,9 @@ export async function showChangesDiff(changes: readonly LogChange[], preview = f
export async function compareChangeWithLocal(change: LogChange, side: "after" | "before" = "after") {
const rev = side === "after" ? change.after : change.before;
if (!rev) return;
const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath) : undefined;
const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath, change.repo) : undefined;
await openDiff({
path: absolutePath(change.path),
path: absolutePath(change.path, change.repo),
left: { type: "rev", rev, path: revPath, label: rev.slice(0, 8) },
right: { type: "worktree" },
});
@@ -156,7 +162,7 @@ export async function compareChangeWithLocal(change: LogChange, side: "after" |
/** Jump to Source: the working file. */
export async function openChangeSource(change: LogChange) {
await editorService().openFile(absolutePath(change.path), { focus: true });
await editorService().openFile(absolutePath(change.path, change.repo), { focus: true });
}
/**
@@ -180,7 +186,7 @@ export async function getFromRevision(changes: readonly LogChange[]) {
"Get from Revision",
async () => {
for (const c of changes) {
const path = absolutePath(c.path);
const path = absolutePath(c.path, c.repo);
if (!c.after) {
if ((await readWorkingText(path)) !== null) await call(commands.fsDelete(path));
continue;
@@ -207,11 +213,17 @@ export async function revertSelectedChanges(changes: readonly LogChange[], commi
"Revert selected changes",
async () => {
const patch = filterPatch(
await call(commands.vcsCreatePatch({ from: "commits", commits: ids, reverse: true })),
await call(
commands.vcsCreatePatch(changes[0]!.repo ?? currentRepoRoot(), {
from: "commits",
commits: ids,
reverse: true,
}),
),
paths,
);
if (!patch.trim()) throw new Error(tx("Nothing to revert"));
return call(commands.vcsApplyPatch(patch));
return call(commands.vcsApplyPatch(changes[0]!.repo ?? currentRepoRoot(), patch));
},
{ refresh: false },
);
+9 -3
View File
@@ -1,3 +1,4 @@
import { focusRepo } from "./store";
/**
* Actions of the Git Log's inner panes: the Branches pane toolbar, Graph Options'
* linear-branch actions and the changed-files pane (toolbar and context menu).
@@ -24,6 +25,7 @@ const VCS = "vcsAvailable";
/** A ref selected in the Branches pane. */
export interface BranchTarget {
ref: string;
repo?: string;
branch?: Branch;
}
@@ -37,8 +39,10 @@ function branchArg(arg: unknown): BranchTarget | null {
const withBranch =
(fn: (b: Branch) => unknown): Action["run"] =>
({ arg }) => {
const b = branchArg(arg)?.branch;
async ({ arg }) => {
const target = branchArg(arg);
if (target?.repo) await focusRepo(target.repo);
const b = target?.branch;
if (b) void fn(b);
};
@@ -90,8 +94,9 @@ export function registerLogPaneActions(): () => void {
icon: "plus",
when: VCS,
hidden: true,
run: ({ arg }) => {
run: async ({ arg }) => {
const t = branchArg(arg);
if (t?.repo) await focusRepo(t.repo);
void newBranch(t && t.ref !== "HEAD" && !t.branch?.current ? t.ref : undefined);
},
},
@@ -134,6 +139,7 @@ export function registerLogPaneActions(): () => void {
hidden: true,
run: async ({ arg }) => {
const t = branchArg(arg);
if (t?.repo) await focusRepo(t.repo);
if (t && !(await goToRef(t.ref)))
notifications().notify({ group: "vcs", level: "warning", title: `'${t.ref}' was not found in the log` });
},
+154 -106
View File
@@ -1,4 +1,5 @@
/** Git log tool window state: filters, paged commits, graph, selection. */
import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot";
import { create } from "zustand";
import type { Branch, BranchInfo, Commit } from "@/bindings";
import { call, commands } from "@/platform/ipc";
@@ -6,7 +7,7 @@ import { workflowSettings } from "@/platform/workflowSettings";
import { collapseLinear, computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph";
import { logOptions, setLogOptions, type LogOptions } from "./logOptions";
import { clearConsole, errorMessage } from "./ops";
import { currentRepo, currentRepoRoot, repoOf, selectRepo, useVcs } from "./store";
import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store";
/** Date filter: commits made (committer time) after / before the given times (epoch ms). */
export interface LogDateFilter {
@@ -21,6 +22,7 @@ export const ME = "me";
export interface LogFilters {
/** Branches / revisions to show; empty = all. */
branches: string[];
repositories?: string[];
/** Author names or emails (any matches); `ME` = the current user. */
authors: string[];
text: string;
@@ -34,7 +36,7 @@ export interface LogFilters {
interface LogState {
filters: LogFilters;
commits: Commit[];
commits: RepoCommit[];
rows: GraphRow[];
graph: GraphState;
loading: boolean;
@@ -46,6 +48,7 @@ interface LogState {
selection: string[];
/** Commits of HEAD not on its upstream (`@{u}..HEAD`); null without an upstream. */
outgoing: Set<string> | null;
tracked: Set<string>;
/** Content tab of the Git tool window (a `GitTab`, or the id of a log tab). */
tab: string;
/** Closable log tabs (History: file, Log: branch) next to the main Log. */
@@ -70,6 +73,7 @@ export interface LogTab {
kind: "history" | "branch";
/** What it shows (the file path or the branch); opening the same again selects the tab. */
target: string;
repo?: string | null;
}
export const PAGE = 300;
@@ -86,7 +90,7 @@ export const emptyFilters = (): LogFilters => ({
/** Any filter other than the text field's toggles is set. */
export function hasFilters(f: LogFilters): boolean {
return !!(f.branches.length || f.authors.length || f.text || f.path || f.date);
return !!(f.repositories?.length || f.branches.length || f.authors.length || f.text || f.path || f.date);
}
/** `ME` replaced by the current user's email (or name); duplicates and blanks dropped. */
@@ -137,6 +141,7 @@ const initialLog = (): Omit<LogState, "generation"> => ({
selected: null,
selection: [],
outgoing: null,
tracked: new Set(),
tab: "log",
logTabs: [],
shownLog: "log",
@@ -185,6 +190,7 @@ function rememberLogBranches(repo: string, branches: readonly string[]) {
*/
export function initLogBranchFilter(): boolean {
const s = useGitLog.getState();
if (useVcs.getState().roots.length > 1) return false;
const repo = currentRepoRoot();
if (s.shownLog !== "log" || !repo || s.branchScope === repo) return false;
const names = useVcs
@@ -258,7 +264,8 @@ let tabSeq = 0;
* they are: Show History for a file, Show Git Log for a branch.
*/
export function openLogTab(kind: LogTab["kind"], target: string): string {
const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target);
const repo = kind === "history" ? repoOf(target) : currentRepoRoot();
const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target && t.repo === repo);
if (existing) {
selectGitTab(existing.id);
return existing.id;
@@ -266,11 +273,8 @@ export function openLogTab(kind: LogTab["kind"], target: string): string {
const id = `${kind}:${++tabSeq}`;
const filters = { ...emptyFilters(), ...(kind === "history" ? { path: target } : { branches: [target] }) };
parked.set(id, freshView(filters));
useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target }] }));
useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target, repo }] }));
selectGitTab(id);
// History of a file in another repository: that repository's log.
const repo = kind === "history" ? repoOf(target) : null;
if (repo && repo !== currentRepo()) void selectRepo(repo);
return id;
}
@@ -288,7 +292,7 @@ export function closeLogTab(id: string) {
/**
* Another project (the window switched projects): forget its commits, filters, selection
* and console. Another repository of the project selected: show its log.
* and console. Repository selection does not change the log or its history tabs.
*/
export function installGitLog(): () => void {
return useVcs.subscribe((s, prev) => {
@@ -301,33 +305,9 @@ export function installGitLog(): () => void {
generation: l.generation + 1,
}));
clearConsole();
} else if (s.repo !== prev.repo && prev.repo !== null) {
// The parked log tabs load again when shown.
} else if (s.roots !== prev.roots) {
for (const [id, view] of parked) parked.set(id, freshView(view.filters));
// Drops a page still loading for the old repository (its generation no longer matches).
loadPromise = null;
useGitLog.setState((l) => ({
// The shown log belonged to the old repository: it loads again once the new
// repository's branch filter is settled (see LogView).
commits: [],
rows: [],
graph: emptyGraphState(),
loading: false,
done: false,
error: null,
selected: null,
selection: [],
branchScope: null,
generation: l.generation + 1,
filters: {
...l.filters,
// The main Log's branch filter belonged to the old repository; show all
// until the new repository's remembered/current branch is settled.
...(l.shownLog === "log" ? { branches: [] } : {}),
// A path filter belongs to the repository it is in.
...(l.filters.path && repoOf(l.filters.path) !== s.repo ? { path: null } : {}),
},
}));
void reloadLog();
}
});
}
@@ -335,9 +315,7 @@ export function installGitLog(): () => void {
export function setLogFilters(patch: Partial<LogFilters>) {
useGitLog.setState((s) => ({ filters: { ...s.filters, ...patch } }));
// History of a file in another repository: that repository's log.
const repo = patch.path ? repoOf(patch.path) : null;
if (repo && repo !== currentRepo()) void selectRepo(repo);
else void reloadLog();
void reloadLog();
}
/** Drops loaded commits and loads the first page. */
@@ -352,35 +330,49 @@ export function reloadLog(): Promise<void> {
}));
loadPromise = null;
void loadOutgoing();
void currentUser().then((user) => useGitLog.setState({ user }));
const generation = useGitLog.getState().generation;
void currentUser().then((user) => {
if (useGitLog.getState().generation === generation) useGitLog.setState({ user });
});
return loadMore();
}
async function loadOutgoing() {
const upstream = useVcs.getState().branch?.upstream;
if (!upstream) return useGitLog.setState({ outgoing: null });
try {
const commits = await call(
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: "@{u}..HEAD",
skip: 0,
limit: 0,
}),
);
useGitLog.setState({ outgoing: new Set(commits.map((c) => c.id)) });
} catch {
useGitLog.setState({ outgoing: null });
}
const generation = useGitLog.getState().generation;
const roots = useVcs.getState().roots.length ? useVcs.getState().roots : [currentRepo()];
const outgoing = new Set<string>();
const tracked = new Set<string>();
await Promise.all(
roots.map(async (repo) => {
try {
const info = await call(commands.vcsBranchInfo(repo));
if (!info.upstream) return;
const commits = await call(
commands.vcsLog(repo, {
branch: "HEAD",
path: null,
author: null,
text: null,
range: "@{u}..HEAD",
skip: 0,
limit: 0,
}),
);
tracked.add(repo ?? "");
for (const c of commits) outgoing.add(commitKey({ ...c, repo }));
} catch {
/* A missing upstream does not prevent browsing history. */
}
}),
);
if (useGitLog.getState().generation === generation) useGitLog.setState({ outgoing, tracked });
}
/** The commit is on the upstream of the current branch (rewriting it needs a force push). */
export function isPushed(id: string): boolean {
const { outgoing } = useGitLog.getState();
return !!outgoing && !outgoing.has(id);
const { outgoing, tracked, commits } = useGitLog.getState();
const c = commits.find((c) => commitKey(c) === id);
return !!c && tracked.has(commitRepo(c) ?? "") && !!outgoing && !outgoing.has(id);
}
export function loadMore(): Promise<void> {
@@ -399,42 +391,77 @@ export function loadMore(): Promise<void> {
useGitLog.setState({ loading: true });
const p = (async () => {
try {
const authors = resolveAuthors(f.authors, f.authors.includes(ME) ? await currentUser() : null);
if (f.authors.length && !authors.length) {
if (useGitLog.getState().generation === gen) useGitLog.setState({ done: true });
return;
}
const o = logOptions();
const page = await call(
commands.vcsLog(currentRepo(), {
branch: null,
branches: f.branches,
authors,
path: f.path,
author: null,
text: f.text.trim() || null,
textRegex: f.regex,
textMatchCase: f.matchCase,
range: null,
skip: s.commits.length,
limit: Math.min(PAGE, cap - s.commits.length),
order: o.sort,
firstParent: o.firstParent,
noMerges: o.noMerges,
// The date filter runs in the backend so the graph stays connected.
sinceMs: f.date?.after ?? null,
untilMs: f.date?.before ?? null,
const tab = s.logTabs.find((t) => t.id === s.shownLog);
const roots = tab?.repo
? [tab.repo]
: f.path
? [repoOf(f.path)]
: f.repositories?.length
? f.repositories
: useVcs.getState().roots.length
? useVcs.getState().roots
: [currentRepo()];
const limit = Math.min(PAGE, cap - s.commits.length);
const refs = await branchesByRepo(roots.filter((r): r is string => !!r));
const knownRefs = new Set(
Object.values(refs)
.flat()
.map((b) => b.name),
);
const results = await Promise.allSettled(
roots.map(async (repo) => {
const branches = f.branches.filter(
(ref) => !knownRefs.has(ref) || (repo && refs[repo]?.some((b) => b.name === ref)),
);
if (f.branches.length && !branches.length) return [];
const config = (key: string) => call(commands.vcsConfigGet(repo, key)).catch(() => null);
const user = (await config("user.email")) || (await config("user.name"));
const ownAuthors = resolveAuthors(f.authors, user);
if (f.authors.length && !ownAuthors.length) return [];
const page = await call(
commands.vcsLog(repo, {
branch: null,
branches,
authors: ownAuthors,
path: f.path,
author: null,
text: f.text.trim() || null,
textRegex: f.regex,
textMatchCase: f.matchCase,
range: null,
skip: s.commits.filter((c) => commitRepo(c) === repo).length,
limit,
order: o.sort,
firstParent: o.firstParent,
noMerges: o.noMerges,
// The date filter runs in the backend so the graph stays connected.
sinceMs: f.date?.after ?? null,
untilMs: f.date?.before ?? null,
}),
);
return page.map((c) => ({
...c,
repo,
user,
remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined,
}));
}),
);
const streams = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
const failures = results.flatMap((r, i) =>
r.status === "rejected" ? [`${roots[i] ? repoName(roots[i]!) : "Git"}: ${errorMessage(r.reason)}`] : [],
);
const page = mergeRepoLogs(streams, limit);
const cur = useGitLog.getState();
if (cur.generation !== gen) return;
const { rows, state } = computeGraph(page, cur.graph);
const { rows, state } = computeGraph(page.map(graphCommit), cur.graph);
useGitLog.setState({
commits: cur.commits.concat(page),
rows: cur.rows.concat(rows),
graph: state,
done: page.length < PAGE || cur.commits.length + page.length >= cap,
error: null,
error: failures.join("\n") || null,
});
} catch (e) {
if (useGitLog.getState().generation === gen) useGitLog.setState({ error: errorMessage(e), done: true });
@@ -469,8 +496,10 @@ export function selectCommit(id: string | null, reveal = false) {
/** Cmd/Ctrl-click: toggles `id` in the selection. */
export function toggleCommitSelection(id: string) {
useGitLog.setState((s) => {
const on = s.selection.includes(id);
const selection = on ? s.selection.filter((x) => x !== id) : [...s.selection, id];
const repo = commitRepo(s.commits.find((c) => commitKey(c) === id)!);
const existing = s.selection.filter((key) => commitRepo(s.commits.find((c) => commitKey(c) === key)!) === repo);
const on = existing.includes(id);
const selection = on ? existing.filter((x) => x !== id) : [...existing, id];
return { selection, selected: on ? (selection[selection.length - 1] ?? null) : id };
});
}
@@ -479,13 +508,19 @@ export function toggleCommitSelection(id: string) {
export function extendCommitSelection(id: string, visible: readonly Commit[]) {
useGitLog.setState((s) => {
const anchor = s.selection[0] ?? s.selected;
const a = anchor ? visible.findIndex((c) => c.id === anchor) : -1;
const b = visible.findIndex((c) => c.id === id);
const a = anchor ? visible.findIndex((c) => commitKey(c) === anchor) : -1;
const b = visible.findIndex((c) => commitKey(c) === id);
if (a < 0 || b < 0) return { selected: id, selection: [id] };
const [from, to] = a <= b ? [a, b] : [b, a];
const range = visible.slice(from, to + 1).map((c) => c.id);
const range = visible
.slice(from, to + 1)
.filter((c) => commitRepo(c) === commitRepo(visible[b]!))
.map(commitKey);
// Keep the anchor first so further extensions pivot on it.
return { selected: id, selection: [anchor!, ...range.filter((x) => x !== anchor)] };
return {
selected: id,
selection: range.includes(anchor!) ? [anchor!, ...range.filter((x) => x !== anchor)] : range,
};
});
}
@@ -493,17 +528,20 @@ export function extendCommitSelection(id: string, visible: readonly Commit[]) {
export function selectedCommits(): Commit[] {
const { commits, selection } = useGitLog.getState();
const ids = new Set(selection);
return commits.filter((c) => ids.has(c.id));
return commits.filter((c) => ids.has(commitKey(c)));
}
const MAX_SEARCH_PAGES = 30;
/** Selects `id` in the log, clearing filters and paging until it is loaded. */
export async function revealCommit(id: string) {
export async function revealCommit(id: string, repo = currentRepo()) {
selectGitTab("log");
const f = useGitLog.getState().filters;
const filtered = hasFilters(f);
const find = () => useGitLog.getState().commits.find((c) => c.id === id || c.id.startsWith(id));
const find = () =>
useGitLog
.getState()
.commits.find((c) => (!commitRepo(c) || commitRepo(c) === repo) && (c.id === id || c.id.startsWith(id)));
if (filtered) {
useGitLog.setState({ filters: emptyFilters() });
await reloadLog();
@@ -512,24 +550,25 @@ export async function revealCommit(id: string) {
}
for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore();
const hit = find();
if (hit) selectCommit(hit.id, true);
if (hit) selectCommit(commitKey(hit), true);
}
/** Go to Parent Commit: selects the first parent, paging the log until it is loaded. */
export async function goToParent(c: Commit) {
const parent = c.parents[0];
if (!parent) return;
const find = () => useGitLog.getState().commits.find((x) => x.id === parent);
const find = () => useGitLog.getState().commits.find((x) => x.id === parent && commitRepo(x) === commitRepo(c));
for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore();
if (find()) selectCommit(parent, true);
if (find()) selectCommit(commitKey(find()!), true);
}
/** Go to Child Commit: the nearest newer loaded commit having `c` as a parent. */
export function goToChild(c: Commit) {
const { commits } = useGitLog.getState();
const index = commits.findIndex((x) => x.id === c.id);
const index = commits.findIndex((x) => commitKey(x) === commitKey(c));
for (let i = index - 1; i >= 0; i--) {
if (commits[i]!.parents.includes(c.id)) return selectCommit(commits[i]!.id, true);
if (commitRepo(commits[i]!) === commitRepo(c) && commits[i]!.parents.includes(c.id))
return selectCommit(commitKey(commits[i]!), true);
}
}
@@ -540,6 +579,7 @@ export function updateLogOptions(patch: Partial<LogOptions>) {
/** Highlight › My Commits: authored by the current user. */
export function isMine(c: Commit, user: string | null): boolean {
if ("user" in c) user = (c as RepoCommit).user ?? null;
if (!user) return false;
const u = user.toLowerCase();
return c.email.toLowerCase() === u || c.author.toLowerCase() === u;
@@ -550,12 +590,13 @@ export function isMine(c: Commit, user: string | null): boolean {
* loaded (and clearing the filters when they hide it). False when there is no such commit.
*/
export async function goToRef(rev: string): Promise<boolean> {
const repo = currentRepo();
const target = rev.trim();
if (!target) return false;
let id: string | undefined;
try {
const [hit] = await call(
commands.vcsLog(currentRepo(), {
commands.vcsLog(repo, {
branch: target,
path: null,
author: null,
@@ -572,17 +613,24 @@ export async function goToRef(rev: string): Promise<boolean> {
if (!id) return false;
const tab = useGitLog.getState().tab;
selectGitTab(isLogTab(tab) ? tab : "log");
const find = () => useGitLog.getState().commits.some((c) => c.id === id);
const find = () =>
useGitLog.getState().commits.some((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo));
if (!useGitLog.getState().commits.length) await reloadLog();
for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore();
if (!find()) await revealCommit(id);
if (!find()) await revealCommit(id, repo);
if (!find()) return false;
// A commit inside a collapsed linear branch: expand them (IDEA does the same).
if (logOptions().collapseLinear) {
const head = useVcs.getState().branch?.headCommit;
const shown = collapseLinear(useGitLog.getState().commits, new Set(head ? [head] : [])).commits;
if (!shown.some((c) => c.id === id)) setLogOptions({ collapseLinear: false });
const commits = useGitLog.getState().commits;
const heads = useVcs.getState().heads;
const headKeys = new Set(commits.filter((c) => c.id === heads[commitRepo(c) ?? ""]?.headCommit).map(commitKey));
const shown = collapseLinear(commits.map(graphCommit), headKeys).commits;
if (!shown.some((c) => c.id === commitKey({ id: id!, repo }))) {
setLogOptions({ collapseLinear: false });
}
}
selectCommit(id, true);
const hit = useGitLog.getState().commits.find((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo));
if (!hit) return false;
selectCommit(commitKey(hit), true);
return true;
}
+43
View File
@@ -0,0 +1,43 @@
/** Repository identity for log rows and shared repository queries. */
import type { Branch, Commit } from "@/bindings";
import { call, commands } from "@/platform/ipc";
export type RepoCommit = Commit & { repo?: string | null; user?: string | null; remoteNames?: string[] };
export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo;
export const commitKey = (c: Pick<RepoCommit, "id" | "repo">): string => (c.repo ? `${c.repo}\0${c.id}` : c.id);
export const graphCommit = (c: Commit): Commit => ({
...c,
id: commitKey(c),
parents: c.parents.map((id) => commitKey({ ...c, id })),
});
/** Merge ordered streams without breaking any repository's topological order. */
export function mergeRepoLogs(streams: readonly RepoCommit[][], limit: number): RepoCommit[] {
const offsets = streams.map(() => 0);
const result: RepoCommit[] = [];
while (result.length < limit) {
let best = -1;
for (let i = 0; i < streams.length; i++) {
const c = streams[i]![offsets[i]!];
if (
c &&
(best < 0 ||
(c.commitTimeMs ?? c.timeMs ?? 0) >
(streams[best]![offsets[best]!]!.commitTimeMs ?? streams[best]![offsets[best]!]!.timeMs ?? 0))
)
best = i;
}
if (best < 0) break;
result.push(streams[best]![offsets[best]!]!);
offsets[best]!++;
}
return result;
}
export async function branchesByRepo(roots: readonly string[]): Promise<Record<string, Branch[]>> {
return Object.fromEntries(
await Promise.all(
roots.map(async (root) => [root, await call(commands.vcsBranches(root)).catch(() => [] as Branch[])] as const),
),
);
}
+17 -10
View File
@@ -1,3 +1,4 @@
import { commitKey, commitRepo } from "./multiRoot";
/**
* Operations on history: Continue / Abort / Skip of a stopped merge, rebase,
* cherry-pick or revert, and the Git Log commit actions (Cherry-Pick, Revert
@@ -219,10 +220,10 @@ export function newestFirst(
commits: readonly Commit[],
log: readonly Commit[] = useGitLog.getState().commits,
): Commit[] {
const pos = new Map(log.map((c, i) => [c.id, i]));
const pos = new Map(log.map((c, i) => [commitKey(c), i]));
return [...commits].sort((a, b) => {
const pa = pos.get(a.id);
const pb = pos.get(b.id);
const pa = pos.get(commitKey(a));
const pb = pos.get(commitKey(b));
if (pa !== undefined && pb !== undefined) return pa - pb;
return (b.timeMs ?? 0) - (a.timeMs ?? 0);
});
@@ -242,6 +243,7 @@ export async function cherryPick(commits: Commit[]) {
failure: "Cherry-pick failed",
onError: conflictsInstead,
},
commitRepo(commits[0]!) ?? currentRepo(),
);
}
@@ -253,6 +255,7 @@ export async function revertCommits(commits: Commit[]) {
await runGitOperation(
{ op: "revert", commits: ordered.map((c) => c.id) },
{ progress: "Reverting", success: `Reverted ${label}`, failure: "Revert failed", onError: conflictsInstead },
commitRepo(commits[0]!) ?? currentRepo(),
);
}
@@ -277,6 +280,7 @@ export function showResetDialog(commit?: Commit) {
}
function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void }) {
const [repo] = useState(() => (commit && commitRepo(commit)) || currentRepo());
const head = useVcs((s) => s.branch?.head ?? "HEAD");
const [mode, setMode] = useState<ResetMode>("mixed");
const [target, setTarget] = useState(commit?.id ?? "HEAD");
@@ -291,6 +295,7 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void
success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`,
failure: "Reset failed",
},
repo,
);
};
return (
@@ -356,7 +361,8 @@ export function validateTagName(name: string, existing: readonly string[] = []):
/** Log › New Tag…: a lightweight tag on the selected commit. */
export async function newTagOn(commit: Commit) {
const existing = await call(commands.vcsTags(currentRepo())).catch(() => [] as string[]);
const repo = commitRepo(commit) ?? currentRepo();
const existing = await call(commands.vcsTags(repo)).catch(() => [] as string[]);
const r = await promptText({
title: tx`Create New Tag On ${short(commit.id)}`,
label: "Enter the name of new tag:",
@@ -365,7 +371,7 @@ export async function newTagOn(commit: Commit) {
validate: (v) => validateTagName(v, existing),
});
if (!r) return;
const create = () => call(commands.vcsCreateTag(currentRepo(), r.value, commit.id, null, false));
const create = () => call(commands.vcsCreateTag(repo, r.value, commit.id, null, false));
const ok = await runOp(`Create tag ${r.value}`, create, {
success: `Created tag ${r.value} on ${short(commit.id)}`,
});
@@ -379,13 +385,14 @@ export function showTagDialog() {
}
function TagDialog({ onClose }: { onClose: () => void }) {
const [repo] = useState(currentRepo);
const [name, setName] = useState("");
const [commit, setCommit] = useState("HEAD");
const [message, setMessage] = useState("");
const [force, setForce] = useState(false);
const [existing, setExisting] = useState<string[]>([]);
useEffect(() => {
void call(commands.vcsTags(currentRepo())).then(setExisting, () => undefined);
void call(commands.vcsTags(repo)).then(setExisting, () => undefined);
}, []);
const error = validateTagName(name, force ? [] : existing);
const run = async () => {
@@ -394,7 +401,7 @@ function TagDialog({ onClose }: { onClose: () => void }) {
const tag = name.trim();
const ok = await runOp(
`Create tag ${tag}`,
() => call(commands.vcsCreateTag(currentRepo(), tag, commit.trim() || "HEAD", message || null, force)),
() => call(commands.vcsCreateTag(repo, tag, commit.trim() || "HEAD", message || null, force)),
{ success: `Created tag ${tag}` },
);
if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog();
@@ -472,7 +479,8 @@ interface Revision {
/** 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 [root] = useState(currentRepoRoot);
const [repo] = useState(currentRepo);
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState(0);
@@ -480,7 +488,6 @@ function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revis
const toRev = to?.rev ?? null;
useEffect(() => {
let alive = true;
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)));
@@ -489,7 +496,7 @@ function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revis
};
}, [fromRev, toRev]);
const open = (f: ChangedFile) => {
const repoRoot = currentRepoRoot() ?? root;
const repoRoot = root;
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
void openDiff({
path: `${repoRoot}/${f.path}`,
+6 -2
View File
@@ -321,7 +321,11 @@ export interface GitOperationOptions {
* Runs a long local git operation like `runRemote`: progress in the status bar,
* output in the Git console, a sticky balloon with Cancel.
*/
export async function runGitOperation(op: GitOperation, opts: GitOperationOptions): Promise<boolean> {
export async function runGitOperation(
op: GitOperation,
opts: GitOperationOptions,
repo = currentRepo(),
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" });
return false;
@@ -351,7 +355,7 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
task(line.trim() || null, false);
};
try {
await call(commands.vcsOperation(currentRepo(), request, op, channel));
await call(commands.vcsOperation(repo, request, op, channel));
let success = opts.success;
if (typeof success === "function") {
await refreshNow();
+9 -4
View File
@@ -1,3 +1,4 @@
import { commitRepo } from "./multiRoot";
/** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */
import { useEffect, useState } from "react";
import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings";
@@ -12,7 +13,7 @@ import { KIND_COLOR, baseName, dirName, stripSlash } from "./model";
import { errorMessage, logConsole, runOp, saveAll } from "./ops";
import { FileChecklist, defaultChangeFiles, reportApply } from "./shelf";
import { activeList, useChangelists } from "./changelists";
import { useVcs } from "./store";
import { currentRepoRoot, useVcs } from "./store";
import styles from "./common.module.css";
import { tx } from "@/platform/i18n";
@@ -76,6 +77,9 @@ async function afterPatchSaved(path: string) {
function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: () => void }) {
const root = useVcs((s) => s.root) ?? "";
const [repo] = useState(() =>
origin.kind === "commits" ? (commitRepo(origin.commits[0]!) ?? currentRepoRoot()) : currentRepoRoot(),
);
const listName = useChangelists((s) => activeList(s.state).name);
const [selected, setSelected] = useState(
() => new Set(origin.kind === "changes" ? origin.files.map((f) => f.path) : []),
@@ -103,7 +107,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
const done = await runOp(
"Create patch",
async () => {
const patch = await call(commands.vcsCreatePatch(source));
const patch = await call(commands.vcsCreatePatch(repo, source));
if (toFile) await call(commands.vcsWritePatch(file.trim(), patch));
else await navigator.clipboard.writeText(patch);
return true;
@@ -212,12 +216,13 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
}
function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: string; onClose: () => void }) {
const [repo] = useState(currentRepoRoot);
const [files, setFiles] = useState<PatchFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void saveAll().then(() =>
call(commands.vcsPatchPreview(patch))
call(commands.vcsPatchPreview(repo, patch))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(errorMessage(e))),
);
@@ -229,7 +234,7 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
const run = async () => {
onClose();
await saveAll();
const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(patch)), {
const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), {
refresh: false,
});
if (outcome) await reportApply(outcome, "Patch applied");
+27 -11
View File
@@ -1,3 +1,4 @@
import { commitRepo } from "./multiRoot";
/**
* Git Log history rewriting: Undo Commit, Edit Commit Message, Drop Commit and
* Interactively Rebase from Here (IDEA's Git.Log.ContextMenu).
@@ -46,6 +47,7 @@ const pushedWarning = (what: string) =>
// Undo Commit
export async function undoCommit(commit: Commit) {
const repo = commitRepo(commit) ?? currentRepo();
await refreshNow();
const branch = useVcs.getState().branch;
const problem = undoCommitProblem(commit, branch);
@@ -59,7 +61,7 @@ export async function undoCommit(commit: Commit) {
});
if (!ok) return;
}
const undo = () => call(commands.vcsUndoCommit(currentRepo(), commit.id));
const undo = () => call(commands.vcsUndoCommit(repo, commit.id));
const message = await runOp(`Undo commit ${short(commit.id)}`, undo, {
success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
});
@@ -71,7 +73,8 @@ export async function undoCommit(commit: Commit) {
// Rebase-based actions
/** The commits from `target` to HEAD, or a warning when they cannot be rewritten. */
async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | null> {
async function rangeFrom(target: Commit, verb: string): Promise<(RebaseRange & { repo: string | null }) | null> {
const repo = commitRepo(target) ?? currentRepo();
await refreshNow();
const branch = useVcs.getState().branch;
const op = inProgress(branch?.operation);
@@ -82,7 +85,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
const parent = target.parents[0];
try {
const history = await call(
commands.vcsLog(currentRepo(), {
commands.vcsLog(repo, {
branch: "HEAD",
path: null,
author: null,
@@ -106,14 +109,14 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
});
if (!ok) return null;
}
return range;
return { ...range, repo };
} catch (e) {
refuse(`Cannot ${verb}`, e instanceof Error ? e.message : String(e));
return null;
}
}
async function runRebase(base: string | null, entries: RebaseEntry[], done: string) {
async function runRebase(base: string | null, entries: RebaseEntry[], done: string, repo: string | null) {
await saveAll();
await runGitOperation(
{ op: "interactiveRebase", base, entries },
@@ -123,6 +126,7 @@ async function runRebase(base: string | null, entries: RebaseEntry[], done: stri
failure: "Rebase failed",
onError: conflictsInstead,
},
repo,
);
}
@@ -136,6 +140,7 @@ export async function editCommitMessage(commit: Commit) {
range.base,
singleCommitPlan(range, commit.id, "reword", message),
`Commit message of ${short(commit.id)} changed`,
range.repo,
);
}
@@ -158,7 +163,7 @@ export async function dropCommits(commits: Commit[]) {
action: ids.has(c.id) ? ("drop" as const) : ("pick" as const),
message: null,
}));
await runRebase(range.base, plan, `Dropped ${commits.length} commits`);
await runRebase(range.base, plan, `Dropped ${commits.length} commits`, range.repo);
}
async function dropCommit(commit: Commit) {
@@ -171,7 +176,12 @@ async function dropCommit(commit: Commit) {
tx`Drop commit ${short(commit.id)} “${commit.summary}” from the current branch? Its changes are removed.`,
);
if (!ok) return;
await runRebase(range.base, singleCommitPlan(range, commit.id, "drop"), `Dropped commit ${short(commit.id)}`);
await runRebase(
range.base,
singleCommitPlan(range, commit.id, "drop"),
`Dropped commit ${short(commit.id)}`,
range.repo,
);
}
/** Settings › Version Control › Confirmation › Ask for confirmation to drop commits, with "Don't ask again". */
@@ -190,7 +200,7 @@ async function confirmDrop(title: string, body: string): Promise<boolean> {
}
/** The range from the oldest of `commits`, when all of them are on the current branch. */
async function rangeFor(commits: Commit[], verb: string): Promise<RebaseRange | null> {
async function rangeFor(commits: Commit[], verb: string): Promise<(RebaseRange & { repo: string | null }) | null> {
// Log order is newest first: the last selected row is the oldest commit.
const oldest = commits[commits.length - 1]!;
const range = await rangeFrom(oldest, verb);
@@ -215,7 +225,7 @@ export async function squashCommits(commits: Commit[]) {
oldestFirst.map((c) => c.message.trim()).join("\n\n"),
);
if (message === null || !message.trim()) return;
await runRebase(range.base, squashPlan(range, ids, message), `Squashed ${commits.length} commits`);
await runRebase(range.base, squashPlan(range, ids, message), `Squashed ${commits.length} commits`, range.repo);
}
/**
@@ -293,7 +303,13 @@ const ACTIONS: Array<{ value: RebaseAction; label: string }> = [
{ value: "drop", label: "Drop" },
];
function InteractiveRebaseDialog({ range, onClose }: { range: RebaseRange; onClose: () => void }) {
function InteractiveRebaseDialog({
range,
onClose,
}: {
range: RebaseRange & { repo: string | null };
onClose: () => void;
}) {
const [rows, setRows] = useState<RebaseRow[]>(() => initialRows(range.commits));
const problem = planProblem(rows);
const noop = planIsNoop(rows, range.commits);
@@ -302,7 +318,7 @@ function InteractiveRebaseDialog({ range, onClose }: { range: RebaseRange; onClo
const start = () => {
if (problem || noop) return;
onClose();
void runRebase(range.base, toEntries(rows), "Rebase finished");
void runRebase(range.base, toEntries(rows), "Rebase finished", range.repo);
};
return (
<Dialog
+7 -4
View File
@@ -282,8 +282,9 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
}
function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClose: () => void }) {
const branch = useVcs((s) => s.branch);
const branches = useVcs((s) => s.branches);
const [repo] = useState(currentRepo);
const [branch] = useState(() => useVcs.getState().branch);
const [branches] = useState(() => useVcs.getState().branches);
const [commits, setCommits] = useState<Commit[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
@@ -305,10 +306,10 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
if (!head) return [];
if (!explicit) {
const q = outgoingQuery(upstream);
return logOf(q.branch, q.limit, q.range);
return logOf(q.branch, q.limit, q.range, repo);
}
const tip = revision ?? head;
return upstream ? logOf(tip, 1000, `${upstream}..${tip}`) : logOf(tip, 50);
return upstream ? logOf(tip, 1000, `${upstream}..${tip}`, repo) : logOf(tip, 50, null, repo);
})()
.then((c) => alive && setCommits(c))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
@@ -339,6 +340,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
target: retarget ? edited : null,
},
`${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`,
[repo],
);
return;
}
@@ -350,6 +352,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
tags: pushTags ? tagsMode : null,
},
upstream ? `${head} → ${upstream}` : `${head} → new remote branch`,
repo,
);
};
+6 -4
View File
@@ -225,14 +225,16 @@ export function applyStatus(status: FileStatus[], branch: BranchInfo | null) {
}
export async function loadBranches(): Promise<Branch[]> {
const branches = await call(commands.vcsBranches(currentRepo()));
useVcs.setState({ branches, branchesLoaded: true });
const repo = currentRepo();
const branches = await call(commands.vcsBranches(repo));
if (repo === currentRepo()) useVcs.setState({ branches, branchesLoaded: true });
return branches;
}
export async function loadStashes(): Promise<Stash[]> {
const stashes = await call(commands.vcsStashes(currentRepo()));
useVcs.setState({ stashes, stashesLoaded: true });
const repo = currentRepo();
const stashes = await call(commands.vcsStashes(repo));
if (repo === currentRepo()) useVcs.setState({ stashes, stashesLoaded: true });
return stashes;
}