feat(vcs): redesign the Clone Repository dialog and support shallow clones (#866)

Clone Repository gets IntelliJ's layout: a source list on the left, a version-control selector, URL history, a directory picker, and a shallow-clone option (history truncated to N commits, git clone --depth N). Entries for hosting platforms without backend support were removed.

Co-authored-by: whyareyouserious <[email protected]>
Цей коміт міститься в:
whyareyouserious authored and GitHub committed 2026-11-03 23:21:53 +08:00
1 джерело f5e12238bb
коміт 09402e3e95
9 files changed
+424 -84

No files matched your search

+12 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cloneDirName, joinPath, validateClone } from "../clone";
import { cloneDirName, joinPath, parseCloneDepth, validateClone } from "../clone";
import { outgoingQuery } from "../remoteUi";
describe("cloneDirName", () => {
@@ -31,6 +31,17 @@ describe("joinPath / validateClone", () => {
});
});
describe("parseCloneDepth", () => {
it("accepts positive Git depths only", () => {
expect(parseCloneDepth("1")).toBe(1);
expect(parseCloneDepth(" 25 ")).toBe(25);
expect(parseCloneDepth("0")).toBeNull();
expect(parseCloneDepth("1.5")).toBeNull();
expect(parseCloneDepth("abc")).toBeNull();
expect(parseCloneDepth("4294967296")).toBeNull();
});
});
describe("outgoingQuery", () => {
it("uses the upstream range when tracked", () => {
expect(outgoingQuery("origin/main")).toEqual({ branch: "HEAD", range: "@{u}..HEAD", limit: 1000 });
+157
Переглянути файл
@@ -0,0 +1,157 @@
.dialog {
height: min(660px, calc(100vh - 48px));
min-height: min(480px, calc(100vh - 48px));
}
.dialog [data-dialog-body] {
display: flex;
overflow: hidden;
}
.dialog [data-dialog-footer] {
border-top: 1px solid var(--border);
}
.dialogTitle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dialogTitle img {
width: 20px;
height: 20px;
}
.layout {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.sidebar {
flex: 0 0 224px;
padding: 8px 0;
border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-dialog, var(--bg-elev)) 96%, var(--fg-strong));
overflow: auto;
}
.sourceItem {
min-height: 44px;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 9px 14px;
color: var(--fg-strong);
}
.sourceItem[data-selected] {
background: var(--bg-tree-selected);
}
.sourceName {
font-weight: 600;
line-height: 22px;
}
.form {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 28px 24px;
}
.versionRow,
.fieldRow {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
align-items: center;
gap: 10px 12px;
}
.versionRow {
grid-template-columns: 112px 138px minmax(0, 1fr);
}
.versionRow > label,
.fieldRow > label {
color: var(--fg-strong);
text-align: right;
}
.versionSelect {
width: 138px;
}
.comboField {
min-width: 0;
height: var(--h-field, 28px);
display: flex;
align-items: center;
border-radius: var(--radius-control, 4px);
background: var(--bg-field, var(--bg-button));
box-shadow: inset 0 0 0 1px var(--border-button);
}
.comboField:focus-within {
box-shadow:
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
.fieldInput {
flex: 1;
height: 100%;
padding: 0 8px;
background: transparent;
}
.fieldButton {
width: 34px;
height: 26px;
border-radius: 0 var(--radius-control, 4px) var(--radius-control, 4px) 0;
border-left: 1px solid var(--border);
}
.shallowRow {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding-left: 124px;
}
.depthInput {
width: 84px;
}
.feedback {
min-width: 0;
padding-left: 124px;
}
.progress {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.progress > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
color: var(--fg-subtle);
font-size: var(--fs-small);
line-height: 1.5;
}
.error {
color: var(--danger);
font-size: var(--fs-small);
}
@media (max-width: 720px) {
.sidebar {
flex-basis: 172px;
}
.form {
padding-inline: 16px;
}
.versionRow,
.fieldRow {
grid-template-columns: 86px minmax(0, 1fr);
}
.versionRow {
grid-template-columns: 86px 118px minmax(0, 1fr);
}
.versionSelect {
width: 118px;
}
.shallowRow,
.feedback {
padding-left: 98px;
}
}
+178 -49
Переглянути файл
@@ -4,13 +4,16 @@ import { Channel } from "@tauri-apps/api/core";
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { openProject } from "@/platform/project";
import { Button, Dialog, Input, ProgressBar, T } from "@/ui";
import { Checkbox, Dialog, Icon, IconButton, Input, ProgressBar, Select, T, showContextMenu } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { openDialog } from "./dialogs";
import { progressFraction } from "./model";
import styles from "./common.module.css";
import logo from "../../../src-tauri/icons/128x128.png";
import styles from "./clone.module.css";
const PARENT_KEY = "openidea.vcs.cloneParent";
const RECENT_URLS_KEY = "openidea.vcs.cloneRecentUrls";
const MAX_RECENT_URLS = 10;
/** Folder name git would pick for `url` (`https://h/o/repo.git` → `repo`, `git@h:o/r` → `r`). */
export function cloneDirName(url: string): string {
@@ -45,6 +48,14 @@ export function validateClone(url: string, dir: string): string | null {
return null;
}
/** A valid `git clone --depth` value, or null while the field is invalid. */
export function parseCloneDepth(value: string): number | null {
const text = value.trim();
if (!/^\d+$/.test(text)) return null;
const depth = Number(text);
return Number.isSafeInteger(depth) && depth > 0 && depth <= 4_294_967_295 ? depth : null;
}
function readParent(): string {
try {
return localStorage.getItem(PARENT_KEY) ?? "";
@@ -61,6 +72,26 @@ function writeParent(path: string) {
}
}
function readRecentUrls(): string[] {
try {
const value = JSON.parse(localStorage.getItem(RECENT_URLS_KEY) ?? "[]");
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string").slice(0, MAX_RECENT_URLS)
: [];
} catch {
return [];
}
}
function rememberUrl(url: string) {
try {
const urls = [url, ...readRecentUrls().filter((item) => item !== url)].slice(0, MAX_RECENT_URLS);
localStorage.setItem(RECENT_URLS_KEY, JSON.stringify(urls));
} catch {
// URL history is a convenience only
}
}
const parentOf = (p: string) => p.replace(/[/\\]+$/, "").replace(/[/\\][^/\\]*$/, "");
async function defaultParent(): Promise<string> {
@@ -89,9 +120,13 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
/** Directory edited by hand: stop deriving it from the URL. */
const [customDir, setCustomDir] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [shallow, setShallow] = useState(false);
const [depthInput, setDepthInput] = useState("1");
const [progress, setProgress] = useState<{ line: string; fraction: number | null }>({ line: "", fraction: null });
const [error, setError] = useState<string | null>(null);
const request = useRef<number | null>(null);
const urlField = useRef<HTMLDivElement>(null);
const [recentUrls] = useState(readRecentUrls);
useEffect(() => {
let alive = true;
@@ -102,7 +137,17 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
}, []);
const dir = customDir ?? joinPath(parent, cloneDirName(url));
const invalid = validateClone(url, dir);
const depth = shallow ? parseCloneDepth(depthInput) : null;
const invalid = validateClone(url, dir) ?? (shallow && depth === null ? "Enter a positive number of commits" : null);
const showUrlHistory = () => {
const rect = urlField.current?.getBoundingClientRect();
if (!rect || !recentUrls.length) return;
showContextMenu(
{ x: rect.left, y: rect.bottom },
recentUrls.map((item) => ({ label: item, verbatim: true, run: () => setUrl(item) })),
);
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -136,8 +181,9 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
if (text) setProgress({ line: text, fraction: progressFraction(text) });
};
try {
await call(commands.vcsClone(id, url.trim(), target, channel));
await call(commands.vcsClone(id, url.trim(), target, depth, channel));
writeParent(parentOf(target));
rememberUrl(url.trim());
onClose();
try {
await openProject(target);
@@ -162,58 +208,141 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
return (
<Dialog
open
title="Get from Version Control"
title={
<span className={styles.dialogTitle}>
<img src={logo} alt="" draggable={false} />
<T>Clone Repository</T>
</span>
}
aria-label="Clone Repository"
onClose={cancel}
width={560}
width="min(920px, calc(100vw - 48px))"
resizable="both"
className={styles.dialog}
buttons={[
{ label: "Clone", role: "default", disabled: !!invalid || busy, onClick: () => void clone() },
{ label: "Cancel", role: "cancel", onClick: cancel },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void clone();
}}
>
<label className={styles.fieldLabel} htmlFor="vcs-clone-url">
<T>URL:</T>
</label>
<Input
id="vcs-clone-url"
data-autofocus
mono
value={url}
disabled={busy}
placeholder="https://github.com/user/repository.git"
onChange={(e) => setUrl(e.target.value)}
/>
<label className={styles.fieldLabel} htmlFor="vcs-clone-dir">
<T>Directory:</T>
</label>
<div className={styles.cloneDirRow}>
<Input
id="vcs-clone-dir"
className={styles.cloneDirInput}
mono
value={dir}
disabled={busy}
onChange={(e) => setCustomDir(e.target.value)}
/>
<Button disabled={busy} onClick={() => void browse()}>
<T>Browse…</T>
</Button>
</div>
{busy && (
<div className={styles.cloneProgress} aria-live="polite">
<ProgressBar value={progress.fraction} aria-label="Clone progress" />
<span className={styles.hint}>{progress.line || tx("Cloning…")}</span>
<div className={styles.layout}>
<aside className={styles.sidebar} aria-label={tx("Clone source")}>
<div className={styles.sourceItem} data-selected="">
<Icon name="branch" size={22} />
<span className={styles.sourceName}>
<T>Repository URL</T>
</span>
</div>
)}
{error && <div className={styles.error}>{error}</div>}
{!busy && !error && url.trim() && invalid && <div className={styles.hint}>{invalid}</div>}
</form>
</aside>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void clone();
}}
>
<div className={styles.versionRow}>
<label id="vcs-clone-version-label">
<T>Version control:</T>
</label>
<Select
aria-label="Version control"
className={styles.versionSelect}
value="git"
options={[{ value: "git", label: "Git" }]}
onChange={() => {}}
disabled={busy}
/>
</div>
<div className={styles.fieldRow}>
<label htmlFor="vcs-clone-url">
<T>URL:</T>
</label>
<div ref={urlField} className={styles.comboField}>
<Input
id="vcs-clone-url"
data-autofocus
variant="bare"
className={styles.fieldInput}
value={url}
disabled={busy}
placeholder="https://github.com/user/repository.git"
onChange={(e) => setUrl(e.target.value)}
/>
<IconButton
icon="chevron-down"
size="normal"
className={styles.fieldButton}
title={recentUrls.length ? "Recent repository URLs" : "No recent repository URLs"}
disabled={busy || !recentUrls.length}
onClick={showUrlHistory}
/>
</div>
</div>
<div className={styles.fieldRow}>
<label htmlFor="vcs-clone-dir">
<T>Directory:</T>
</label>
<div className={styles.comboField}>
<Input
id="vcs-clone-dir"
variant="bare"
className={styles.fieldInput}
value={dir}
disabled={busy}
onChange={(e) => setCustomDir(e.target.value)}
/>
<IconButton
icon="folder-open"
size="normal"
className={styles.fieldButton}
title="Browse…"
disabled={busy}
onClick={() => void browse()}
/>
</div>
</div>
<div className={styles.shallowRow}>
<Checkbox
checked={shallow}
disabled={busy}
onChange={setShallow}
label={<T>Shallow clone with a history truncated to</T>}
/>
<Input
aria-label="Clone depth"
className={styles.depthInput}
type="number"
inputMode="numeric"
min={1}
step={1}
value={depthInput}
disabled={busy || !shallow}
invalid={shallow && depth === null}
onChange={(e) => setDepthInput(e.target.value)}
/>
<T>commits</T>
</div>
<div className={styles.feedback}>
{busy && (
<div className={styles.progress} aria-live="polite">
<ProgressBar value={progress.fraction} aria-label="Clone progress" />
<span className={styles.hint}>{progress.line || tx("Cloning…")}</span>
</div>
)}
{error && <div className={styles.error}>{error}</div>}
{!busy && !error && url.trim() && invalid && (
<div className={styles.hint}>
<T>{invalid}</T>
</div>
)}
</div>
</form>
</div>
</Dialog>
);
}
-21
Переглянути файл
@@ -125,27 +125,6 @@
flex-direction: column;
padding: 0 14px 14px;
}
.cloneDirRow {
display: flex;
align-items: center;
gap: 6px;
}
.cloneDirInput {
flex: 1;
min-width: 0;
}
.cloneProgress {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
min-width: 0;
}
.cloneProgress > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.todoList {
margin: 8px 0 0;
padding-left: 18px;