diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 33871d658..e29f8dfc3 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -55,6 +55,7 @@ import { moveToChangelist } from "./changelistsUi"; import { messageHistory, setAmend, + setCommitDiffPreview, setDiffPreview, setGroupByDir, setGroupByStaging, @@ -357,18 +358,43 @@ export function useIncludedCount(): number { return useMemo(() => planCommit(status, checks).included.length, [status, checks]); } -/** The Commit tool window's Commit tab: changes with inclusion checkboxes above the message. */ +/** + * The Commit tool window's Commit tab: changes with inclusion checkboxes above the + * message, with an optional embedded preview of the selected file's diff beside them + * (issue #1599). + */ function ChangesPanel() { const included = useIncludedCount(); + const preview = useCommit((s) => s.commitDiffPreview); + const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null)); + const hasChanges = useVcs((s) => s.status.length > 0); return ( - + - - - - - + + + + + + + + + + {preview && ( + <> + + + {path ? ( + + ) : ( + + {hasChanges ? "Select a file to preview its changes" : "No changes to preview"} + + )} + + + )} ); } @@ -415,6 +441,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { const groupByDir = useCommit((s) => s.groupByDir); const groupByStaging = useCommit((s) => s.groupByStaging); const diffPreview = useCommit((s) => s.diffPreview); + const commitDiffPreview = useCommit((s) => s.commitDiffPreview); const lists = useChangelists((s) => s.state); const checks = useCommitChecks(); const checkboxes = mode !== "local"; @@ -511,6 +538,16 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { ]) } /> + {/* WebStorm's Preview Diff: an embedded diff of the selection beside the changes (#1599). */} + {mode === "commit" && ( + setCommitDiffPreview(!commitDiffPreview)} + /> + )} {mode !== "dialog" && ( diff --git a/src/features/vcs/__tests__/commitPreviewDiff.test.tsx b/src/features/vcs/__tests__/commitPreviewDiff.test.tsx new file mode 100644 index 000000000..6820d4573 --- /dev/null +++ b/src/features/vcs/__tests__/commitPreviewDiff.test.tsx @@ -0,0 +1,143 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn() }; +}); + +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: { data: { path: string } } }) => ( +
+ ), +})); + +const openDiff = vi.hoisted(() => vi.fn()); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff, +})); + +import { CommitToolWindow } from "../CommitView"; +import { setCommitDiffPreview, useCommit } from "../commitStore"; +import { useVcs } from "../store"; + +/** The splitter between the changes and the preview: the only vertical one in the layout. */ +const previewHandle = () => + screen.queryAllByRole("separator").find((s) => s.getAttribute("aria-orientation") === "vertical"); + +const modified = (path: string) => ({ + path, + relPath: path.slice(3), + staged: null, + unstaged: "modified" as const, + renamedFrom: null, +}); + +/** Selects the tree's first file the way a user does, so the tree's own state moves too. */ +function selectFirstFile() { + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); +} + +describe("Commit tool window Preview Diff (#1599)", () => { + beforeEach(() => { + localStorage.clear(); + openDiff.mockClear(); + useCommit.setState({ commitDiffPreview: false, selection: [] }); + useVcs.setState({ available: true, root: "/p", repo: "/p", roots: ["/p"], status: [] }); + }); + + it("offers a Preview Diff toggle in the changes toolbar, off by default", () => { + render(); + const toggle = screen.getByRole("button", { name: "Preview Diff" }); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + }); + + it("shows an embedded diff pane beside the changes when enabled", () => { + useVcs.setState({ status: [modified("/p/a.ts")] }); + render(); + selectFirstFile(); + act(() => setCommitDiffPreview(true)); + // Side by side with the changes: the splitter between the panels is vertical. + expect(previewHandle()).toBeTruthy(); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + expect(screen.getByPlaceholderText("Commit Message")).toBeTruthy(); + }); + + it("previews the selected file and follows the selection", () => { + useVcs.setState({ status: ["a.ts", "b.ts"].map((name) => modified(`/p/${name}`)) }); + render(); + act(() => setCommitDiffPreview(true)); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + selectFirstFile(); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + const tree = screen.getByRole("tree", { name: "Changes" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + fireEvent.keyDown(tree, { key: "ArrowUp" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + }); + + it("keeps the files list and the commit message accessible while the preview is open", () => { + useVcs.setState({ status: [modified("/p/a.ts")] }); + render(); + selectFirstFile(); + act(() => setCommitDiffPreview(true)); + expect(screen.getByTestId("diff-preview")).toBeTruthy(); + expect(screen.getByRole("tree", { name: "Changes" })).toBeTruthy(); + expect(screen.getByPlaceholderText("Commit Message")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Commit" })).toBeTruthy(); + }); + + it("offers a resize handle between the changes and the preview", () => { + useVcs.setState({ status: [modified("/p/a.ts")] }); + render(); + selectFirstFile(); + act(() => setCommitDiffPreview(true)); + const handle = previewHandle(); + expect(handle).toBeTruthy(); + expect(handle!.getAttribute("aria-valuenow")).not.toBeNull(); + // Resizing moves the boundary and keeps both sides rendered. + fireEvent.keyDown(handle!, { key: "ArrowLeft" }); + expect(screen.getByTestId("diff-preview")).toBeTruthy(); + expect(screen.getByRole("tree", { name: "Changes" })).toBeTruthy(); + }); + + it("hides the pane when disabled and persists the toggle state", () => { + useVcs.setState({ status: [modified("/p/a.ts")] }); + render(); + selectFirstFile(); + const toggle = screen.getByRole("button", { name: "Preview Diff" }); + act(() => toggle.click()); + expect(useCommit.getState().commitDiffPreview).toBe(true); + expect(localStorage.getItem("openidea.vcs.commitDiffPreview")).toBe("true"); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + act(() => toggle.click()); + expect(useCommit.getState().commitDiffPreview).toBe(false); + expect(localStorage.getItem("openidea.vcs.commitDiffPreview")).toBe("false"); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + expect(previewHandle()).toBeUndefined(); + // The tree keeps its selection across the toggling. + expect(screen.getByText("a.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + }); + + it("shows an empty state until exactly one file is selected", () => { + useVcs.setState({ status: ["a.ts", "b.ts"].map((name) => modified(`/p/${name}`)) }); + render(); + act(() => setCommitDiffPreview(true)); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + act(() => useCommit.setState({ selection: ["/p/a.ts", "/p/b.ts"] })); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + }); + + it("shows an empty state when there are no changes to preview", () => { + render(); + act(() => setCommitDiffPreview(true)); + expect(screen.getByText("No changes to preview")).toBeTruthy(); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + }); +}); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index 968371ea4..fc5811a9a 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -18,6 +18,8 @@ interface CommitState { groupByStaging: boolean; /** Whether the Git window's Local Changes tab previews the selection's diff. */ diffPreview: boolean; + /** Whether the Commit tool window previews the selection's diff beside the changes (#1599). */ + commitDiffPreview: boolean; /** User overrides of the inclusion checkboxes (absolute path → state). */ checks: Map; /** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */ @@ -48,6 +50,7 @@ export const useCommit = create(() => ({ groupByDir: readBool("openidea.vcs.groupByDir", false), groupByStaging: readBool("openidea.vcs.groupByStaging", false), diffPreview: readBool("openidea.vcs.diffPreview", true), + commitDiffPreview: readBool("openidea.vcs.commitDiffPreview", false), checks: new Map(), excludedHunks: new Map(), selection: [], @@ -95,6 +98,15 @@ export function setDiffPreview(v: boolean) { } } +export function setCommitDiffPreview(v: boolean) { + useCommit.setState({ commitDiffPreview: v }); + try { + localStorage.setItem("openidea.vcs.commitDiffPreview", String(v)); + } catch { + // ignore + } +} + const historyKey = () => `openidea.vcs.commitMessages:${useVcs.getState().root ?? ""}`; const MAX_HISTORY = 20; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 80a9620a4..80a549de1 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1402,6 +1402,7 @@ "No language server available": "没有可用的语言服务器", "No languages": "没有语言", "No local changes": "没有本地更改", + "No changes to preview": "没有可预览的更改", "No matches": "没有匹配项", "No matching branches": "没有匹配的分支", "No matching languages": "没有匹配的语言", @@ -1599,6 +1600,7 @@ "Press a shortcut": "按下快捷键", "Press a shortcut to find actions": "按下快捷键以查找操作", "Preview": "预览", + "Preview Diff": "预览差异", "Preview Replacement": "预览替换", "Preview failed": "预览失败", "Previous Bookmark": "上一个书签",