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": "目录映射",