Files
openidea/src/features/vcs/commitStore.ts
T
arookieofcanda-rookie-of-C-language 00e717d8fd feat(vcs): diff preview in the Git window's Local Changes tab (#714)
The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab.

It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling).

The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates.

Fixes #710.

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
2026-11-03 10:18:39 +08:00

230 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Commit tool window state shared with actions (selection, message draft). */
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import type { CheckState } from "./changes";
import type { CommitProblem } from "./commitChecks";
import { currentRepo, useVcs } from "./store";
interface CommitState {
message: string;
amend: boolean;
signOff: boolean;
/** Commit Options › Author (`Name <mail>`); blank uses the configured user. */
author: string;
/** 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<string, CheckState>;
/** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */
excludedHunks: Map<string, Set<string>>;
/** Absolute paths selected in the changes tree. */
selection: string[];
/** Bumped to focus the message field. */
focusTick: number;
/** Bumped to open the commit message history popup. */
historyTick: number;
/** Failed before-commit checks; while any are shown, Commit means "Commit Anyway". */
checkProblems: CommitProblem[];
tab: CommitTab;
/** Change list of the selected tree node (list actions without an argument). */
selectedList: string | null;
/** Selected shelf tree key: shelf id, optionally followed by NUL and a file path. */
selectedShelf: string | null;
}
export type CommitTab = "changes" | "shelf" | "stashes";
export const useCommit = create<CommitState>(() => ({
message: "",
amend: false,
signOff: false,
author: "",
runHooks: true,
groupByDir: readBool("openidea.vcs.groupByDir", false),
diffPreview: readBool("openidea.vcs.diffPreview", true),
checks: new Map(),
excludedHunks: new Map(),
selection: [],
focusTick: 0,
historyTick: 0,
checkProblems: [],
tab: "changes",
selectedList: null,
selectedShelf: null,
}));
function readBool(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v === "true";
} catch {
return fallback;
}
}
export function setGroupByDir(v: boolean) {
useCommit.setState({ groupByDir: v });
try {
localStorage.setItem("openidea.vcs.groupByDir", String(v));
} catch {
// ignore
}
}
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;
export function messageHistory(): string[] {
try {
const raw = localStorage.getItem(historyKey());
const v: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
export function rememberMessage(message: string) {
const m = message.trim();
if (!m) return;
try {
const list = [m, ...messageHistory().filter((x) => x !== m)].slice(0, MAX_HISTORY);
localStorage.setItem(historyKey(), JSON.stringify(list));
} catch {
// ignore
}
}
/** The message typed before Amend replaced it, and the amended commit's message. */
let amendData: { before: string; amend: string } | null = null;
/**
* Amend checkbox, as in IDEA: checking it replaces the message
* with the last commit's (the draft is kept in the message history and restored when
* Amend is unchecked again, unless the amend message was edited meanwhile).
*/
export async function setAmend(on: boolean) {
useCommit.setState({ amend: on });
if (!on) {
const data = amendData;
amendData = null;
if (data && useCommit.getState().message === data.amend) useCommit.setState({ message: data.before });
return;
}
let last: string | undefined;
try {
const [head] = await call(
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: null,
skip: 0,
limit: 1,
}),
);
last = head?.message.replace(/\s+$/, "");
} catch {
// No commits yet.
}
const before = useCommit.getState().message;
if (!last || !useCommit.getState().amend || before.trim() === last.trim()) return;
rememberMessage(before);
amendData = { before, amend: last };
useCommit.setState((s) => ({ message: last, focusTick: s.focusTick + 1 }));
}
/** After a commit: nothing to restore. */
export function forgetAmendDraft() {
amendData = null;
}
/** The message a commit left in the field (not typed since). */
let keptMessage: string | null = null;
/**
* The message field after a commit, as IDEA's: the committed message stays for the
* next commit unless Settings › Version Control › Commit › "Clear initial commit
* message" is on.
*/
export function messageAfterCommit(committed: string, clear: boolean): string {
return clear ? "" : committed.trim();
}
export function keepMessageAfterCommit(committed: string, clear: boolean) {
const message = messageAfterCommit(committed, clear);
keptMessage = message || null;
useCommit.setState({ message });
}
/** The field holds nothing the user typed: empty, or what the last commit left there. */
function messageReplaceable(): boolean {
const m = useCommit.getState().message.trim();
return !m || m === keptMessage;
}
/** Operations whose commit message git prepares in `.git/MERGE_MSG`. */
const PREPARED_MESSAGE_OPS = new Set(["merge", "cherry-pick", "revert"]);
/** `MERGE_MSG` without git's `#` comment lines (the conflict list etc.). */
export function preparedMessageText(raw: string): string {
return raw
.split("\n")
.filter((l) => !l.startsWith("#"))
.join("\n")
.trim();
}
/**
* Like IDEA, pre-fills the commit message (unless something was typed) with the
* message git prepared for a merge / cherry-pick / revert that stopped (e.g. on conflicts).
*/
export function installPreparedMessage(): () => void {
const load = async () => {
const root = useVcs.getState().root;
if (!root || !messageReplaceable()) return;
try {
const text = preparedMessageText(await call(commands.fsReadText(`${root}/.git/MERGE_MSG`)));
if (text && messageReplaceable()) useCommit.setState({ message: text });
} catch {
// No prepared message (or `.git` is a worktree file): leave the field empty.
}
};
const op = (s = useVcs.getState()) => s.branch?.operation ?? null;
if (PREPARED_MESSAGE_OPS.has(op() ?? "")) void load();
return useVcs.subscribe((s, prev) => {
if (s.root !== prev.root) {
// Another repository: nothing typed or ticked for the previous one carries over (Amend least of all).
amendData = null;
keptMessage = null;
useCommit.setState({
message: "",
amend: false,
checks: new Map(),
excludedHunks: new Map(),
selection: [],
tab: "changes",
selectedList: null,
selectedShelf: null,
});
if (PREPARED_MESSAGE_OPS.has(op(s) ?? "")) void load();
return;
}
const now = op(s);
if (now !== op(prev) && PREPARED_MESSAGE_OPS.has(now ?? "")) void load();
});
}