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 <114078441+whyareyouserious@users.noreply.github.com>
This commit is contained in:
whyareyouserious
2026-11-03 23:21:53 +08:00
committed by GitHub
parent f5e12238bb
commit 09402e3e95
9 changed files with 424 additions and 84 deletions
+12 -2
View File
@@ -552,12 +552,13 @@ pub async fn vcs_clone(
request: RequestId,
url: String,
dest: PathBuf,
depth: Option<u32>,
on_output: Channel<String>,
) -> IdeResult<()> {
apply_git_executable(&host.global_settings.effective());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_vcs::clone(&url, &dest, &cancel, &|line| {
ide_vcs::clone(&url, &dest, depth, &cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -1070,7 +1071,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
);
d.add("vcs_clone", |c| async move {
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
vcs_clone(
&c.host,
&c.window,
c.arg("request")?,
c.arg("url")?,
c.arg("dest")?,
c.arg("depth")?,
c.sender("onOutput")?,
)
.await
});
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
+52 -8
View File
@@ -3,7 +3,7 @@
//! hooks, credentials and signing behave exactly as on the command line.
use ide_core::ignored::IgnoredNames;
use ide_core::{CancellationToken, IdeResult};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
@@ -485,20 +485,64 @@ pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
pub use remotes::Remote;
pub use roots::Repos;
fn clone_args(url: &str, dest: &str, depth: Option<u32>) -> IdeResult<Vec<String>> {
if depth == Some(0) {
return Err(IdeError::invalid("clone depth must be greater than zero"));
}
let mut args = vec!["-c".into(), "core.hooksPath=".into(), "clone".into(), "--progress".into()];
if let Some(depth) = depth {
args.extend(["--depth".into(), depth.to_string()]);
}
args.extend(["--".into(), url.into(), dest.into()]);
Ok(args)
}
/// Clones `url` into `dest` (which must not exist or be empty), streaming progress.
pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
/// `depth` creates a shallow clone containing at most that many commits.
pub fn clone(
url: &str,
dest: &Path,
depth: Option<u32>,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let parent = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
std::fs::create_dir_all(parent)?;
let dest = if dest.is_absolute() { dest.to_path_buf() } else { std::env::current_dir()?.join(dest) };
let dest = dest.to_string_lossy();
// Cloning must not inherit an enclosing project's executable Git settings.
let cwd = tempfile::tempdir()?;
cli::run_streaming(
cwd.path(),
&["-c", "core.hooksPath=", "clone", "--progress", "--", url, &dest],
cancel,
on_output,
)
let args = clone_args(url, &dest, depth)?;
let args: Vec<&str> = args.iter().map(String::as_str).collect();
cli::run_streaming(cwd.path(), &args, cancel, on_output)
}
#[cfg(test)]
mod clone_tests {
use super::clone_args;
#[test]
fn builds_full_and_shallow_clone_commands() {
assert_eq!(
clone_args("https://example.test/repo.git", "/tmp/repo", None).unwrap(),
["-c", "core.hooksPath=", "clone", "--progress", "--", "https://example.test/repo.git", "/tmp/repo"]
);
assert_eq!(
clone_args("https://example.test/repo.git", "/tmp/repo", Some(3)).unwrap(),
[
"-c",
"core.hooksPath=",
"clone",
"--progress",
"--depth",
"3",
"--",
"https://example.test/repo.git",
"/tmp/repo",
]
);
assert!(clone_args("https://example.test/repo.git", "/tmp/repo", Some(0)).is_err());
}
}
/// Creates an empty repository in `dir` (`git init`).
+2 -1
View File
@@ -355,9 +355,10 @@ pub async fn vcs_clone(
request: RequestId,
url: String,
dest: PathBuf,
depth: Option<u32>,
on_output: Channel<String>,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_clone(&state.host, "", request, url, dest, crate::commands::sender(on_output)).await
ide_host::ops::vcs::vcs_clone(&state.host, "", request, url, dest, depth, crate::commands::sender(on_output)).await
}
/// `git init` in `dir` (File › New Project… "Create Git repository").
+1 -1
View File
@@ -504,7 +504,7 @@ export const commands = {
*/
vcsStageHunks: (path: string, hunks: number[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_hunks", { path, hunks })),
/** Clones a repository (Get from VCS). Works without an open project. */
vcsClone: (request: RequestId, url: string, dest: string, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_clone", { request, url, dest, onOutput })),
vcsClone: (request: RequestId, url: string, dest: string, depth: number | null, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_clone", { request, url, dest, depth, onOutput })),
/** `git init` in `dir` (File › New Project… "Create Git repository"). */
vcsInit: (dir: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_init", { dir })),
/**
+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10 -1
View File
@@ -410,9 +410,11 @@
"Clear language server workspaces (Java import data and indexes)": "清除语言服务器工作区(Java 导入数据和索引)",
"Clipboard": "剪贴板",
"Clone": "克隆",
"Clone depth": "克隆深度",
"Clone Repository": "克隆仓库",
"Clone Repository…": "克隆仓库…",
"Clone progress": "克隆进度",
"Clone source": "克隆来源",
"Cloned, but the project could not be opened": "已克隆,但无法打开项目",
"Cloning requires the desktop app": "克隆需要桌面应用",
"Close": "关闭",
@@ -470,6 +472,7 @@
"Commits": "提交",
"Commits are applied from top to bottom.": "提交将从上到下依次应用。",
"Commits to rebase": "要变基的提交",
"commits": "个提交",
"Commit…": "提交…",
"Compact mode": "紧凑模式",
"Companion object": "伴生对象",
@@ -746,6 +749,7 @@
"Encoding of the output of processes started from Run configurations.": "从运行配置启动的进程的输出编码。",
"Enhanced for loop": "增强型 for 循环",
"Ensure every saved file ends with a line break": "确保每个已保存的文件都以换行符结尾",
"Enter a positive number of commits": "请输入正整数提交数",
"Enter a tag, branch or commit hash:": "输入标签、分支或提交哈希:",
"Enter the name of new tag:": "输入新标签的名称:",
"Enum": "枚举",
@@ -912,7 +916,6 @@
"Generate Request in HTTP Client": "在 HTTP 客户端中生成请求",
"Generate Requests for Controller": "为控制器生成请求",
"Generate…": "生成…",
"Get from Version Control": "从版本控制获取",
"Get from Version Control…": "从版本控制获取…",
"Gevent compatible": "兼容 Gevent",
"Git Branches": "Git 分支",
@@ -1294,6 +1297,7 @@
"No Maven projects": "没有 Maven 项目",
"No profile for '{name}'": "没有 '{name}' 的分析数据",
"No Python found": "未找到 Python",
"No recent repository URLs": "没有最近使用的仓库 URL",
"No recent searches": "无最近搜索",
"No TODO items found": "未找到 TODO 项",
"No TODO items in this file": "此文件中没有 TODO 项",
@@ -1626,6 +1630,7 @@
"Recent files": "最近的文件",
"Recent locations": "最近的位置",
"Recent projects": "最近的项目",
"Recent repository URLs": "最近使用的仓库 URL",
"Recent Searches": "最近搜索",
"Recently Viewed Files": "最近查看的文件",
"Record": "记录",
@@ -1739,6 +1744,7 @@
"Replacements": "替换项",
"Reports errors and warnings from language servers.": "报告来自语言服务器的错误和警告。",
"Repository": "仓库",
"Repository URL": "仓库 URL",
"Request": "请求",
"Require": "必需",
"Rerun": "重新运行",
@@ -1971,6 +1977,7 @@
"Shelf": "搁置",
"Shell integration": "Shell 集成",
"Shell path": "Shell 路径",
"Shallow clone with a history truncated to": "浅克隆,历史记录截断为",
"Shelve Changes": "搁置更改",
"Shelve Changes…": "搁置更改…",
"Shelved changes": "已搁置的更改",
@@ -2379,6 +2386,8 @@
"Variables:": "变量:",
"Version": "版本",
"Version Control": "版本控制",
"Version control": "版本控制",
"Version control:": "版本控制:",
"Version control is not configured": "未配置版本控制",
"Version used by vtsls / typescript-language-server (typescript.tsdk, tsserver.path).": "vtsls / typescript-language-server 使用的版本(typescript.tsdk、tsserver.path)。",
"Vertical": "竖线",