дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
f5e12238bb
коміт
09402e3e95
9 files changed
+424
-84
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача