Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai ee3ad070fd feat(vcs): show the number of changed files in the Project view's branch suffix
With "Show branch and incoming/outgoing commits in the Project view" on, a
repository root with local changes now also shows how many files changed
(staged, unstaged or untracked; ignored files aside), e.g. main/↑2 ✎7, with
a tooltip; a clean repository shows nothing extra. The count follows the
working tree status as it refreshes, from the branches already read, without
asking git again.
2026-11-15 01:08:33 +08:00
2 змінених файлів з 78 додано та 9 видалено

No files matched your search

+29 -1
Переглянути файл
@@ -113,7 +113,7 @@ describe("installRootSync", () => {
status: "ok",
data: root === "/p" ? info("main", "origin/main", 1, 0) : info("dev", null),
}));
useVcs.setState({ available: true, roots: ["/p", "/p/lib"] });
useVcs.setState({ available: true, roots: ["/p", "/p/lib"], status: [] });
});
afterEach(() => {
@@ -164,6 +164,34 @@ describe("installRootSync", () => {
expect(ipc.branchInfo).toHaveBeenCalledTimes(2);
});
it("counts the changed files of each repository and follows the status without asking git", async () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledTimes(2));
const file = (path: string, unstaged: string) =>
({ path, relPath: path, staged: null, unstaged, renamedFrom: null }) as never;
useVcs.setState({
status: [
file("/p/a.ts", "modified"),
file("/p/b.ts", "untracked"),
file("/p/lib/c.ts", "added"),
file("/p/d.log", "ignored"),
],
});
await vi.waitFor(() =>
expect(getFileDecoration("/p")?.suffix?.at(-1)).toEqual({
text: "2",
icon: "edit",
label: "2 changed files",
color: "var(--vcs-modified)",
}),
);
expect(getFileDecoration("/p/lib")?.suffix?.map((s) => s.text)).toEqual(["dev", "1"]);
useVcs.setState({ status: [] });
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.map((s) => s.text)).toEqual(["main", "/", "1"]));
expect(ipc.branchInfo).toHaveBeenCalledTimes(2);
});
it("queries the repositories again when the set of roots changes", async () => {
enable(true);
dispose = installRootSync();
+49 -8
Переглянути файл
@@ -1,8 +1,9 @@
import type { BranchInfo } from "@/bindings";
import type { BranchInfo, FileStatus } 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 { rootOf } from "./model";
import { useVcs } from "./store";
const PROVIDER = "vcs-root-sync";
@@ -20,7 +21,13 @@ const styled = (attr: string, extra: Pick<DecorationSegment, "text"> & Decoratio
...extra,
});
export function rootSyncSuffix(info: BranchInfo): DecorationSegment[] | null {
/** How many files of the working tree changed (staged, unstaged or untracked): nothing when clean. */
function changedFiles(count: number): DecorationSegment[] {
if (count <= 0) return [];
return [{ text: String(count), icon: "edit", label: tx`${count} changed files`, color: "var(--vcs-modified)" }];
}
export function rootSyncSuffix(info: BranchInfo, changed = 0): DecorationSegment[] | null {
const { head, upstream, ahead, behind } = info;
if (!head) return null;
const branchAttr = upstream
@@ -32,7 +39,7 @@ export function rootSyncSuffix(info: BranchInfo): DecorationSegment[] | null {
: "local-branch";
const branchColor = `var(--git-${branchAttr}, var(--accent))`;
const branch: DecorationSegment = styled(branchAttr, { text: head, color: branchColor });
if (!upstream) return [branch];
if (!upstream) return [branch, ...changedFiles(changed)];
const counts: DecorationSegment[] = [];
if (ahead > 0)
counts.push(
@@ -61,7 +68,23 @@ export function rootSyncSuffix(info: BranchInfo): DecorationSegment[] | null {
color: "var(--git-head-tags, var(--accent))",
}),
);
return [branch, styled("head-tags", { text: "/", color: "var(--git-head-tags, var(--accent))" }), ...counts];
return [
branch,
styled("head-tags", { text: "/", color: "var(--git-head-tags, var(--accent))" }),
...counts,
...changedFiles(changed),
];
}
/** Changed files per repository root (each file counted once, in its innermost repository). */
export function changedByRoot(status: readonly FileStatus[], roots: readonly string[]): Map<string, number> {
const out = new Map<string, number>();
for (const f of status) {
if (f.staged === "ignored" || f.unstaged === "ignored") continue;
const root = rootOf(f.path, roots);
if (root) out.set(root, (out.get(root) ?? 0) + 1);
}
return out;
}
async function branchInfoOf(root: string): Promise<[string, BranchInfo] | null> {
@@ -83,6 +106,7 @@ export function installRootSync(): () => void {
let running = false;
let pending = false;
let shown: ReadonlyMap<string, FileDecoration> = new Map();
let branches = new Map<string, BranchInfo>();
const publish = (next: ReadonlyMap<string, FileDecoration>) => {
if (disposed || sameDecorations(next, shown)) return;
@@ -94,12 +118,27 @@ export function installRootSync(): () => void {
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>();
const next = new Map<string, BranchInfo>();
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 });
// A repository whose branch can't be read now keeps what it showed.
const info = infos[i]?.[1] ?? branches.get(root);
if (info) next.set(root, info);
});
branches = next;
render();
};
/** The suffixes from the branches last read and the current working tree status (no git query). */
const render = () => {
const { available, roots, status } = useVcs.getState();
if (!available || !workflowSettings().vcs.git.showBranchInProjectView) return publish(new Map());
const changed = changedByRoot(status, roots);
const next = new Map<string, FileDecoration>();
for (const root of roots) {
const info = branches.get(root);
const suffix = info ? rootSyncSuffix(info, changed.get(root) ?? 0) : null;
if (suffix) next.set(root, { suffix, suffixColor: SUFFIX_COLOR });
}
publish(next);
};
@@ -129,6 +168,8 @@ export function installRootSync(): () => void {
const unsubVcs = useVcs.subscribe((s, prev) => {
if (s.available !== prev.available || s.roots.join("\n") !== prev.roots.join("\n")) schedule();
// The changed-file counts follow the working tree as the status refreshes, without asking git.
else if (s.status !== prev.status) render();
});
const offGit = onIdeEvent("vcsChanged", scheduleSoon);
const offSettings = onWorkflowSettingsChange((s) => s.vcs.git.showBranchInProjectView, schedule);