diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d8655e3b..1ed60e50 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -552,12 +552,13 @@ pub async fn vcs_clone( request: RequestId, url: String, dest: PathBuf, + depth: Option, on_output: Channel, ) -> 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 }); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 7e8cd352..6917faef 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -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) -> IdeResult> { + 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, + 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`). diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 0e06e2f5..9f23a5a5 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -355,9 +355,10 @@ pub async fn vcs_clone( request: RequestId, url: String, dest: PathBuf, + depth: Option, on_output: Channel, ) -> 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"). diff --git a/src/bindings.ts b/src/bindings.ts index e9bcf986..fb756b5d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -504,7 +504,7 @@ export const commands = { */ vcsStageHunks: (path: string, hunks: number[]) => typedError(__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) => typedError(__TAURI_INVOKE("vcs_clone", { request, url, dest, onOutput })), + vcsClone: (request: RequestId, url: string, dest: string, depth: number | null, onOutput: Channel) => typedError(__TAURI_INVOKE("vcs_clone", { request, url, dest, depth, onOutput })), /** `git init` in `dir` (File › New Project… "Create Git repository"). */ vcsInit: (dir: string) => typedError(__TAURI_INVOKE("vcs_init", { dir })), /** diff --git a/src/features/vcs/__tests__/clone.test.ts b/src/features/vcs/__tests__/clone.test.ts index 77859ccf..9090071e 100644 --- a/src/features/vcs/__tests__/clone.test.ts +++ b/src/features/vcs/__tests__/clone.test.ts @@ -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 }); diff --git a/src/features/vcs/clone.module.css b/src/features/vcs/clone.module.css new file mode 100644 index 00000000..f7222250 --- /dev/null +++ b/src/features/vcs/clone.module.css @@ -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; + } +} diff --git a/src/features/vcs/clone.tsx b/src/features/vcs/clone.tsx index 1d4ccd61..7c80230a 100644 --- a/src/features/vcs/clone.tsx +++ b/src/features/vcs/clone.tsx @@ -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 { @@ -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(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(null); const request = useRef(null); + const urlField = useRef(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 ( + + Clone Repository + + } + 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 }, ]} > -
{ - e.preventDefault(); - void clone(); - }} - > - - setUrl(e.target.value)} - /> - -
- setCustomDir(e.target.value)} - /> - -
- {busy && ( -
- - {progress.line || tx("Cloning…")} +
+ + +
{ + e.preventDefault(); + void clone(); + }} + > +
+ + setUrl(e.target.value)} + /> + +
+
+ +
+ +
+ setCustomDir(e.target.value)} + /> + void browse()} + /> +
+
+ +
+ Shallow clone with a history truncated to} + /> + setDepthInput(e.target.value)} + /> + commits +
+ +
+ {busy && ( +
+ + {progress.line || tx("Cloning…")} +
+ )} + {error &&
{error}
} + {!busy && !error && url.trim() && invalid && ( +
+ {invalid} +
+ )} +
+ +
); } diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 9e9acc8d..766a0785 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -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; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c47f3d11..d09276bd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "竖线",