From 5e7339e4c233f0b7e2160361051f07d93314ee8c Mon Sep 17 00:00:00 2001 From: Florent V Date: Tue, 3 Nov 2026 19:22:56 +0100 Subject: [PATCH] feat(vcs): group commit changes by staging state (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit view › Group By gains "Staging State". When it is on, Changes splits into Staged Changes and Unstaged Changes; a partially staged file appears in both, and its diff compares HEAD with the Index under Staged and the Index with the working copy under Unstaged. The choice is remembered across sessions. Closes #1067 Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- src/features/vcs/CommitView.tsx | 25 +++++++---- src/features/vcs/__tests__/changes.test.ts | 41 ++++++++++++++++++ src/features/vcs/actions.tsx | 1 + src/features/vcs/changes.ts | 48 +++++++++++++++++++++- src/features/vcs/commitStore.ts | 12 ++++++ src/features/vcs/diffInput.ts | 8 +++- src/platform/locales/text/zh.json | 3 ++ 7 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 69d04972f..84ca3d674 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -37,12 +37,13 @@ import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { aggregateCheck, buildChangeTree, - changeGroupPaths, checkOf, filesOf, + groupFileNodes, ignoreTargets, isPartiallyStaged, planCommit, + stagingSide, toggledCheck, type ChangeNode, } from "./changes"; @@ -50,7 +51,15 @@ import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; -import { messageHistory, setAmend, setDiffPreview, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; +import { + messageHistory, + setAmend, + setDiffPreview, + setGroupByDir, + setGroupByStaging, + useCommit, + type CommitTab, +} from "./commitStore"; import { pushAfterCommit } from "./commitUi"; import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput"; @@ -169,9 +178,9 @@ const bottomLeft = (el: HTMLElement) => { /** Opens a file's diff; F7 continues through the other files of its group, in tree order. */ function openNode(n: ChangeNode, roots: readonly ChangeNode[]) { if (n.type !== "file" || !n.path) return; - const paths = changeGroupPaths(roots, n.path); - const items = paths.map((p) => workingDiffData(p)); - void openDiff(withChain(items, Math.max(0, paths.indexOf(n.path))), { preview: true }); + const nodes = groupFileNodes(roots, n); + const items = nodes.map((f) => workingDiffData(f.path!, undefined, stagingSide(f.group, f.files[0]!))); + void openDiff(withChain(items, Math.max(0, nodes.indexOf(n))), { preview: true }); } /** Context menu of a change list's header: the list's own actions, then the bulk ones on its files. */ @@ -384,6 +393,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const status = useVcs((s) => s.status); const root = useVcs((s) => s.root) ?? ""; const groupByDir = useCommit((s) => s.groupByDir); + const groupByStaging = useCommit((s) => s.groupByStaging); const diffPreview = useCommit((s) => s.diffPreview); const lists = useChangelists((s) => s.state); const checks = useCommitChecks(); @@ -398,8 +408,8 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const repos = useVcs((s) => s.roots); const projectRoots = useEffectiveRoots(); const roots = useMemo( - () => buildChangeTree(status, root, groupByDir, lists, repos), - [status, root, groupByDir, lists, repos], + () => buildChangeTree(status, root, groupByDir, lists, repos, groupByStaging), + [status, root, groupByDir, lists, repos, groupByStaging], ); const [byKey, parents] = useMemo(() => { const m = new Map(); @@ -471,6 +481,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { showContextMenu(bottomLeft(viewRef.current), [ { kind: "header", label: "Group By" }, { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + { label: "Staging State", checked: groupByStaging, run: () => setGroupByStaging(!groupByStaging) }, ...(mode === "local" ? [ { kind: "separator" } as const, diff --git a/src/features/vcs/__tests__/changes.test.ts b/src/features/vcs/__tests__/changes.test.ts index 0b64b6147..38ad0400b 100644 --- a/src/features/vcs/__tests__/changes.test.ts +++ b/src/features/vcs/__tests__/changes.test.ts @@ -7,7 +7,9 @@ import { ignoreTargets, initialCheck, hunkIncluded, + groupFileNodes, planCommit, + stagingSide, toggledHunk, type CheckState, type ChangeNode, @@ -35,6 +37,45 @@ const status = [ const shape = (nodes: ChangeNode[], depth = 0): string[] => nodes.flatMap((n) => [`${" ".repeat(depth)}${n.label}`, ...shape(n.children ?? [], depth + 1)]); +describe("buildChangeTree by staging state", () => { + const tree = buildChangeTree(status, "/r", false, undefined, [], true); + const names = (g: string) => (tree.find((n) => n.group === g)?.children ?? []).map((n) => n.label); + + it("splits Changes into Staged and Unstaged, keeping the other groups", () => { + expect(tree.map((n) => n.label)).toEqual([ + "Merge Conflicts", + "Staged Changes", + "Unstaged Changes", + "Unversioned Files", + ]); + }); + + it("lists a partially staged file under both groups", () => { + expect(names("staged")).toEqual(["AppTest.kt", "Partial.kt", "README.md"]); + expect(names("unstaged")).toEqual(["App.kt", "Partial.kt"]); + }); + + it("compares a partially staged file on the side of its group only", () => { + const partial = status[1]!; + expect(stagingSide("staged", partial)).toBe("staged"); + expect(stagingSide("unstaged", partial)).toBe("unstaged"); + expect(stagingSide("staged", status[3]!)).toBeNull(); + expect(stagingSide("changes", partial)).toBeNull(); + }); + + it("chains the diff through the files of the clicked group", () => { + const node = tree.find((n) => n.group === "unstaged")!.children![1]!; + expect(groupFileNodes(tree, node).map((n) => n.key)).toEqual([ + "unstaged:/r/src/main/kotlin/App.kt", + "unstaged:/r/src/main/kotlin/Partial.kt", + ]); + }); + + it("is off by default", () => { + expect(buildChangeTree(status, "/r", false).some((n) => n.group === "staged")).toBe(false); + }); +}); + describe("buildChangeTree", () => { it("groups conflicts, changes and unversioned files (flat)", () => { expect(shape(buildChangeTree(status, "/r", false))).toEqual([ diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index c35af0463..2edcfb400 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -334,6 +334,7 @@ export function registerVcsActions(): () => void { useCommit.getState().groupByDir, useChangelists.getState().state, state.roots, + useCommit.getState().groupByStaging, ); for (const path of paths.slice(0, 5)) { const siblings = changeGroupPaths(tree, path); diff --git a/src/features/vcs/changes.ts b/src/features/vcs/changes.ts index 307db4f95..9559885b5 100644 --- a/src/features/vcs/changes.ts +++ b/src/features/vcs/changes.ts @@ -3,14 +3,21 @@ import type { ChangelistState, FileStatus } from "@/bindings"; import { baseName, dirName, effectiveKind, relTo, repoLabel, rootOf, stripSlash, type EffectiveKind } from "./model"; import { tx } from "@/platform/i18n"; -export type GroupId = "conflicts" | "changes" | "unversioned"; +export type GroupId = "conflicts" | "changes" | "unversioned" | "staged" | "unstaged"; export const GROUP_LABEL: Record = { conflicts: "Merge Conflicts", changes: "Changes", unversioned: "Unversioned Files", + staged: "Staged Changes", + unstaged: "Unstaged Changes", }; +/** The index side a file under a "by staging state" group is compared on (partially staged files only). */ +export function stagingSide(group: GroupId, fs: FileStatus): "staged" | "unstaged" | null { + return (group === "staged" || group === "unstaged") && isPartiallyStaged(fs) ? group : null; +} + /** * Inclusion of a file in the next commit: * `all` – stage everything before committing; `none` – keep out (unstage); @@ -216,7 +223,8 @@ function tagList(nodes: ChangeNode[], id: string): ChangeNode[] { * Group roots (Merge Conflicts / Changes / Unversioned Files) with flat or directory * children. With change lists, "Changes" is split into one group per list * (`ChangelistState.files` keys are project-relative paths). With several - * repositories (`repos`), each group's files are grouped by repository first. + * repositories (`repos`), each group's files are grouped by repository first. With + * `byStaging`, "Changes" becomes "Staged Changes" and "Unstaged Changes" instead. */ export function buildChangeTree( status: readonly FileStatus[], @@ -224,6 +232,7 @@ export function buildChangeTree( groupByDir: boolean, lists?: ChangelistState, repos: readonly string[] = [], + byStaging = false, ): ChangeNode[] { const byGroup = new Map(); for (const fs of status) { @@ -236,6 +245,26 @@ export function buildChangeTree( const roots: ChangeNode[] = []; for (const g of KIND_ORDER) { const files = byGroup.get(g) ?? []; + if (g === "changes" && byStaging) { + // A partially staged file is listed under both, each opening its own side of the index. + const sides: Array<["staged" | "unstaged", FileStatus[]]> = [ + ["staged", files.filter((f) => !!f.staged)], + ["unstaged", files.filter((f) => !!f.unstaged)], + ]; + for (const [side, own] of sides) { + if (!own.length) continue; + const children = repoNodes(own, root, side, groupByDir, side, repos); + roots.push({ + key: `group:${side}`, + type: "group", + label: GROUP_LABEL[side], + group: side, + children, + files: own, + }); + } + continue; + } if (g === "changes" && lists) { const byList = new Map(lists.lists.map((l) => [l.id, []])); for (const f of files) { @@ -282,6 +311,21 @@ export function changeGroupPaths(roots: readonly ChangeNode[], path: string): st return paths; } +/** File nodes, in tree order, of the group a file node belongs to. */ +export function groupFileNodes(roots: readonly ChangeNode[], node: ChangeNode): ChangeNode[] { + const group = roots.find((r) => r.group === node.group && r.files.some((f) => stripSlash(f.path) === node.path)); + if (!group) return [node]; + const out: ChangeNode[] = []; + const walk = (nodes: readonly ChangeNode[]) => { + for (const n of nodes) { + if (n.type === "file" && n.path) out.push(n); + else walk(n.children ?? []); + } + }; + walk([group]); + return out; +} + export interface CommitPlan { stage: string[]; unstage: string[]; diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index b1f78b721..aa0536c74 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -14,6 +14,8 @@ interface CommitState { /** Commit Options › Run Git hooks. */ runHooks: boolean; groupByDir: boolean; + /** Group the changes into Staged / Unstaged instead of one "Changes" group. */ + groupByStaging: boolean; /** Whether the Git window's Local Changes tab previews the selection's diff. */ diffPreview: boolean; /** User overrides of the inclusion checkboxes (absolute path → state). */ @@ -44,6 +46,7 @@ export const useCommit = create(() => ({ author: "", runHooks: true, groupByDir: readBool("openidea.vcs.groupByDir", false), + groupByStaging: readBool("openidea.vcs.groupByStaging", false), diffPreview: readBool("openidea.vcs.diffPreview", true), checks: new Map(), excludedHunks: new Map(), @@ -74,6 +77,15 @@ export function setGroupByDir(v: boolean) { } } +export function setGroupByStaging(v: boolean) { + useCommit.setState({ groupByStaging: v }); + try { + localStorage.setItem("openidea.vcs.groupByStaging", String(v)); + } catch { + // ignore + } +} + export function setDiffPreview(v: boolean) { useCommit.setState({ diffPreview: v }); try { diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index 583c8bb93..861bc7145 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -130,7 +130,13 @@ export async function openDiff(data: DiffData, opts: { preview?: boolean; focus? } /** Default local-changes diff for a working file: HEAD (or empty) vs the working copy. */ -export function workingDiffData(path: string, line?: number): DiffData { +export function workingDiffData(path: string, line?: number, side?: "staged" | "unstaged" | null): DiffData { + if (side === "staged") { + return { path, left: { type: "rev", rev: "HEAD" }, right: { type: "rev", rev: ":0", label: "Index" }, line }; + } + if (side === "unstaged") { + return { path, left: { type: "rev", rev: ":0", label: "Index" }, right: { type: "worktree" }, line }; + } const st = statusOf(path); const kind = st ? effectiveKind(st) : null; const left: DiffSide = diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 756086f80..cafd9a141 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -675,6 +675,9 @@ "Different tool window background": "不同的工具窗口背景", "Direction": "方向", "Directory": "目录", + "Staging State": "暂存状态", + "Staged Changes": "已暂存的更改", + "Unstaged Changes": "未暂存的更改", "Directory (relative to project root)": "目录(相对于项目根目录)", "Directory Mappings": "目录映射", "Directory mappings": "目录映射",