дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Commit view › Group By gains "Staging State". When it is on, Changes splits into Staged Changes and Unstaged Changes; a partially staged file appears in both, and its diff compares HEAD with the Index under Staged and the Index with the working copy under Unstaged. The choice is remembered across sessions. Closes #1067 Co-authored-by: florentvdry <[email protected]>
1036 рядки
39 KiB
TypeScript
1036 рядки
39 KiB
TypeScript
/**
|
||
* 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 ? <span className={styles.count}>{t.count}</span> : 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 (
|
||
<Tabs
|
||
variant="compact"
|
||
className={styles.tabs}
|
||
activeId={stashTabOf(tab, combine)}
|
||
onSelect={(id) => 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 (
|
||
<EmptyState icon="commit" title="Version control is not configured">
|
||
<T>Open a Git repository to commit changes.</T>
|
||
</EmptyState>
|
||
);
|
||
return <div className={styles.root}>{tab === "changes" ? <ChangesPanel /> : <StashContent tab={tab} />}</div>;
|
||
}
|
||
|
||
/** 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 (
|
||
<SplitGroup orientation="vertical" className={styles.split}>
|
||
<SplitPanel defaultSize="50%" minSize="80px">
|
||
<StashesPanel />
|
||
</SplitPanel>
|
||
<SplitHandle />
|
||
<SplitPanel defaultSize="50%" minSize="80px">
|
||
<ShelfPanel />
|
||
</SplitPanel>
|
||
</SplitGroup>
|
||
);
|
||
return tab === "shelf" ? <ShelfPanel /> : <StashesPanel />;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 (
|
||
<span
|
||
role="checkbox"
|
||
aria-checked={indeterminate ? "mixed" : checked}
|
||
aria-label={label}
|
||
className={clsx(styles.check, (checked || indeterminate) && styles.checkOn)}
|
||
onMouseDown={(e) => {
|
||
if (e.button !== 0) return;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
onToggle();
|
||
}}
|
||
onDoubleClick={(e) => e.stopPropagation()}
|
||
>
|
||
{indeterminate ? <Icon name="minus" size={10} /> : checked ? <Icon name="check" size={10} /> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<SplitGroup orientation="vertical" className={styles.split}>
|
||
<SplitPanel defaultSize="62%" minSize="20%">
|
||
<ChangesTree mode="commit" />
|
||
</SplitPanel>
|
||
<SplitHandle />
|
||
<SplitPanel defaultSize="38%" minSize="120px">
|
||
<CommitPanel included={included} />
|
||
</SplitPanel>
|
||
</SplitGroup>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<SplitGroup orientation={anchor === "bottom" ? "horizontal" : "vertical"} className={styles.split}>
|
||
<SplitPanel defaultSize="50%" minSize="20%">
|
||
<ChangesTree mode="local" />
|
||
</SplitPanel>
|
||
{preview && (
|
||
<>
|
||
<SplitHandle />
|
||
<SplitPanel defaultSize="50%" minSize="120px">
|
||
{path ? (
|
||
<DiffEditor input={diffInput(workingDiffData(path))} />
|
||
) : (
|
||
<EmptyState>
|
||
<T>Select a file to preview its changes</T>
|
||
</EmptyState>
|
||
)}
|
||
</SplitPanel>
|
||
</>
|
||
)}
|
||
</SplitGroup>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<string | null>(null);
|
||
// IDEA starts with Unversioned Files collapsed.
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["group:unversioned"]));
|
||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||
const treeRef = useRef<TreeHandle>(null);
|
||
const viewRef = useRef<HTMLButtonElement>(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<string, ChangeNode>();
|
||
const up = new Map<string, string>();
|
||
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<string>();
|
||
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 (
|
||
<div className={styles.changes}>
|
||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Changes")}>
|
||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
|
||
{mode === "local" && <IconButton icon="commit" action="vcs.commit" size="small" />}
|
||
<IconButton icon="undo" action="vcs.rollback" size="small" />
|
||
{mode === "local" && <IconButton icon="changelist" action="vcs.changelist.new" size="small" />}
|
||
{mode !== "dialog" && <IconButton icon="git-pull" action="vcs.update" size="small" />}
|
||
<IconButton
|
||
ref={viewRef}
|
||
icon="eye"
|
||
title="View Options"
|
||
size="small"
|
||
aria-haspopup="menu"
|
||
onClick={() =>
|
||
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) },
|
||
]
|
||
: []),
|
||
])
|
||
}
|
||
/>
|
||
<span className={styles.sep} />
|
||
{mode !== "dialog" && (
|
||
<IconButton icon="locate" title="Select Opened File" size="small" onClick={selectOpened} />
|
||
)}
|
||
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setCollapsed(new Set())} />
|
||
<IconButton
|
||
icon="collapse-all"
|
||
title="Collapse All"
|
||
size="small"
|
||
onClick={() => setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key)))}
|
||
/>
|
||
</div>
|
||
{mode !== "dialog" && <OperationBanner />}
|
||
<div
|
||
className={styles.treeWrap}
|
||
onKeyDownCapture={(e) => {
|
||
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 });
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
<Tree<ChangeNode>
|
||
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={
|
||
<span className={styles.empty}>
|
||
<T>No local changes</T>
|
||
</span>
|
||
}
|
||
renderNode={(n, st) => {
|
||
const agg = aggregateCheck(n.files, checks);
|
||
const label = (
|
||
<span
|
||
className={clsx(
|
||
styles.label,
|
||
n.type === "group" && styles.groupLabel,
|
||
n.type === "group" && n.changelist && !n.active && styles.inactiveList,
|
||
)}
|
||
style={n.type === "file" && n.kind ? { color: KIND_COLOR[n.kind] } : undefined}
|
||
title={n.type === "file" && n.kind ? tx(KIND_LABEL[n.kind]) : undefined}
|
||
>
|
||
{nodeText(n)}
|
||
</span>
|
||
);
|
||
// 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<HTMLSpanElement>) => {
|
||
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 (
|
||
<span
|
||
className={clsx(styles.nodeRow, n.type === "group" && dropTarget === n.changelist && styles.dropTarget)}
|
||
{...dnd}
|
||
>
|
||
{checkboxes && (
|
||
<CheckMark
|
||
checked={agg.checked}
|
||
indeterminate={agg.indeterminate}
|
||
label={tx`Include ${n.label} in commit`}
|
||
onToggle={() => toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])}
|
||
/>
|
||
)}
|
||
{n.type === "group" ? null : (
|
||
<Icon
|
||
name={n.type === "dir" ? (st.expanded ? "folder-open" : "folder") : fileIconName(n.label)}
|
||
size={16}
|
||
className={styles.icon}
|
||
/>
|
||
)}
|
||
{label}
|
||
{n.type === "group" && <span className={styles.detail}>{fileCount(n.files.length)}</span>}
|
||
{n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && (
|
||
<span className={styles.partial} title={tx("Partially staged: only staged hunks are committed")}>
|
||
<T>partial</T>
|
||
</span>
|
||
)}
|
||
{n.detail && <span className={styles.detail}>{n.detail}</span>}
|
||
</span>
|
||
);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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<void>;
|
||
}
|
||
|
||
/**
|
||
* 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<HTMLTextAreaElement>(null);
|
||
const historyRef = useRef<HTMLButtonElement>(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 (
|
||
<>
|
||
<div className={styles.commitBar}>
|
||
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
|
||
<IconButton
|
||
ref={historyRef}
|
||
icon="history"
|
||
title="Commit Message History"
|
||
size="small"
|
||
disabled={!history.length}
|
||
aria-haspopup="menu"
|
||
aria-expanded={historyOpen}
|
||
onClick={() => setHistoryOpen((o) => !o)}
|
||
/>
|
||
<Menu
|
||
open={historyOpen}
|
||
onClose={() => 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();
|
||
},
|
||
}))}
|
||
/>
|
||
<span className={styles.grow} />
|
||
{tooLong && (
|
||
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title={tx("Subject line length")}>
|
||
{first.length}/{cfg.subjectLimit}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<textarea
|
||
ref={ref}
|
||
className={clsx(styles.message, !showMargin && styles.noMargin, emptyError && styles.messageError)}
|
||
aria-invalid={emptyError || undefined}
|
||
style={{ ["--guide" as string]: `${margin}ch` }}
|
||
value={message}
|
||
placeholder={tx("Commit Message")}
|
||
aria-label={tx("Commit message")}
|
||
spellCheck
|
||
onChange={(e) => {
|
||
const el = e.target;
|
||
const wrapped =
|
||
cfg.limitBody && cfg.wrapAtMargin ? wrapAtMargin(el.value, el.selectionStart, cfg.bodyLimit) : null;
|
||
useCommit.setState({ message: wrapped?.text ?? el.value });
|
||
runner.clearEmptyError();
|
||
if (wrapped) requestAnimationFrame(() => el.setSelectionRange(wrapped.caret, wrapped.caret));
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
void runner.run(e.altKey);
|
||
} else if (e.key === "Escape") {
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
/>
|
||
{emptyError && (
|
||
<div className={clsx(styles.inspections, styles.inspectionError)} role="alert">
|
||
<T>Specify commit message</T>
|
||
</div>
|
||
)}
|
||
{problems.length > 0 && (
|
||
<div className={styles.inspections} role="status">
|
||
{problems.join(" · ")}
|
||
</div>
|
||
)}
|
||
{checkProblems.length > 0 && (
|
||
<div className={styles.checkProblems} role="alert">
|
||
<Icon name="warning" size={14} className={styles.checkProblemsIcon} />
|
||
<div className={styles.checkProblemsList}>
|
||
{checkProblems.map((p, i) => (
|
||
<div key={i}>
|
||
<span className={styles.checkProblemTitle}>{tx(p.title)}</span>
|
||
{typeof p.text === "string" ? `: ${tx(p.text)}` : p.text}
|
||
{p.details && (
|
||
<>
|
||
{" "}
|
||
<button type="button" className={styles.checkProblemLink} onClick={p.details.run}>
|
||
{tx(p.details.label)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Label of the Commit button. */
|
||
export function commitLabel(amend: boolean, problems: boolean): string {
|
||
return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit";
|
||
}
|
||
|
||
/** Label of the Commit and Push button. */
|
||
export const commitAndPushLabel = (amend: boolean) => (amend ? "Amend and Push…" : "Commit and Push…");
|
||
|
||
/** The message and the Commit buttons of the Commit tool window. */
|
||
function CommitPanel({ included }: { included: number }) {
|
||
const amend = useCommit((s) => s.amend);
|
||
const remoteBusy = useVcs((s) => s.remoteBusy);
|
||
const checkProblems = useCommit((s) => s.checkProblems);
|
||
const optionsRef = useRef<HTMLButtonElement>(null);
|
||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||
const runner = useCommitRunner(included);
|
||
const { canCommit } = runner;
|
||
|
||
return (
|
||
<div className={styles.commit}>
|
||
<CommitMessageField included={included} runner={runner} />
|
||
<div className={styles.commitActions}>
|
||
<Button variant="primary" disabled={!canCommit} onClick={() => void runner.run(false)} title="⌘↩">
|
||
<T>{commitLabel(amend, checkProblems.length > 0)}</T>
|
||
</Button>
|
||
<Button
|
||
className={styles.pushButton}
|
||
disabled={!canCommit || !!remoteBusy}
|
||
onClick={() => void runner.run(true)}
|
||
title="⌥⌘↩"
|
||
>
|
||
<span>
|
||
<T>{commitAndPushLabel(amend)}</T>
|
||
</span>
|
||
</Button>
|
||
<span className={styles.grow} />
|
||
<IconButton
|
||
ref={optionsRef}
|
||
icon="settings"
|
||
title="Commit Options"
|
||
size="small"
|
||
aria-haspopup="dialog"
|
||
aria-expanded={optionsOpen}
|
||
onClick={() => setOptionsOpen((o) => !o)}
|
||
/>
|
||
{optionsOpen && <CommitOptionsPopup anchor={optionsRef} onClose={() => setOptionsOpen(false)} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function StashesPanel() {
|
||
const repo = useVcs((s) => s.repo);
|
||
const repos = useVcs((s) => s.roots);
|
||
const version = useVcs((s) => s.version);
|
||
const [tick, setTick] = useState(0);
|
||
const [active, setActive] = useState(0);
|
||
const request = useMemo(() => ({ repos, repo, version, tick }), [repos, repo, version, tick]);
|
||
const [result, setResult] = useState<{
|
||
request: typeof request;
|
||
stashes: (import("@/bindings").Stash & { repo: string | null })[];
|
||
error: string | null;
|
||
} | null>(null);
|
||
const loaded = result?.request === request;
|
||
const stashes = loaded ? result.stashes : [];
|
||
const error = loaded ? result.error : null;
|
||
useEffect(() => {
|
||
let live = true;
|
||
void Promise.all(
|
||
(request.repos.length ? request.repos : [request.repo]).map(async (root) =>
|
||
(await call(commands.vcsStashes(root))).map((s) => ({ ...s, repo: root })),
|
||
),
|
||
)
|
||
.then((lists) => {
|
||
if (live) setResult({ request, stashes: lists.flat(), error: null });
|
||
})
|
||
.catch((e) => {
|
||
if (live) setResult({ request, stashes: [], error: String(e) });
|
||
});
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [request]);
|
||
const current = stashes[active];
|
||
return (
|
||
<div className={styles.changes}>
|
||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Stashes")}>
|
||
{repos.length > 1 && (
|
||
<Select
|
||
aria-label="Repository"
|
||
value={repo}
|
||
onChange={(root) => void selectRepo(root)}
|
||
options={repos.map((root) => ({ value: root, label: repoName(root) }))}
|
||
/>
|
||
)}
|
||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => setTick((n) => n + 1)} />
|
||
<IconButton icon="plus" title="Stash Changes…" size="small" onClick={() => void stashChanges()} />
|
||
<span className={styles.sep} />
|
||
<IconButton
|
||
icon="check"
|
||
title="Apply Stash"
|
||
size="small"
|
||
disabled={!current}
|
||
onClick={() => current && void applyStash(current.index, false, current.repo)}
|
||
/>
|
||
<IconButton
|
||
icon="undo"
|
||
title="Pop Stash (Apply and Drop)"
|
||
size="small"
|
||
disabled={!current}
|
||
onClick={() => current && void applyStash(current.index, true, current.repo)}
|
||
/>
|
||
<IconButton
|
||
icon="trash"
|
||
title="Drop Stash"
|
||
size="small"
|
||
disabled={!current}
|
||
onClick={() => current && void dropStash(current.index, current.message, current.repo)}
|
||
/>
|
||
</div>
|
||
<List
|
||
items={stashes}
|
||
getKey={(s) => `${s.repo}:${s.index}`}
|
||
activeIndex={Math.min(active, Math.max(0, stashes.length - 1))}
|
||
onActiveChange={setActive}
|
||
rowHeight={22}
|
||
aria-label="Stashes"
|
||
className={styles.stashList}
|
||
empty={<span className={styles.empty}>{error ?? (loaded ? tx("No stashes") : tx("Loading…"))}</span>}
|
||
onActivate={(s, _i, e) => {
|
||
if (e.type === "click" && (e as MouseEvent).detail < 2) return;
|
||
void applyStash(s.index, false, s.repo);
|
||
}}
|
||
onContextMenu={(s, i, e) => {
|
||
e.preventDefault();
|
||
setActive(i);
|
||
showContextMenu(e, [
|
||
{ label: "Apply Stash", icon: "check", run: () => void applyStash(s.index, false, s.repo) },
|
||
{ label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true, s.repo) },
|
||
{ label: "Unstash…", run: () => showUnstashDialog(s.index, s.repo) },
|
||
{ kind: "separator" },
|
||
{ label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message, s.repo) },
|
||
]);
|
||
}}
|
||
renderItem={(s) => {
|
||
const { branch, text } = stashLabel(s.message);
|
||
return (
|
||
<span className={styles.stashRow} title={`stash@{${s.index}}: ${s.message}`}>
|
||
<span className={styles.stashMessage}>
|
||
{repos.length > 1 && s.repo ? `${repoName(s.repo)} · ` : ""}
|
||
{text}
|
||
</span>
|
||
{branch && <span className={styles.detail}>{branch}</span>}
|
||
<span className={styles.detail}>{formatTime(s.timeMs)}</span>
|
||
</span>
|
||
);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|