дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
552a43a0a1
коміт
f87d153caf
5 files changed
+146
-31
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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…": "解决冲突…",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача