feat(vcs): show branch and incoming/outgoing commits beside repository roots (#2605)

Settings > Version Control > Git > "Show branch and incoming/outgoing commits
in the Project view" (off by default) shows each repository root's branch and
its outgoing/incoming commit counts beside the root in the Project view. Git is
queried only after a change inside .git or when the set of roots changes, one
round at a time.

Co-authored-by: pdasilem <74730932+pdasilem@users.noreply.github.com>
This commit is contained in:
Sergei Melnikov
2026-11-11 08:46:16 +08:00
committed by GitHub
parent fe69210ef0
commit 2e4e5bb088
14 changed files with 418 additions and 4 deletions
+2
View File
@@ -43,6 +43,7 @@ impl Default for VcsSettings {
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
pub show_branch_in_project_view: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -74,6 +75,7 @@ impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
show_branch_in_project_view: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+1
View File
@@ -4732,6 +4732,7 @@ export type GitOperation =
export type GitSettings = {
/** Show blame after the current editor line. */
inlineBlame?: boolean,
showBranchInProjectView?: boolean,
/** Path to the git executable; empty = auto-detect. */
executable?: string,
/** Default of Update Project: `merge` | `rebase`. */
@@ -66,6 +66,22 @@
text-decoration-skip-ink: none;
text-underline-offset: 3px;
}
.suffix {
display: inline-flex;
flex: 0 1 auto;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: 6px;
overflow: hidden;
color: var(--fg-muted);
white-space: nowrap;
}
.suffixSegment {
display: inline-flex;
align-items: center;
gap: 1px;
}
.detail {
min-width: 0;
margin-left: 4px;
+38 -1
View File
@@ -3,7 +3,12 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
import { emitLocalIdeEvent } from "@/platform/ipc";
import { installRepeatedDoubleClick } from "@/platform/doubleClick";
import { getContext } from "@/platform/context";
import { provideEditorService, registerProjectViewRoot, type EditorService } from "@/workbench/contributions";
import {
provideEditorService,
registerProjectViewRoot,
setFileDecorations,
type EditorService,
} from "@/workbench/contributions";
import { activate } from "./index";
import { setOsClipboardForTests } from "./clipboard";
import { MemoryFs, setProjectFsForTests } from "./fs";
@@ -736,3 +741,35 @@ it("hides the path beside workspace roots when Show Root Paths is off, keeping i
expect(root?.getAttribute("title")).toBe("/a/service");
act(() => setViewOption("showRootPaths", true));
});
it("shows a decoration's suffix after the root name, with icons and struck-out segments", async () => {
const { row } = setup();
await waitFor(() => expect(row("acme")).toBeTruthy());
act(() =>
setFileDecorations(
"test-suffix",
new Map([
[
ROOT,
{
suffixColor: "var(--accent)",
suffix: [
{ text: "main" },
{ text: "/" },
{ text: "0", strike: true, label: "Up to date with origin/main" },
{ text: "2", icon: "vcs-outgoing", label: "2 outgoing commits" },
],
},
],
]),
),
);
await waitFor(() => expect(row("acme").textContent).toContain("main/02"));
const root = row("acme");
expect(root.querySelector("s")?.textContent).toBe("0");
expect(root.querySelector("s")?.closest("[style]")?.getAttribute("style")).toContain("var(--accent)");
expect(within(root).getByLabelText("2 outgoing commits").querySelector("svg")).not.toBeNull();
expect(within(root).getByLabelText("Up to date with origin/main").textContent).toBe("0");
act(() => setFileDecorations("test-suffix", new Map()));
await waitFor(() => expect(row("acme").textContent).not.toContain("main/"));
});
+34 -1
View File
@@ -26,7 +26,12 @@ import { nativeDropPoint } from "@/platform/nativeDrop";
import type { RootFolder } from "@/platform/languageSettings";
import { useDetectedRoots, useEffectiveRoots } from "@/platform/projectRoots";
import { macStroke, strokeOf } from "@/platform/keymap";
import { OWNS_FILE_DROP, prepareMenuGroups, useFileDecoration } from "@/workbench/contributions";
import {
OWNS_FILE_DROP,
prepareMenuGroups,
useFileDecoration,
type DecorationSegment,
} from "@/workbench/contributions";
import {
FILES_DRAG,
HighlightedText,
@@ -345,6 +350,7 @@ const Row = memo(function Row({
indices={matches}
tone="accent"
/>
{deco.suffix && <DecorationSuffix segments={deco.suffix} color={deco.suffixColor} />}
{detail && <span className={styles.detail}>{detail}</span>}
{deco.badge && <span className={styles.badge}>{deco.badge}</span>}
</>
@@ -353,6 +359,33 @@ const Row = memo(function Row({
);
});
interface DecorationSuffixProps {
segments: readonly DecorationSegment[];
color?: string;
}
function DecorationSuffix({ segments, color }: DecorationSuffixProps) {
return (
<span className={styles.suffix} style={color ? { color } : undefined}>
{segments.map((s, i) => {
const text = s.strike ? <s>{s.text}</s> : s.text;
return (
<span
key={i}
className={styles.suffixSegment}
role={s.label ? "img" : undefined}
aria-label={s.label}
title={s.label}
>
{text}
{s.icon && <Icon name={s.icon} size={12} />}
</span>
);
})}
</span>
);
}
// ---------------------------------------------------------------------------
// Member rows ("Show Members")
+5
View File
@@ -494,6 +494,11 @@ export const VCS_PAGES: PageSpec[] = [
component: GitExecutableRow,
},
{ kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" },
{
kind: "toggle",
key: "vcs.git.showBranchInProjectView",
label: "Show branch and incoming/outgoing commits in the Project view",
},
],
},
{
+201
View File
@@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BranchInfo } from "@/bindings";
const ipc = vi.hoisted(() => ({ branchInfo: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, vcsBranchInfo: ipc.branchInfo } };
});
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { getFileDecoration } from "@/workbench/contributions";
import { installRootSync, rootSyncSuffix } from "../rootSync";
import { useVcs } from "../store";
const info = (head: string | null, upstream: string | null, ahead = 0, behind = 0): BranchInfo => ({
head,
headCommit: "c1",
upstream,
ahead,
behind,
operation: null,
});
describe("rootSyncSuffix", () => {
it("strikes out zero when the branch matches its upstream", () => {
expect(rootSyncSuffix(info("main", "origin/main"))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "0", strike: true, label: "Up to date with origin/main" },
]);
});
it("shows outgoing then incoming commits with their icons", () => {
expect(rootSyncSuffix(info("main", "origin/main", 1, 3))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "1", icon: "vcs-outgoing", label: "1 outgoing commits" },
{ text: "3", icon: "vcs-incoming", label: "3 incoming commits" },
]);
expect(rootSyncSuffix(info("main", "origin/main", 0, 2))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "2", icon: "vcs-incoming", label: "2 incoming commits" },
]);
});
it("shows only the branch without an upstream", () => {
expect(rootSyncSuffix(info("feature", null))).toEqual([{ text: "feature" }]);
});
it("shows nothing for a detached HEAD", () => {
expect(rootSyncSuffix(info(null, null))).toBeNull();
});
});
describe("installRootSync", () => {
const enable = (on: boolean) =>
useProject.setState({ settings: { vcs: { git: { showBranchInProjectView: on } } } } as never);
const gitChanged = () => emitLocalIdeEvent({ type: "vcsChanged", root: "/p" });
const settle = () => new Promise((resolve) => setTimeout(resolve, 400));
let dispose: () => void;
beforeEach(() => {
ipc.branchInfo.mockReset().mockImplementation(async (root: string) => ({
status: "ok",
data: root === "/p" ? info("main", "origin/main", 1, 0) : info("dev", null),
}));
useVcs.setState({ available: true, roots: ["/p", "/p/lib"] });
});
afterEach(() => {
dispose?.();
useProject.setState({ settings: null });
});
it("decorates every repository root when the setting is on", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.[2]?.text).toBe("1"));
expect(getFileDecoration("/p/lib")?.suffix).toEqual([{ text: "dev" }]);
expect(getFileDecoration("/p")?.suffixColor).toBe("var(--accent)");
expect(ipc.branchInfo).toHaveBeenCalledWith("/p");
expect(ipc.branchInfo).toHaveBeenCalledWith("/p/lib");
});
it("shows nothing and queries nothing while the setting is off", async () => {
enable(false);
dispose = installRootSync();
gitChanged();
await settle();
expect(ipc.branchInfo).not.toHaveBeenCalled();
expect(getFileDecoration("/p")?.suffix).toBeUndefined();
});
it("queries again after a change inside .git, such as a fetch, push or commit", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.[2]?.text).toBe("1"));
ipc.branchInfo.mockImplementation(async (root: string) => ({
status: "ok",
data: root === "/p" ? info("main", "origin/main", 1, 4) : info("dev", null),
}));
gitChanged();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.[3]?.text).toBe("4"));
});
it("does not query git when only the status refreshes or worktree files change", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledTimes(2));
useVcs.setState((s) => ({ version: s.version + 1 }));
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: ["/p/a.txt"], removed: [] } as never);
await settle();
expect(ipc.branchInfo).toHaveBeenCalledTimes(2);
});
it("queries the repositories again when the set of roots changes", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledTimes(2));
useVcs.setState({ roots: ["/p", "/p/lib", "/p/web"] });
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledWith("/p/web"));
});
it("clears the decorations when the setting is switched off", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toBeDefined());
enable(false);
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toBeUndefined());
});
it("skips a repository whose branch cannot be read", async () => {
ipc.branchInfo.mockImplementation(async (root: string) =>
root === "/p" ? { status: "error", error: "boom" } : { status: "ok", data: info("dev", null) },
);
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p/lib")?.suffix).toEqual([{ text: "dev" }]));
expect(getFileDecoration("/p")?.suffix).toBeUndefined();
});
it("keeps a repository's previous suffix when a later read fails", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.[2]?.text).toBe("1"));
ipc.branchInfo.mockImplementation(async (root: string) =>
root === "/p" ? { status: "error", error: "index.lock" } : { status: "ok", data: info("dev", "origin/dev") },
);
gitChanged();
await vi.waitFor(() => expect(getFileDecoration("/p/lib")?.suffix?.[2]?.text).toBe("0"));
expect(getFileDecoration("/p")?.suffix?.[2]?.text).toBe("1");
});
it("runs one query round at a time and coalesces Git changes made meanwhile", async () => {
const pending: Array<() => void> = [];
ipc.branchInfo.mockImplementation(
(root: string) =>
new Promise((resolve) =>
pending.push(() => resolve({ status: "ok", data: info(root === "/p" ? "main" : "dev", null) })),
),
);
enable(true);
dispose = installRootSync();
expect(ipc.branchInfo).toHaveBeenCalledTimes(2);
gitChanged();
await settle();
gitChanged();
await settle();
expect(ipc.branchInfo).toHaveBeenCalledTimes(2);
pending.splice(0).forEach((done) => done());
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledTimes(4));
pending.splice(0).forEach((done) => done());
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toEqual([{ text: "main" }]));
await settle();
expect(ipc.branchInfo).toHaveBeenCalledTimes(4);
});
it("does not publish results that arrive after disposal", async () => {
let finish = () => {};
ipc.branchInfo.mockImplementation(
() => new Promise((resolve) => (finish = () => resolve({ status: "ok", data: info("main", null) }))),
);
useVcs.setState({ roots: ["/p"] });
enable(true);
dispose = installRootSync();
dispose();
finish();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getFileDecoration("/p")?.suffix).toBeUndefined();
});
it("clears the decorations when the project has no repositories any more", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toBeDefined());
useVcs.setState({ available: false, roots: [] });
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toBeUndefined());
});
});
+2
View File
@@ -47,6 +47,7 @@ import { installFileConfirmations } from "./confirmations";
import { installIncomingCheck } from "./incoming";
import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore";
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
import { installRootSync } from "./rootSync";
import { tx } from "@/platform/i18n";
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
@@ -213,6 +214,7 @@ export function activate(): () => void {
useCommit.setState({ signOff: workflowSettings().vcs.git.signOff });
const disposers = [
installVcsStore(),
installRootSync(),
installGitLog(),
installDiagnosticsTracking(),
installPreparedMessage(),
+100
View File
@@ -0,0 +1,100 @@
import type { BranchInfo } from "@/bindings";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { setFileDecorations, type DecorationSegment, type FileDecoration } from "@/workbench/contributions";
import { useVcs } from "./store";
const PROVIDER = "vcs-root-sync";
const GIT_CHANGE_DELAY_MS = 250;
const SUFFIX_COLOR = "var(--accent)";
export function rootSyncSuffix(info: BranchInfo): DecorationSegment[] | null {
const { head, upstream, ahead, behind } = info;
if (!head) return null;
if (!upstream) return [{ text: head }];
const counts: DecorationSegment[] = [];
if (ahead > 0) counts.push({ text: String(ahead), icon: "vcs-outgoing", label: tx`${ahead} outgoing commits` });
if (behind > 0) counts.push({ text: String(behind), icon: "vcs-incoming", label: tx`${behind} incoming commits` });
if (!counts.length) counts.push({ text: "0", strike: true, label: tx`Up to date with ${upstream}` });
return [{ text: head }, { text: "/" }, ...counts];
}
async function branchInfoOf(root: string): Promise<[string, BranchInfo] | null> {
try {
return [root, await call(commands.vcsBranchInfo(root))];
} catch {
return null;
}
}
function sameDecorations(a: ReadonlyMap<string, FileDecoration>, b: ReadonlyMap<string, FileDecoration>): boolean {
if (a.size !== b.size) return false;
for (const [root, d] of a) if (JSON.stringify(d) !== JSON.stringify(b.get(root))) return false;
return true;
}
export function installRootSync(): () => void {
let disposed = false;
let running = false;
let pending = false;
let shown: ReadonlyMap<string, FileDecoration> = new Map();
const publish = (next: ReadonlyMap<string, FileDecoration>) => {
if (disposed || sameDecorations(next, shown)) return;
shown = next;
setFileDecorations(PROVIDER, new Map(next));
};
const update = async () => {
const { available, roots } = useVcs.getState();
if (!available || !workflowSettings().vcs.git.showBranchInProjectView) return publish(new Map());
const infos = await Promise.all(roots.map(branchInfoOf));
const next = new Map<string, FileDecoration>();
roots.forEach((root, i) => {
const entry = infos[i];
const suffix = entry ? rootSyncSuffix(entry[1]) : shown.get(root)?.suffix;
if (suffix) next.set(root, { suffix, suffixColor: SUFFIX_COLOR });
});
publish(next);
};
const schedule = () => {
if (running) {
pending = true;
return;
}
running = true;
void (async () => {
try {
do {
pending = false;
await update();
} while (pending && !disposed);
} finally {
running = false;
}
})();
};
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleSoon = () => {
clearTimeout(timer);
timer = setTimeout(schedule, GIT_CHANGE_DELAY_MS);
};
const unsubVcs = useVcs.subscribe((s, prev) => {
if (s.available !== prev.available || s.roots.join("\n") !== prev.roots.join("\n")) schedule();
});
const offGit = onIdeEvent("vcsChanged", scheduleSoon);
const offSettings = onWorkflowSettingsChange((s) => s.vcs.git.showBranchInProjectView, schedule);
schedule();
return () => {
disposed = true;
clearTimeout(timer);
unsubVcs();
offGit();
offSettings();
setFileDecorations(PROVIDER, new Map());
};
}
+1
View File
@@ -83,6 +83,7 @@ export const CLIENT_SETTINGS_KEYS = [
"run.showOnStart",
"vcs.showDirtyDirectories",
"vcs.git.inlineBlame",
"vcs.git.showBranchInProjectView",
"vcs.git.showPushDialog",
"vcs.git.pushDialogOnlyProtected",
"vcs.git.combineStashAndShelf",
+3 -1
View File
@@ -983,5 +983,7 @@
"Import from Project": "Import from Project",
"No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "No new MCP servers in the project's .mcp.json or .vscode/mcp.json.",
"Imported 1 MCP server from the project.": "Imported 1 MCP server from the project.",
"Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project."
"Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.",
"Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view",
"Up to date with {upstream}": "Up to date with {upstream}"
}
+3 -1
View File
@@ -7315,5 +7315,7 @@
"Import from Project": "从项目导入",
"No new MCP servers in the project's .mcp.json or .vscode/mcp.json.": "项目的 .mcp.json 和 .vscode/mcp.json 中没有新的 MCP 服务器。",
"Imported 1 MCP server from the project.": "已从项目导入 1 个 MCP 服务器。",
"Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。"
"Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。",
"Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交",
"Up to date with {upstream}": "已与 {upstream} 同步"
}
+3
View File
@@ -19,6 +19,7 @@ export interface IssueLinkValid {
export interface GitSettingsValid {
inlineBlame: boolean;
showBranchInProjectView: boolean;
executable: string;
updateMethod: UpdateMethod;
autoUpdateOnPushRejected: boolean;
@@ -190,6 +191,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
historyLimit: 1000,
git: {
inlineBlame: false,
showBranchInProjectView: false,
executable: "",
updateMethod: "merge",
autoUpdateOnPushRejected: false,
@@ -321,6 +323,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true),
git: {
inlineBlame: bool(g.inlineBlame, d.git.inlineBlame),
showBranchInProjectView: bool(g.showBranchInProjectView, d.git.showBranchInProjectView),
executable: text(g.executable, d.git.executable).trim(),
updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod),
autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected),
+9
View File
@@ -826,9 +826,18 @@ export function showFindResults(source: FindResultsSource) {
// tree, editor tabs, Search Everywhere, etc. Providers (VCS status, problems)
// push decorations into this store; consumers read with `useFileDecoration`.
export interface DecorationSegment {
text: string;
icon?: string;
strike?: boolean;
label?: string;
}
export interface FileDecoration {
/** CSS colour, normally a token like `var(--vcs-modified)`. */
color?: string;
suffix?: readonly DecorationSegment[];
suffixColor?: string;
/** CSS colour tinting the editor tab (a data source's colour on its console). */
tint?: string;
/** Short badge text, e.g. a problem count. */