Файли
openidea/src/features/vcs/CommitView.tsx
T
Jaw 70f10e741c fix(i18n): Translate the remaining UI text (Git, hierarchy, pull requests, dialogs, aria labels) (#114)
Hard-coded UI text, titles and aria-labels in about 110 components (Git panels, hierarchy, pull requests, dialogs, settings, welcome) now go through tx; spliced fragments became whole sentences (fileCount, noneFoundLabel, hierarchyTitle) and pull request states use keyed messages. 225 zh entries added, stale fragment entries removed.

Also: tx used as a tag without interpolations (tx`No checks`) now looks the text up exactly instead of as a template, so it is translated like tx("No checks"); the text-catalog test checks such tags as plain literals. Check and test counts reuse the existing "{count} passed" / "{failed} failing" entries.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 12:18:46 +08:00

798 рядки
31 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** 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 (
<Tabs
variant="compact"
className={styles.tabs}
activeId={tab}
onSelect={(id) => useCommit.setState({ tab: id as CommitTab })}
aria-label="Commit"
items={[
{ id: "changes", label: "Commit" },
...(!available
? []
: shelfCount || tab === "shelf"
? [
{
id: "shelf",
label: "Shelf",
trailing: shelfCount ? <span className={styles.count}>{shelfCount}</span> : undefined,
},
]
: []),
...(available
? [
{
id: "stashes",
label: "Stash",
trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : undefined,
},
]
: []),
]}
/>
);
}
export function CommitToolWindow() {
const available = useVcs((s) => s.available);
const tab = useCommit((s) => s.tab);
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 /> : tab === "shelf" ? <ShelfPanel /> : <StashesPanel />}
</div>
);
}
// ---------------------------------------------------------------------------
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 (
<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>
);
}
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<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 roots = useMemo(
() => buildChangeTree(status, root, groupByDir, lists, repos),
[status, root, groupByDir, lists, repos],
);
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);
const plan = useMemo(() => planCommit(status, checks), [status, checks]);
return (
<SplitGroup orientation="vertical" className={styles.split}>
<SplitPanel defaultSize="62%" minSize="20%">
<div className={styles.changes}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Changes")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
<IconButton icon="undo" action="vcs.rollback" size="small" />
<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) },
])
}
/>
<span className={styles.sep} />
<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>
<OperationBanner />
<div
className={styles.treeWrap}
onKeyDownCapture={(e) => {
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 });
}
}
}}
>
<Tree<ChangeNode>
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={
<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 ? KIND_LABEL[n.kind] : undefined}
>
{n.type === "group" && !n.changelist ? tx(n.label) : n.label}
</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}
>
<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>
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="38%" minSize="120px">
<CommitMessage included={plan.included.length} />
</SplitPanel>
</SplitGroup>
);
}
/** 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<HTMLTextAreaElement>(null);
const historyRef = useRef<HTMLButtonElement>(null);
const optionsRef = useRef<HTMLButtonElement>(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 (
<div className={styles.commit}>
<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 });
setEmptyError(false);
if (wrapped) requestAnimationFrame(() => el.setSelectionRange(wrapped.caret, wrapped.caret));
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit(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>
)}
<div className={styles.commitActions}>
<Button variant="primary" disabled={!canCommit} onClick={() => void commit(false)} title="⌘↩">
<T>{checkProblems.length ? "Commit Anyway" : amend ? "Amend Commit" : "Commit"}</T>
</Button>
<Button
className={styles.pushButton}
disabled={!canCommit || !!remoteBusy}
onClick={() => void commit(true)}
title="⌥⌘↩"
>
<span>
<T>{amend ? "Amend and Push…" : "Commit and Push…"}</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>
);
}
// ---------------------------------------------------------------------------
function StashesPanel() {
const stashes = useVcs((s) => s.stashes);
const loaded = useVcs((s) => s.stashesLoaded);
const [active, setActive] = useState(0);
useEffect(() => {
void loadStashes().catch(() => undefined);
}, []);
const current = stashes[active];
return (
<div className={styles.changes}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Stashes")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void loadStashes()} />
<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)}
/>
<IconButton
icon="undo"
title="Pop Stash (Apply and Drop)"
size="small"
disabled={!current}
onClick={() => current && void applyStash(current.index, true)}
/>
<IconButton
icon="trash"
title="Drop Stash"
size="small"
disabled={!current}
onClick={() => current && void dropStash(current.index, current.message)}
/>
</div>
<List
items={stashes}
getKey={(s) => String(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}>{loaded ? "No stashes" : "Loading…"}</span>}
onActivate={(s, _i, e) => {
if (e.type === "click" && (e as MouseEvent).detail < 2) return;
void applyStash(s.index, false);
}}
onContextMenu={(s, i, e) => {
e.preventDefault();
setActive(i);
showContextMenu(e, [
{ label: "Apply Stash", icon: "check", run: () => void applyStash(s.index, false) },
{ label: "Pop Stash", icon: "undo", run: () => void applyStash(s.index, true) },
{ label: "Unstash…", run: () => showUnstashDialog(s.index) },
{ kind: "separator" },
{ label: "Drop Stash", icon: "trash", run: () => void dropStash(s.index, s.message) },
]);
}}
renderItem={(s) => {
const { branch, text } = stashLabel(s.message);
return (
<span className={styles.stashRow} title={`stash@{${s.index}}: ${s.message}`}>
<span className={styles.stashMessage}>{text}</span>
{branch && <span className={styles.detail}>{branch}</span>}
<span className={styles.detail}>{formatTime(s.timeMs)}</span>
</span>
);
}}
/>
</div>
);
}