void refreshNow()} />
{mode === "local" && }
{mode !== "dialog" && }
viewRef.current &&
showContextMenu(bottomLeft(viewRef.current), [
{ kind: "header", label: "Group By" },
{ label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) },
])
}
/>
{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}
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 = (
{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