From f87d153caf47276489580f6a0365e7130af39c3a Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:37:56 +0800 Subject: [PATCH] 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 <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/vcs/common.module.css | 15 ++++++ src/features/vcs/remoteUi.tsx | 85 +++++++++++++++++++----------- src/platform/locales/text/zh.json | 1 + src/ui/Dialog.module.css | 27 ++++++++++ src/ui/Dialog.tsx | 49 ++++++++++++++++- 5 files changed, 146 insertions(+), 31 deletions(-) diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 580352b4d..3fbd5f58a 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -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; diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index 4626bc2c1..45e247e21 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -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(null); const [checked, setChecked] = useState>(() => new Set()); + const [collapsed, setCollapsed] = useState>(() => new Set()); const [error, setError] = useState(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 ( void }) { title="Push Commits" onClose={onClose} width={620} + resizable="both" footerStart={ <> @@ -230,7 +239,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { { label: "Cancel", role: "cancel" }, ]} > -
+
{error ? (
{error}
) : !repos ? ( @@ -238,39 +247,54 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
) : ( - (r.type === "repo" ? `repo:${r.repo.root}` : `${r.repo.root}:${r.commit.id}`)} + + 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={ Everything is up to date } - renderItem={(r) => - r.type === "repo" ? ( + renderNode={(n) => + n.commit ? ( - toggle(r.repo.root)} - label={repoName(r.repo.root)} - /> - - {r.repo.branch.head} → {r.repo.branch.upstream ?? tx("new remote branch")} - + {n.commit.shortId} + {n.commit.summary} + {n.commit.author} + {formatTime(n.commit.timeMs)} ) : ( - - {r.commit.shortId} - {r.commit.summary} - {r.commit.author} - {formatTime(r.commit.timeMs)} + { + if (!(e.target as HTMLElement | null)?.closest?.("label")) toggle(n.repo.root); + }} + > + toggle(n.repo.root)} + label={repoName(n.repo.root)} + /> + + {n.repo.branch.head} → {n.repo.branch.upstream ?? tx("new remote branch")} + ) } @@ -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={ <> @@ -459,7 +484,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo )} {retarget && {tx("New")}}
-
+
{error ? (
{error}
) : !commits ? ( diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 350d1cdb7..b8242c554 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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…": "解决冲突…", diff --git a/src/ui/Dialog.module.css b/src/ui/Dialog.module.css index 494b6fc0f..690c2dda8 100644 --- a/src/ui/Dialog.module.css +++ b/src/ui/Dialog.module.css @@ -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); diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx index 2d45a529f..8f876ec4f 100644 --- a/src/ui/Dialog.tsx +++ b/src/ui/Dialog.tsx @@ -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) => { + 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("[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(
{icon ? null : popup ? ( title != null && ( @@ -392,6 +430,15 @@ function DialogInner({ {!!buttons?.length && }
)} + {canResize && ( +
+ )}
, document.body,