diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index dee76b931..03d0159e8 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -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, diff --git a/src/bindings.ts b/src/bindings.ts index 6b0a8044a..5e318a887 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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`. */ diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index 4354bfc09..1e71b0bed 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -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; diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 75df34880..eef702444 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -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/")); +}); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 4ac5280a4..da190740c 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -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 && } {detail && {detail}} {deco.badge && {deco.badge}} @@ -353,6 +359,33 @@ const Row = memo(function Row({ ); }); +interface DecorationSuffixProps { + segments: readonly DecorationSegment[]; + color?: string; +} + +function DecorationSuffix({ segments, color }: DecorationSuffixProps) { + return ( + + {segments.map((s, i) => { + const text = s.strike ? {s.text} : s.text; + return ( + + {text} + {s.icon && } + + ); + })} + + ); +} + // --------------------------------------------------------------------------- // Member rows ("Show Members") diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 239964c3d..6f33e1f78 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -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", + }, ], }, { diff --git a/src/features/vcs/__tests__/rootSync.test.ts b/src/features/vcs/__tests__/rootSync.test.ts new file mode 100644 index 000000000..4a7e7ae0e --- /dev/null +++ b/src/features/vcs/__tests__/rootSync.test.ts @@ -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(); + 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()); + }); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index d741b3013..e16d2cd3d 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -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(), diff --git a/src/features/vcs/rootSync.ts b/src/features/vcs/rootSync.ts new file mode 100644 index 000000000..4a90223ff --- /dev/null +++ b/src/features/vcs/rootSync.ts @@ -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, b: ReadonlyMap): 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 = new Map(); + + const publish = (next: ReadonlyMap) => { + 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(); + 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 | 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()); + }; +} diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index d795db34d..96acb44d0 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -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", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 753b1ec74..92b59f07c 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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}" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e0276b589..12c1feb86 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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} 同步" } diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index f7ae73712..972a4d211 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -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), diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 931704c40..db25a20ad 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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. */