diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index f9019a0a..b809529d 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -32,6 +32,7 @@ import { T, } from "@/ui"; import { editorService } from "@/workbench/contributions"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { aggregateCheck, buildChangeTree, @@ -47,10 +48,11 @@ 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, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; +import { messageHistory, setAmend, setDiffPreview, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; import { pushAfterCommit } from "./commitUi"; import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; -import { openDiff, withChain, workingDiffData } from "./diffInput"; +import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput"; +import { DiffEditor } from "./DiffEditor"; import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model"; import { call, commands } from "@/platform/ipc"; import type { FileStatus } from "@/bindings"; @@ -344,6 +346,37 @@ function ChangesPanel() { ); } +/** + * The Git window's Local Changes tab: a diff preview beside the tree when + * bottom-docked, below it when side-docked (off via View Options › Show Diff Preview). + */ +export function LocalChangesView() { + const preview = useCommit((s) => s.diffPreview); + const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null)); + const anchor = useToolWindowLayout((s) => s.placements.git?.anchor ?? "bottom"); + return ( + + + + + {preview && ( + <> + + + {path ? ( + + ) : ( + + Select a file to preview its changes + + )} + + + )} + + ); +} + /** * The change tree with its toolbar. `commit`: the Commit tool window's (inclusion * checkboxes); `local`: the Git tool window's Local Changes tab of the modal commit @@ -353,6 +386,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 diffPreview = useCommit((s) => s.diffPreview); const lists = useChangelists((s) => s.state); const checks = useCommitChecks(); const checkboxes = mode !== "local"; @@ -438,6 +472,12 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { showContextMenu(bottomLeft(viewRef.current), [ { kind: "header", label: "Group By" }, { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + ...(mode === "local" + ? [ + { kind: "separator" } as const, + { label: "Show Diff Preview", checked: diffPreview, run: () => setDiffPreview(!diffPreview) }, + ] + : []), ]) } /> diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index acf34543..5fc7db47 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -1,5 +1,6 @@ .root { position: relative; + container: diff-editor / inline-size; flex: 1; height: 100%; min-height: 0; @@ -16,19 +17,37 @@ padding: 0 6px; border-bottom: 1px solid var(--border); } +.toolbar > * { + flex-shrink: 0; +} .sep { width: 1px; height: 14px; margin: 0 4px; background: var(--border-strong); } -.count { +.toolbar > .count { + flex-shrink: 1; + min-width: 0; margin-left: auto; padding: 0 6px; + overflow: hidden; + text-overflow: ellipsis; color: var(--fg-subtle); font-size: var(--fs-small); white-space: nowrap; } +/* Narrow (a side-docked preview): the whitespace / highlighting pickers stay in the gear menu. */ +@container diff-editor (max-width: 640px) { + .select { + display: none; + } +} +@container diff-editor (max-width: 380px) { + .toolbar > .sep { + display: none; + } +} .labels { flex: none; display: flex; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index ba4c431f..440a9b03 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -93,7 +93,7 @@ import styles from "./GitLog.module.css"; import { tx } from "@/platform/i18n"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { gitTabs, shownTab } from "./gitTabs"; -import { ChangesTree, StashContent, tabItems, useStashTabCounts } from "./CommitView"; +import { LocalChangesView, StashContent, tabItems, useStashTabCounts } from "./CommitView"; import { WorktreesPanel } from "./gitMenuDialogs"; const ROW = 26; @@ -148,7 +148,7 @@ export function GitToolWindow() { aria-labelledby={`openidea-git-tab-${shown}`} > {shown === "changes" ? ( - + ) : shown === "shelf" || shown === "stashes" ? ( ) : shown === "console" ? ( diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx index e7cdde2d..a42649bc 100644 --- a/src/features/vcs/__tests__/diffFind.test.tsx +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -40,6 +40,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({ })); import { DiffEditor } from "../DiffEditor"; +import type { DiffData } from "../diffInput"; const diff = (right: object) => ({ @@ -74,6 +75,24 @@ const press = async (index: number, code: string, shift = false) => { afterEach(() => useKeymap.setState({ overrides: {} })); describe("Find in the diff viewer", () => { + it("preserves external focus when loading and changing a working-tree preview", async () => { + const preview = (input: EditorInput) => ( + <> + + + + ); + const { rerender } = render(preview(working)); + const tree = screen.getByRole("button", { name: "Changes tree" }); + tree.focus(); + const oldPane = await pane(1); + expect(tree).toHaveFocus(); + rerender(preview({ ...working, key: "diff:b.ts", data: { ...(working.data as DiffData), path: "/p/b.ts" } })); + await waitFor(() => expect(oldPane.dom.isConnected).toBe(false)); + await pane(1); + expect(tree).toHaveFocus(); + }); + it("opens the editor's find bar from the keymap and closes it with Esc", async () => { useKeymap.setState({ overrides: KEYMAP }); render(); diff --git a/src/features/vcs/__tests__/localChanges.test.tsx b/src/features/vcs/__tests__/localChanges.test.tsx new file mode 100644 index 00000000..23b2f8a5 --- /dev/null +++ b/src/features/vcs/__tests__/localChanges.test.tsx @@ -0,0 +1,130 @@ +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 { LocalChangesView } from "../CommitView"; +import { setDiffPreview, useCommit } from "../commitStore"; +import { useVcs } from "../store"; +import { useToolWindowLayout } from "@/workbench/toolwindows/store"; + +describe("Local Changes diff preview (#710)", () => { + beforeEach(() => { + localStorage.clear(); + openDiff.mockClear(); + useCommit.setState({ diffPreview: true, selection: [] }); + useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"], status: [] }); + useToolWindowLayout.setState({ placements: {} }); + }); + + it("places the preview beside the tree for default bottom docking", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it.each(["left", "right"] as const)("stacks the preview for %s docking and updates when moved", (anchor) => { + render(); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal"); + act(() => useToolWindowLayout.setState({ placements: { git: { anchor: "bottom" } } })); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it("previews the single selected file's working-tree diff", () => { + useCommit.setState({ selection: ["/p/src/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/src/a.ts"); + }); + + it("shows a placeholder until exactly one file is selected", () => { + render(); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + useCommit.setState({ selection: ["/p/a.ts", "/p/b.ts"] }); + expect(screen.getByText("Select a file to preview its changes")).toBeTruthy(); + }); + + it("follows the selection", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/a.ts"); + act(() => useCommit.setState({ selection: ["/p/b.ts"] })); + expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/b.ts"); + act(() => useCommit.setState({ selection: [] })); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + }); + + it("previews keyboard selection and opens the full diff on Enter and double click", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts"); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + expect(tree).toHaveFocus(); + expect(openDiff).not.toHaveBeenCalled(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/b.ts" }), { preview: true }); + fireEvent.doubleClick(screen.getByText("a.ts")); + expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/a.ts" }), { preview: true }); + }); + + it("turns off via the View Options toggle, persists, and keeps the tree alone", () => { + useCommit.setState({ selection: ["/p/a.ts"] }); + render(); + act(() => setDiffPreview(false)); + expect(useCommit.getState().diffPreview).toBe(false); + expect(screen.queryByTestId("diff-preview")).toBeNull(); + expect(screen.getByText("No local changes")).toBeTruthy(); + expect(localStorage.getItem("openidea.vcs.diffPreview")).toBe("false"); + }); + + it("keeps the tree selection when the preview is toggled off and on", () => { + useVcs.setState({ + status: ["a.ts", "b.ts"].map((name) => ({ + path: `/p/${name}`, + relPath: name, + staged: null, + unstaged: "modified" as const, + renamedFrom: null, + })), + }); + render(); + const tree = screen.getByRole("tree", { name: "Changes" }); + tree.focus(); + fireEvent.keyDown(tree, { key: "Home" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(false)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + act(() => setDiffPreview(true)); + expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts"); + }); +}); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index e609cec9..b1f78b72 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; + /** Whether the Git window's Local Changes tab previews the selection's diff. */ + diffPreview: 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. */ @@ -42,6 +44,7 @@ export const useCommit = create(() => ({ author: "", runHooks: true, groupByDir: readBool("openidea.vcs.groupByDir", false), + diffPreview: readBool("openidea.vcs.diffPreview", true), checks: new Map(), excludedHunks: new Map(), selection: [], @@ -71,6 +74,15 @@ export function setGroupByDir(v: boolean) { } } +export function setDiffPreview(v: boolean) { + useCommit.setState({ diffPreview: v }); + try { + localStorage.setItem("openidea.vcs.diffPreview", 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 5d0f755f..3a2d099a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1755,6 +1755,7 @@ "Restore Size": "恢复大小", "Restore detected roots": "恢复检测到的根", "Result": "结果", + "Select a file to preview its changes": "选择文件以预览其更改", "Select a problem to see its details": "选择一个问题以查看详情", "Select an item to preview": "选择一项以预览", "Severity": "严重性",