/** Commit tool window: change list with inclusion checkboxes, message, stashes. */ 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 { Button, Checkbox, EmptyState, Icon, IconButton, List, Menu, SplitGroup, SplitHandle, SplitPanel, Tabs, Tree, fileIconName, registerDropTarget, showContextMenu, startPointerDrag, type MenuItem, type TreeHandle, T, } from "@/ui"; import { editorService } from "@/workbench/contributions"; import { aggregateCheck, buildChangeTree, checkOf, filesOf, isPartiallyStaged, planCommit, toggledCheck, type ChangeNode, } from "./changes"; import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; import { CommitOptionsPopup } from "./CommitOptions"; import { useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; import { messageHistory, setAmend, setGroupByDir, useCommit, type CommitTab } from "./commitStore"; import { openDiff, withChain, workingDiffData } from "./diffInput"; import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model"; import { call, commands } from "@/platform/ipc"; import { OperationBanner } from "./operations"; import { ShelfPanel, loadShelves, useShelf } from "./shelf"; import { loadStashes, refreshNow, 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"; /** "Commit" / "Shelf" / "Stash" content tabs; they stand in for the tool window title. */ export function CommitTabs() { const available = useVcs((s) => s.available); const tab = useCommit((s) => s.tab); const stashCount = useVcs((s) => s.stashes.length); const shelfCount = useShelf((s) => s.shelves.length); useEffect(() => { if (available) void loadShelves().catch(() => undefined); }, [available]); return ( useCommit.setState({ tab: id as CommitTab })} aria-label="Commit" items={[ { id: "changes", label: "Commit" }, ...(!available ? [] : shelfCount || tab === "shelf" ? [ { id: "shelf", label: "Shelf", trailing: shelfCount ? {shelfCount} : undefined, }, ] : []), ...(available ? [ { id: "stashes", label: "Stash", trailing: stashCount ? {stashCount} : undefined, }, ] : []), ]} /> ); } export function CommitToolWindow() { const available = useVcs((s) => s.available); const tab = useCommit((s) => s.tab); if (!available) return ( Open a Git repository to commit changes. ); return (
{tab === "changes" ? : 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 group = roots.find((g) => g.files.some((f) => f.path === n.files[0]?.path)) ?? null; const paths: string[] = []; const walk = (ns: readonly ChangeNode[]) => ns.forEach((x) => (x.type === "file" ? x.path && paths.push(x.path) : walk(x.children ?? []))); walk(group ? [group] : [n]); const items = paths.map((p) => workingDiffData(p)); void openDiff(withChain(items, Math.max(0, paths.indexOf(n.path))), { preview: true }); } function listMenu(id: string, active: boolean, paths: string[]): MenuItem[] { 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" }, ...(count ? [ { kind: "action" as const, action: "vcs.shelve", arg: paths }, { kind: "action" as const, action: "vcs.createPatch", arg: paths }, ] : []), ]; } function nodeMenu(nodes: ChangeNode[]): 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 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) }] : []), { 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} ); } function ChangesPanel() { const status = useVcs((s) => s.status); const root = useVcs((s) => s.root) ?? ""; const groupByDir = useCommit((s) => s.groupByDir); const userChecks = useCommit((s) => s.checks); const lists = useChangelists((s) => s.state); const checks = useMemo(() => withListDefaults(status, root, lists, userChecks), [status, root, lists, userChecks]); 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 roots = useMemo( () => buildChangeTree(status, root, groupByDir, lists, repos), [status, root, groupByDir, lists, repos], ); 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); const plan = useMemo(() => planCommit(status, checks), [status, checks]); return (
void refreshNow()} /> viewRef.current && showContextMenu(bottomLeft(viewRef.current), [ { kind: "header", label: "Group By" }, { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, ]) } /> setCollapsed(new Set())} /> setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key))) } />
{ if (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={(n) => n.label} getChildren={(n) => n.children} 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) { const paths = node.files.map((f) => stripSlash(f.path)); showContextMenu(e, listMenu(node.changelist, !!node.active, paths)); return; } const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); showContextMenu(e, nodeMenu(nodes.length ? nodes : [node])); }} aria-label="Changes" empty={ No local changes } renderNode={(n, st) => { const agg = aggregateCheck(n.files, checks); const label = ( {n.type === "group" && !n.changelist ? tx(n.label) : n.label} ); // 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 ( 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 }; } function CommitMessage({ included }: { included: number }) { const message = useCommit((s) => s.message); const amend = useCommit((s) => s.amend); const focusTick = useCommit((s) => s.focusTick); const remoteBusy = useVcs((s) => s.remoteBusy); const merging = useVcs((s) => s.branch?.operation === "merge"); const ref = useRef(null); const historyRef = useRef(null); const optionsRef = useRef(null); const [historyOpen, setHistoryOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false); const [emptyError, setEmptyError] = useState(false); const [busy, setBusy] = useState(false); const historyTick = useCommit((s) => s.historyTick); const checkProblems = useCommit((s) => s.checkProblems); // 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 commit = 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); ref.current?.focus(); return; } setBusy(true); try { const id = await commitChanges(); if (id !== undefined && push) void executeAction("vcs.push"); } finally { setBusy(false); } }; const history = messageHistory(); const canCommit = (included > 0 || amend || merging) && !busy; 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} )}