/** * Commit tool window: change list with inclusion checkboxes, message, stashes. Its * parts also make the Git window's Local Changes / Stash tabs and the Commit dialog. */ import { useWorkflowSettings } from "@/platform/workflowSettings"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react"; import { tx } from "@/platform/i18n"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; import { dirname } from "@/platform/project"; import { isTestFile, useEffectiveRoots } from "@/platform/projectRoots"; import { Button, Checkbox, EmptyState, Icon, IconButton, List, Menu, Select, SplitGroup, SplitHandle, SplitPanel, Tabs, Tree, fileIconName, registerDropTarget, showContextMenu, startPointerDrag, type MenuItem, type TabItem, type TreeHandle, T, } from "@/ui"; import { editorService } from "@/workbench/contributions"; import { useToolWindowLayout } from "@/workbench/toolwindows/store"; import { aggregateCheck, buildChangeTree, checkOf, filesOf, groupFileNodes, ignoreTargets, isPartiallyStaged, planCommit, stagingSide, toggledCheck, type ChangeNode, } from "./changes"; import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; 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"; 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"; import { OperationBanner } from "./operations"; import { ShelfPanel, loadShelves, useShelf } from "./shelf"; import { refreshNow, repoName, selectRepo, useVcs } from "./store"; import styles from "./CommitView.module.css"; /** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */ const CHANGES_MIME = "application/x-openidea-changes"; /** Shown text of a node: group names are translated, except the names of user change lists. */ function nodeText(n: ChangeNode): string { return n.type === "group" && (!n.changelist || n.label === DEFAULT_LIST_NAME) ? tx(n.label) : n.label; } /** Content tabs as `Tabs` items, with their counts. */ export function tabItems(tabs: readonly ContentTab[]): TabItem[] { return tabs.map((t) => ({ id: t.id, label: t.label, // `Tabs` treats a missing flag as closable once it has `onClose`. closable: !!t.closable, trailing: t.count ? {t.count} : undefined, })); } /** Shelf and stash counts for the tabs (loads the shelves once VCS is available). */ export function useStashTabCounts(available: boolean) { const roots = useVcs((s) => s.roots); const version = useVcs((s) => s.version); const [stashCount, setStashCount] = useState(0); useEffect(() => { let live = true; if (available) void Promise.all(roots.map((r) => call(commands.vcsStashes(r)))) .then((lists) => live && setStashCount(lists.reduce((n, list) => n + list.length, 0))) .catch(() => undefined); return () => { live = false; }; }, [available, roots, version]); const shelfCount = useShelf((s) => s.shelves.length); useEffect(() => { if (available) void loadShelves().catch(() => undefined); }, [available]); return { stashCount: available ? stashCount : 0, shelfCount }; } /** "Commit" / "Shelf" / "Stash" content tabs; they stand in for the tool window title. */ export function CommitTabs() { const available = useVcs((s) => s.available); const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf); const tab = useCommit((s) => s.tab); const counts = useStashTabCounts(available); return ( useCommit.setState({ tab: id as CommitTab })} aria-label="Commit" items={tabItems([ { id: "changes", label: "Commit" }, ...(available ? stashTabs({ combine, tab, ...counts }) : []), ])} /> ); } export function CommitToolWindow() { const available = useVcs((s) => s.available); const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf); const tab = stashTabOf( useCommit((s) => s.tab), combine, ); if (!available) return ( Open a Git repository to commit changes. ); return
{tab === "changes" ? : }
; } /** The Shelf, Stash or combined "Stashes and Shelves" tab. */ export function StashContent({ tab }: { tab: CommitTab }) { const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf); if (combine) return ( ); return tab === "shelf" ? : ; } // --------------------------------------------------------------------------- const bottomLeft = (el: HTMLElement) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.bottom + 2 }; }; /** 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 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. */ export function listMenu(id: string, active: boolean, files: readonly FileStatus[]): MenuItem[] { const paths = files.map((f) => stripSlash(f.path)); const count = paths.length; return [ ...(count ? [{ kind: "action" as const, action: "vcs.changelist.commit", arg: id, label: "Commit…" }] : []), { kind: "separator" }, { kind: "action", action: "vcs.changelist.new" }, { kind: "action", action: "vcs.changelist.edit", arg: id }, ...(active ? [] : [ { kind: "action" as const, action: "vcs.changelist.setActive", arg: id }, { kind: "action" as const, action: "vcs.changelist.delete", arg: id }, ]), { kind: "separator" }, // The whole list's files, as IDEA offers on a change list's header and as file // and directory nodes below it already offer (issue #473). { kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…", disabled: !count }, { label: "Stage", icon: "plus", disabled: !files.some((f) => f.unstaged), run: () => void call(commands.vcsStage(paths)).finally(() => void refreshNow()), }, { label: "Unstage", icon: "minus", disabled: !files.some((f) => f.staged), run: () => void call(commands.vcsUnstage(paths)).finally(() => void refreshNow()), }, { kind: "separator" }, ...(count ? [ { kind: "action" as const, action: "vcs.shelve", arg: paths }, { kind: "action" as const, action: "vcs.createPatch", arg: paths }, ] : []), ]; } function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { const files = filesOf(nodes); const paths = files.map((f) => f.path); const first = nodes.find((n) => n.type === "file"); const untracked = files.filter((f) => f.unstaged === "untracked"); const toIgnore = ignoreTargets(nodes, repos); const conflicted = files.filter((f) => f.unstaged === "conflicted"); const items: MenuItem[] = [ { kind: "action", action: "vcs.showDiff", arg: first?.path }, { label: "Jump to Source", icon: "external-link", shortcut: "F4", disabled: !first?.path || first.kind === "deleted", run: () => first?.path && void editorService().openFile(first.path, { focus: true }), }, { kind: "separator" }, ]; if (conflicted.length) items.push({ kind: "action", action: "vcs.resolveConflicts", label: "Resolve Conflicts…" }); items.push( { kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…" }, ...(untracked.length ? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }] : []), ...(toIgnore.length ? [ { kind: "action" as const, action: "vcs.ignoreShared", arg: toIgnore, label: "Add to .gitignore", }, ] : []), { label: "Stage", icon: "plus", disabled: !files.some((f) => f.unstaged), run: () => void call(commands.vcsStage(paths)).finally(() => void refreshNow()), }, { label: "Unstage", icon: "minus", disabled: !files.some((f) => f.staged), run: () => void call(commands.vcsUnstage(paths)).finally(() => void refreshNow()), }, { kind: "separator" }, ...(nodes.some((n) => n.changelist) ? [{ kind: "action" as const, action: "vcs.changelist.move", arg: paths, label: "Move to Another Changelist…" }] : []), { kind: "action", action: "vcs.shelve", arg: paths }, { kind: "action", action: "vcs.createPatch", arg: paths }, { kind: "separator" }, { label: "Show History", icon: "history", disabled: !first?.path, run: () => void executeAction("vcs.showLog", { path: first?.path }), }, { kind: "action", action: "vcs.localHistory", arg: first?.path }, { kind: "action", action: "vcs.annotate", arg: first?.path }, { kind: "separator" }, { label: "Copy Path", icon: "copy", run: () => void navigator.clipboard?.writeText(paths.join("\n")) }, ); return items; } function CheckMark({ checked, indeterminate, label, onToggle, }: { checked: boolean; indeterminate: boolean; label: string; onToggle: () => void; }) { return ( { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); onToggle(); }} onDoubleClick={(e) => e.stopPropagation()} > {indeterminate ? : checked ? : null} ); } /** Inclusion state of every change (user overrides over the change list defaults). */ function useCommitChecks() { const status = useVcs((s) => s.status); const root = useVcs((s) => s.root) ?? ""; const userChecks = useCommit((s) => s.checks); const lists = useChangelists((s) => s.state); return useMemo(() => withListDefaults(status, root, lists, userChecks), [status, root, lists, userChecks]); } /** Number of changes the commit would include. */ export function useIncludedCount(): number { const status = useVcs((s) => s.status); const checks = useCommitChecks(); return useMemo(() => planCommit(status, checks).included.length, [status, checks]); } /** The Commit tool window's Commit tab: changes with inclusion checkboxes above the message. */ function ChangesPanel() { const included = useIncludedCount(); return ( ); } /** * 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 * interface (no checkboxes, a Commit… button); `dialog`: the Commit dialog's. */ 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(); const checkboxes = mode !== "local"; const [dropTarget, setDropTarget] = useState(null); // IDEA starts with Unversioned Files collapsed. const [collapsed, setCollapsed] = useState>(() => new Set(["group:unversioned"])); const [selected, setSelected] = useState>(() => new Set()); const treeRef = useRef(null); const viewRef = useRef(null); const repos = useVcs((s) => s.roots); const projectRoots = useEffectiveRoots(); const roots = useMemo( () => buildChangeTree(status, root, groupByDir, lists, repos, groupByStaging), [status, root, groupByDir, lists, repos, groupByStaging], ); const [byKey, parents] = useMemo(() => { const m = new Map(); const up = new Map(); const walk = (ns: ChangeNode[], parent?: string) => ns.forEach((n) => { m.set(n.key, n); if (parent) up.set(n.key, parent); if (n.children) walk(n.children, n.key); }); walk(roots); return [m, up] as const; }, [roots]); /** Select Opened File: reveals the focused editor's file in the tree. */ const selectOpened = () => { const path = editorService().activePath(); const node = path ? [...byKey.values()].find((n) => n.type === "file" && n.path === path) : undefined; if (!node) return; const ancestors: string[] = []; for (let k = parents.get(node.key); k; k = parents.get(k)) ancestors.push(k); setCollapsed((c) => new Set([...c].filter((k) => !ancestors.includes(k)))); requestAnimationFrame(() => treeRef.current?.reveal(node.key)); }; const expanded = useMemo(() => { const s = new Set(); for (const k of byKey.keys()) if (!collapsed.has(k)) s.add(k); return s; }, [byKey, collapsed]); // Forget overrides for files that are no longer changed. useEffect(() => { const live = new Set(status.map((f) => f.path)); const cur = useCommit.getState().checks; if ([...cur.keys()].some((k) => !live.has(k))) { useCommit.setState({ checks: new Map([...cur].filter(([k]) => live.has(k))) }); } }, [status]); const toggle = useCallback((nodes: ChangeNode[]) => { const files = filesOf(nodes); const { status: st, root: r } = useVcs.getState(); const cur = withListDefaults(st, r ?? "", useChangelists.getState().state, useCommit.getState().checks); const agg = aggregateCheck(files, cur); const target = agg.on === files.length && !agg.indeterminate ? "none" : "all"; const next = new Map(cur); for (const f of files) next.set(f.path, files.length === 1 ? toggledCheck(checkOf(f, cur)) : target); useCommit.setState({ checks: next }); }, []); const selectedNodes = () => [...selected].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); return (
void refreshNow()} /> {mode === "local" && } {mode === "local" && } {mode !== "dialog" && } viewRef.current && 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, { label: "Show Diff Preview", checked: diffPreview, run: () => setDiffPreview(!diffPreview) }, ] : []), ]) } /> {mode !== "dialog" && ( )} setCollapsed(new Set())} /> setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key)))} />
{mode !== "dialog" && }
{ if (checkboxes && e.key === " " && !e.metaKey && !e.ctrlKey && selected.size) { e.preventDefault(); e.stopPropagation(); toggle(selectedNodes()); } else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) { const group = selectedNodes().find((x) => x.type === "group" && x.changelist); if (group?.changelist) { e.preventDefault(); e.stopPropagation(); void executeAction("vcs.changelist.delete", group.changelist); } } else if (e.key === "F4") { const n = selectedNodes().find((x) => x.type === "file"); if (n?.path) { e.preventDefault(); void editorService().openFile(n.path, { focus: true }); } } }} > handleRef={treeRef} roots={roots} getKey={(n) => n.key} getLabel={nodeText} getDirectory={(n) => (n.path && n.type !== "group" ? (n.type === "dir" ? n.path : dirname(n.path)) : null)} getChildren={(n) => n.children} rowClassName={(n) => n.type !== "group" && n.path && isTestFile(n.path, projectRoots) ? styles.testFile : undefined } isExpandable={(n) => n.type !== "file"} expanded={expanded} onExpandedChange={(_next, node, open) => setCollapsed((c) => { const s = new Set(c); if (open) s.delete(node.key); else s.add(node.key); return s; }) } selected={selected} onSelectionChange={(sel) => { setSelected(sel); const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); useCommit.setState({ selection: filesOf(nodes).map((f) => f.path), selectedList: nodes.find((n) => n.changelist)?.changelist ?? null, }); }} onActivate={(n) => openNode(n, roots)} onContextMenu={(node, sel, e: MouseEvent) => { if (!node) return; if (node.type === "group" && node.changelist) { showContextMenu(e, listMenu(node.changelist, !!node.active, node.files)); return; } const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); showContextMenu(e, nodeMenu(nodes.length ? nodes : [node], repos)); }} aria-label="Changes" empty={ No local changes } renderNode={(n, st) => { const agg = aggregateCheck(n.files, checks); const label = ( {nodeText(n)} ); // Pointer-driven (HTML5 drag and drop never reaches the Tauri webview): // files and folders drag; any row takes them into its changelist. const list = n.changelist; const dnd = list === undefined ? {} : { ref: (el: HTMLSpanElement | null) => el ? registerDropTarget(el, { accepts: (type) => type === CHANGES_MIME, over: () => setDropTarget(list), leave: () => setDropTarget(null), drop: (data) => { setDropTarget(null); moveToChangelist(data.payload.split("\n"), list); }, }) : undefined, ...(n.type !== "group" && { onPointerDown: (e: PointerEvent) => { const paths = selected.has(n.key) && selected.size > 1 ? filesOf(selectedNodes()).map((f) => f.path) : n.files.map((f) => f.path); startPointerDrag(e, e.currentTarget, { type: CHANGES_MIME, payload: paths.map(stripSlash).join("\n"), }); }, }), }; return ( {checkboxes && ( toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])} /> )} {n.type === "group" ? null : ( )} {label} {n.type === "group" && {fileCount(n.files.length)}} {n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && ( partial )} {n.detail && {n.detail}} ); }} />
); } /** Commit message inspections (Settings › Version Control › Commit). */ export function messageProblems( message: string, c: { blankLineAfterSubject: boolean; limitSubject: boolean; subjectLimit: number; limitBody: boolean; bodyLimit: number; }, ): string[] { const lines = message.split("\n"); const out: string[] = []; if (c.limitSubject && (lines[0]?.length ?? 0) > c.subjectLimit) out.push(`Subject line is longer than ${c.subjectLimit} characters`); if (c.blankLineAfterSubject && lines.length > 1 && lines[1]!.trim() !== "") out.push("Missing blank line between subject and body"); if (c.limitBody) { const long = lines.slice(1).findIndex((l) => l.length > c.bodyLimit); if (long >= 0) out.push(`Body line ${long + 2} is longer than ${c.bodyLimit} characters`); } return out; } /** "Wrap when typing reaches right margin": breaks the caret line at the last space before `limit`. */ export function wrapAtMargin(text: string, caret: number, limit: number): { text: string; caret: number } | null { const start = text.lastIndexOf("\n", caret - 1) + 1; const end = text.indexOf("\n", caret); const line = text.slice(start, end < 0 ? text.length : end); if (start === 0 || line.length <= limit) return null; // the subject line is never wrapped const cut = line.lastIndexOf(" ", limit); if (cut <= 0) return null; const at = start + cut; return { text: `${text.slice(0, at)}\n${text.slice(at + 1)}`, caret }; } export interface CommitRunner { busy: boolean; /** Commit was asked for without a message: reported at the field. */ emptyError: boolean; clearEmptyError: () => void; /** Whether the Commit buttons are enabled. */ canCommit: boolean; run: (push: boolean) => Promise; } /** * Commit / Commit and Push… of the Commit tool window and the Commit dialog: the * shared pipeline (`commitChanges`), then the push. `onCommitted` runs after a commit. */ export function useCommitRunner(included: number, onCommitted?: () => void): CommitRunner { const amend = useCommit((s) => s.amend); const merging = useVcs((s) => s.branch?.operation === "merge"); const [emptyError, setEmptyError] = useState(false); const [busy, setBusy] = useState(false); const run = async (push: boolean) => { if (busy) return; // IDEA keeps Commit enabled and reports an empty message at the field. if (!useCommit.getState().message.trim() && !useCommit.getState().amend) { setEmptyError(true); useCommit.setState((s) => ({ focusTick: s.focusTick + 1 })); return; } setBusy(true); try { const id = await commitChanges(); if (id === undefined) return; onCommitted?.(); if (push) void pushAfterCommit(); } finally { setBusy(false); } }; return { busy, emptyError, clearEmptyError: () => setEmptyError(false), canCommit: (included > 0 || amend || merging) && !busy, run, }; } /** Amend, message history and the message field with its inspections and failed checks. */ export function CommitMessageField({ included, runner }: { included: number; runner: CommitRunner }) { const message = useCommit((s) => s.message); const amend = useCommit((s) => s.amend); const focusTick = useCommit((s) => s.focusTick); const ref = useRef(null); const historyRef = useRef(null); const [historyOpen, setHistoryOpen] = useState(false); const historyTick = useCommit((s) => s.historyTick); const checkProblems = useCommit((s) => s.checkProblems); const { emptyError } = runner; // The failed checks concern what was about to be committed: editing it dismisses them. useEffect(() => { if (useCommit.getState().checkProblems.length) useCommit.setState({ checkProblems: [] }); }, [message, amend, included]); useEffect(() => { if (focusTick) ref.current?.focus(); }, [focusTick]); // Show Commit Message History (⌃M) asks through the store. const [seenHistoryTick, setSeenHistoryTick] = useState(historyTick); if (historyTick !== seenHistoryTick) { setSeenHistoryTick(historyTick); if (messageHistory().length) setHistoryOpen(true); } const cfg = useWorkflowSettings((s) => s.vcs.commit); const first = message.split("\n")[0] ?? ""; const tooLong = cfg.limitSubject && first.length > cfg.subjectLimit; const problems = messageProblems(message, cfg).filter((p) => !p.startsWith("Subject line")); const margin = cfg.limitBody ? cfg.bodyLimit : cfg.subjectLimit; const showMargin = cfg.showRightMargin && (cfg.limitBody || cfg.limitSubject); const history = messageHistory(); return ( <>
void setAmend(v)} label="Amend" /> setHistoryOpen((o) => !o)} /> setHistoryOpen(false)} anchor={historyRef} placement="bottom-end" aria-label="Recent commit messages" items={history.map((m, i) => ({ id: `h${i}`, label: m.split("\n")[0]!.slice(0, 80), run: () => { useCommit.setState({ message: m }); ref.current?.focus(); }, }))} /> {tooLong && ( {first.length}/{cfg.subjectLimit} )}