feat(vcs): resizable Push dialog with collapsible repository groups (#407)

The Push dialog had a fixed size and listed every repository's outgoing commits flat. Dialog gains a resizable option (corner grip, clamped to the viewport, numeric width as the minimum), both Push dialogs use it with the commit list filling the body, and the multi-repository Push dialog shows each repository as a collapsible group (clicking the row still toggles its checkbox).

Fixes #397

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 19:37:56 +08:00
1 джерело 552a43a0a1
коміт f87d153caf
5 files changed
+146 -31

No files matched your search

+15
Переглянути файл
@@ -110,6 +110,21 @@
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
/* Push dialogs: the list fills the body so a resizable window shows more rows. */
.pushTreeWrap {
flex: 1;
min-height: 220px;
display: flex;
flex-direction: column;
padding: 14px;
}
.pushListWrap {
flex: 1;
min-height: 180px;
display: flex;
flex-direction: column;
padding: 0 14px 14px;
}
.cloneDirRow {
display: flex;
align-items: center;
+55 -30
Переглянути файл
@@ -5,7 +5,7 @@ import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications } from "@/workbench/contributions";
import { Checkbox, Dialog, Input, List, Select, Spinner, T } from "@/ui";
import { Checkbox, Dialog, Input, List, Select, Spinner, T, Tree } from "@/ui";
import { ask } from "@/workbench/messages";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
@@ -128,12 +128,19 @@ export function defaultPushChecked(repos: readonly RepoPush[], selected: string
);
}
type PushRow = { type: "repo"; repo: RepoPush } | { type: "commit"; repo: RepoPush; commit: Commit };
/** One node of the multi-repository Push tree: a repo group with its outgoing commits. */
interface PushNode {
key: string;
repo: RepoPush;
commit?: Commit;
children?: PushNode[];
}
function MultiPushDialog({ onClose }: { onClose: () => void }) {
const roots = useVcs((s) => s.roots);
const [repos, setRepos] = useState<RepoPush[] | null>(null);
const [checked, setChecked] = useState<Set<string>>(() => new Set());
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
const [pushTags, setPushTags] = useState(false);
@@ -192,10 +199,11 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
if (!ok) return;
}
};
const rows: PushRow[] = (repos ?? []).flatMap((repo) => [
{ type: "repo" as const, repo },
...repo.commits.map((commit) => ({ type: "commit" as const, repo, commit })),
]);
const nodes: PushNode[] = (repos ?? []).map((repo) => ({
key: repo.root,
repo,
children: repo.commits.map((commit) => ({ key: `${repo.root}:${commit.id}`, repo, commit })),
}));
return (
<Dialog
@@ -203,6 +211,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
title="Push Commits"
onClose={onClose}
width={620}
resizable="both"
footerStart={
<>
<Checkbox checked={force} onChange={setForce} label="Force (with lease)" />
@@ -230,7 +239,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
{ label: "Cancel", role: "cancel" },
]}
>
<div style={{ height: 340, display: "flex", flexDirection: "column", padding: "14px" }}>
<div className={styles.pushTreeWrap}>
{error ? (
<div className={styles.error}>{error}</div>
) : !repos ? (
@@ -238,39 +247,54 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
<Spinner size={14} />
</div>
) : (
<List
items={rows}
getKey={(r) => (r.type === "repo" ? `repo:${r.repo.root}` : `${r.repo.root}:${r.commit.id}`)}
<Tree<PushNode>
roots={nodes}
getKey={(n) => n.key}
getLabel={(n) => (n.commit ? n.commit.summary : repoName(n.repo.root))}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children?.length}
expanded={new Set(nodes.map((n) => n.key).filter((k) => !collapsed.has(k)))}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(node.key);
else s.add(node.key);
return s;
})
}
rowHeight={22}
aria-label="Outgoing commits"
className={styles.boxList}
// Clicks on the checkbox toggle it themselves.
onActivate={(r, _i, e) => {
if (r.type === "repo" && !(e.target as HTMLElement | null)?.closest?.("label")) toggle(r.repo.root);
}}
empty={
<span>
<T>Everything is up to date</T>
</span>
}
renderItem={(r) =>
r.type === "repo" ? (
renderNode={(n) =>
n.commit ? (
<span className={styles.commitRow} style={{ flex: 1, padding: 0 }}>
<Checkbox
checked={checked.has(r.repo.root)}
onChange={() => toggle(r.repo.root)}
label={repoName(r.repo.root)}
/>
<span className={styles.commitMeta}>
{r.repo.branch.head} → {r.repo.branch.upstream ?? tx("new remote branch")}
</span>
<span className={styles.commitHash}>{n.commit.shortId}</span>
<span className={styles.commitSubject}>{n.commit.summary}</span>
<span className={styles.commitMeta}>{n.commit.author}</span>
<span className={styles.commitMeta}>{formatTime(n.commit.timeMs)}</span>
</span>
) : (
<span className={styles.commitRow} style={{ flex: 1, padding: "0 0 0 24px" }}>
<span className={styles.commitHash}>{r.commit.shortId}</span>
<span className={styles.commitSubject}>{r.commit.summary}</span>
<span className={styles.commitMeta}>{r.commit.author}</span>
<span className={styles.commitMeta}>{formatTime(r.commit.timeMs)}</span>
<span
className={styles.commitRow}
style={{ flex: 1, padding: 0 }}
// A row click toggles the checkbox, like before; only the chevron collapses.
onClick={(e) => {
if (!(e.target as HTMLElement | null)?.closest?.("label")) toggle(n.repo.root);
}}
>
<Checkbox
checked={checked.has(n.repo.root)}
onChange={() => toggle(n.repo.root)}
label={repoName(n.repo.root)}
/>
<span className={styles.commitMeta}>
{n.repo.branch.head} → {n.repo.branch.upstream ?? tx("new remote branch")}
</span>
</span>
)
}
@@ -388,6 +412,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
title={head ? tx`Push Commits to ${upstream ?? "remote"}` : "Push"}
onClose={onClose}
width={620}
resizable="both"
footerStart={
<>
<span title={protectedTarget ? `Force push to protected branch '${target}' is prohibited` : undefined}>
@@ -459,7 +484,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo
)}
{retarget && <span className={styles.hint}> {tx("New")}</span>}
</div>
<div style={{ height: 300, display: "flex", flexDirection: "column", padding: "0 14px 14px" }}>
<div className={styles.pushListWrap}>
{error ? (
<div className={styles.error}>{error}</div>
) : !commits ? (
+1
Переглянути файл
@@ -1685,6 +1685,7 @@
"Line comment at first column": "在第一列添加行注释",
"Add a space at line comment start": "在行注释开头添加空格",
"Reset type": "重置类型",
"Resize": "调整大小",
"Resize right tool window bar": "调整右侧工具窗口栏大小",
"Resize tool window bar": "调整工具窗口栏大小",
"Resolve Conflicts…": "解决冲突…",
+27
Переглянути файл
@@ -16,6 +16,7 @@
.dialog {
display: flex;
flex-direction: column;
position: relative;
max-width: 100%;
max-height: 100%;
background: var(--bg-dialog, var(--bg-elev));
@@ -36,6 +37,32 @@
box-shadow: var(--shadow-popup);
}
/* Corner grip for `resizable` dialogs (over the footer's right padding). */
.resizeGrip {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
background: linear-gradient(
135deg,
transparent 55%,
var(--fg-faint) 55%,
var(--fg-faint) 62%,
transparent 62%,
transparent 72%,
var(--fg-faint) 72%,
var(--fg-faint) 79%,
transparent 79%
);
opacity: 0.55;
}
.resizeGrip:hover {
opacity: 1;
}
/* Title bar (drag handle). macOS: traffic lights then the title; else title + close. */
.titleBar {
height: var(--h-dialog-titlebar, 32px);
+48 -1
Переглянути файл
@@ -108,6 +108,11 @@ export interface DialogProps {
/** Free-form footer for the rare dialog whose footer is not a button row. */
footer?: ReactNode;
width?: number | string;
/**
* `both` shows a corner grip resizing the window. The content lays out again
* against the new size; a numeric `width` is also the minimum width.
*/
resizable?: "both";
/**
* `top` places a lightweight modal popup (choosers, Search Everywhere) near
* the top. Popups (also any dialog without a title) get no window title bar
@@ -194,6 +199,7 @@ function DialogInner({
onHelp,
footer,
width = 480,
resizable,
position = "center",
className,
dismissOnBackdrop,
@@ -217,6 +223,7 @@ function DialogInner({
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [active, setActive] = useState(() => typeof document === "undefined" || document.hasFocus());
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
const onFocus = () => setActive(true);
@@ -309,6 +316,33 @@ function DialogInner({
const label = aria["aria-label"] ?? (typeof title === "string" ? title : undefined);
const hasFooter = !!(buttons?.length || footerStart || onHelp || footer);
const canResize = resizable === "both" && !popup;
const resize = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const el = ref.current;
if (!el) return;
e.preventDefault();
const start = { x: e.clientX, y: e.clientY };
const rect = el.getBoundingClientRect();
const minW = typeof width === "number" ? width : 320;
const chrome = el.querySelector<HTMLElement>("[data-dialog-titlebar]")?.offsetHeight ?? 0;
const minH = chrome + 120;
const origin = offset;
const move = (ev: globalThis.PointerEvent) => {
const w = Math.min(Math.max(rect.width + ev.clientX - start.x, minW), window.innerWidth - 24);
const h = Math.min(Math.max(rect.height + ev.clientY - start.y, minH), window.innerHeight - 24);
setSize({ w, h });
// The overlay centres the dialog: shift by half the growth so its top-left stays put.
setOffset({ x: origin.x + (w - rect.width) / 2, y: origin.y + (h - rect.height) / 2 });
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return createPortal(
<div
@@ -327,7 +361,11 @@ function DialogInner({
data-alert={icon ? "" : undefined}
data-inactive={active ? undefined : ""}
className={clsx(styles.dialog, popup ? styles.popup : styles.window, className)}
style={{ width, translate: offset.x || offset.y ? `${offset.x}px ${offset.y}px` : undefined }}
style={{
width: size ? size.w : width,
height: size?.h,
translate: offset.x || offset.y ? `${offset.x}px ${offset.y}px` : undefined,
}}
>
{icon ? null : popup ? (
title != null && (
@@ -392,6 +430,15 @@ function DialogInner({
{!!buttons?.length && <DialogButtons buttons={buttons} onCancel={onClose} platform={platform} />}
</div>
)}
{canResize && (
<div
className={styles.resizeGrip}
role="separator"
aria-orientation="vertical"
aria-label={tx("Resize")}
onPointerDown={resize}
/>
)}
</div>
</div>,
document.body,