feat(vcs): clone from the repositories of your GitHub account (#1125)

The Clone Repository dialog lists GitHub next to Repository URL: the repositories of the account gh is signed in to, searchable; picking one fills URL and directory, double-click or Enter clones. Clone URL follows gh's git_protocol. Clone sources are a workbench contribution (registerCloneSource); gh_account_repos needs no project.

Closes #750
This commit is contained in:
l0ng-ai
2026-11-04 08:40:06 +08:00
committed by GitHub
parent 9988ce4113
commit a3bbbf90a4
16 changed files with 668 additions and 55 deletions
+145
View File
@@ -0,0 +1,145 @@
//! The signed-in GitHub account, independent of any project: its repositories for
//! "Get from Version Control" › GitHub.
use crate::{GhAvailability, QUICK, classify_failure, command, gh_executable, process};
use serde::{Deserialize, Serialize};
use specta::Type;
/// A repository of the signed-in account (owned, collaborator, or organization member).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhAccountRepo {
/// `owner/name`.
pub name_with_owner: String,
pub description: Option<String>,
pub private: bool,
pub fork: bool,
pub archived: bool,
/// What `git clone` takes: HTTPS, or SSH when gh's `git_protocol` is `ssh`.
pub clone_url: String,
}
/// The account's repositories, or why they can't be listed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GhAccountRepos {
Ready {
login: String,
repos: Vec<GhAccountRepo>,
},
/// No `gh` executable was found.
NotInstalled,
/// `gh auth login` has not been run (or the token expired).
NotLoggedIn {
message: String,
},
Error {
message: String,
},
}
#[derive(Deserialize)]
struct RestRepo {
full_name: String,
description: Option<String>,
#[serde(default)]
private: bool,
#[serde(default)]
fork: bool,
#[serde(default)]
archived: bool,
clone_url: String,
ssh_url: String,
}
/// `gh api --paginate --slurp user/repos` output: one array per page.
pub fn parse_account_repos(slurped: &str, ssh: bool) -> Result<Vec<GhAccountRepo>, serde_json::Error> {
let pages: Vec<Vec<RestRepo>> = serde_json::from_str(slurped)?;
Ok(pages
.into_iter()
.flatten()
.map(|r| GhAccountRepo {
name_with_owner: r.full_name,
description: r.description.filter(|d| !d.trim().is_empty()),
private: r.private,
fork: r.fork,
archived: r.archived,
clone_url: if ssh { r.ssh_url } else { r.clone_url },
})
.collect())
}
/// Lists the repositories of the account gh is signed in to, most recently pushed first.
pub fn account_repositories() -> GhAccountRepos {
if gh_executable().is_none() {
return GhAccountRepos::NotInstalled;
}
let dir = std::env::temp_dir();
let gh = |args: &[&str]| -> Result<String, GhAccountRepos> {
let out = command(&dir, args)
.and_then(|cmd| process::run(cmd, QUICK, None))
.map_err(|e| GhAccountRepos::Error { message: e.message })?;
if out.success {
return Ok(out.stdout);
}
let message = if out.stderr.trim().is_empty() { out.stdout } else { out.stderr };
Err(match classify_failure(&message) {
GhAvailability::NotLoggedIn { message } => GhAccountRepos::NotLoggedIn { message },
_ => GhAccountRepos::Error { message: message.trim().to_owned() },
})
};
let result = (|| {
let login = gh(&["api", "user", "--jq", ".login"])?.trim().to_owned();
let ssh = gh(&["config", "get", "git_protocol", "--host", "github.com"]).is_ok_and(|p| p.trim() == "ssh");
let pages = gh(&[
"api",
"user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
"--paginate",
"--slurp",
])?;
let repos = parse_account_repos(&pages, ssh)
.map_err(|e| GhAccountRepos::Error { message: format!("unexpected gh output: {e}") })?;
Ok(GhAccountRepos::Ready { login, repos })
})();
result.unwrap_or_else(|e| e)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_every_page_and_picks_the_configured_protocol() {
let pages = r#"[
[{"full_name":"me/app","description":"An app","private":true,"fork":false,"archived":false,
"clone_url":"https://github.com/me/app.git","ssh_url":"git@github.com:me/app.git"}],
[{"full_name":"org/lib","description":"","fork":true,
"clone_url":"https://github.com/org/lib.git","ssh_url":"git@github.com:org/lib.git"}]
]"#;
let https = parse_account_repos(pages, false).unwrap();
assert_eq!(
https,
vec![
GhAccountRepo {
name_with_owner: "me/app".into(),
description: Some("An app".into()),
private: true,
fork: false,
archived: false,
clone_url: "https://github.com/me/app.git".into(),
},
GhAccountRepo {
name_with_owner: "org/lib".into(),
description: None,
private: false,
fork: true,
archived: false,
clone_url: "https://github.com/org/lib.git".into(),
},
]
);
let ssh = parse_account_repos(pages, true).unwrap();
assert_eq!(ssh[1].clone_url, "git@github.com:org/lib.git");
assert!(parse_account_repos("[]", false).unwrap().is_empty());
}
}
+2
View File
@@ -3,9 +3,11 @@
//!
//! Every function blocks; callers run them off the UI / command threads.
mod account;
mod model;
mod process;
pub use account::{GhAccountRepo, GhAccountRepos, account_repositories, parse_account_repos};
pub use model::*;
use ide_core::{CancellationToken, IdeError, IdeResult};
+9 -2
View File
@@ -3,8 +3,8 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_github::{
CreatePullRequest, GhApiRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment, PullRequestDetails,
PullRequestSummary, ReviewEvent,
CreatePullRequest, GhAccountRepos, GhApiRequest, GhAvailability, PrDiffRevisions, PrListQuery, PrReviewComment,
PullRequestDetails, PullRequestSummary, ReviewEvent,
};
use std::path::PathBuf;
use tauri::ipc::Channel;
@@ -35,6 +35,13 @@ fn vcs_changed(state: &AppState, window: &Window, root: &std::path::Path) {
}
}
/// The repositories of the account gh is signed in to ("Get from Version Control" › GitHub); needs no project.
#[tauri::command]
#[specta::specta]
pub async fn gh_account_repos() -> IdeResult<GhAccountRepos> {
blocking(|| Ok(ide_github::account_repositories())).await
}
#[tauri::command]
#[specta::specta]
pub async fn gh_availability(
+1
View File
@@ -474,6 +474,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
docker::docker_set_registry_password,
docker::docker_registry_login,
docker::docker_run_env,
github::gh_account_repos,
github::gh_availability,
github::gh_api,
github::gh_pr_list,
+21
View File
@@ -1023,6 +1023,8 @@ export const commands = {
* daemon and puts the docker executable on `PATH`.
*/
dockerRunEnv: (endpoint: DockerEndpoint) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("docker_run_env", { endpoint })),
/** The repositories of the account gh is signed in to ("Get from Version Control" › GitHub); needs no project. */
ghAccountRepos: () => typedError<GhAccountRepos, IdeError>(__TAURI_INVOKE("gh_account_repos")),
ghAvailability: (repo: string | null) => typedError<GhAvailability, IdeError>(__TAURI_INVOKE("gh_availability", { repo })),
/** A GitHub GraphQL / REST call through `gh api`; returns the response body. */
ghApi: (repo: string | null, request: GhApiRequest) => typedError<string, IdeError>(__TAURI_INVOKE("gh_api", { repo, request })),
@@ -3505,6 +3507,25 @@ export type GeneralSettings = {
projectName?: string,
};
/** A repository of the signed-in account (owned, collaborator, or organization member). */
export type GhAccountRepo = {
/** `owner/name`. */
nameWithOwner: string,
description: string | null,
private: boolean,
fork: boolean,
archived: boolean,
/** What `git clone` takes: HTTPS, or SSH when gh's `git_protocol` is `ssh`. */
cloneUrl: string,
};
/** The account's repositories, or why they can't be listed. */
export type GhAccountRepos = { kind: "ready"; login: string; repos: GhAccountRepo[] } |
/** No `gh` executable was found. */
{ kind: "notInstalled" } |
/** `gh auth login` has not been run (or the token expired). */
{ kind: "notLoggedIn"; message: string } | { kind: "error"; message: string };
/**
* A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
* `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
@@ -0,0 +1,75 @@
.pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
}
.login {
color: var(--fg-strong);
font-weight: 600;
}
.search {
flex: 1;
min-width: 0;
margin-left: 8px;
}
.list {
flex: 1;
min-height: 160px;
display: flex;
border-radius: var(--radius-control, 4px);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
.list > * {
flex: 1;
min-width: 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.rowIcon {
flex: none;
color: var(--fg-subtle);
}
.name {
flex: none;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
flex: none;
color: var(--fg-faint);
font-size: var(--fs-small);
}
.description {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-faint);
font-size: var(--fs-small);
}
.center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.muted {
color: var(--fg-subtle);
}
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const ghAccountRepos = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: { ghAccountRepos: () => ghAccountRepos() },
}));
import type { GhAccountRepo } from "@/bindings";
import { GitHubCloneSource, matchRepos } from "./GitHubCloneSource";
const repo = (nameWithOwner: string, extra: Partial<GhAccountRepo> = {}): GhAccountRepo => ({
nameWithOwner,
description: null,
private: false,
fork: false,
archived: false,
cloneUrl: `https://github.com/${nameWithOwner}.git`,
...extra,
});
const repos = [repo("me/app", { private: true, description: "The app" }), repo("org/lib", { fork: true })];
afterEach(() => {
cleanup();
ghAccountRepos.mockReset();
});
describe("GitHub clone source", () => {
it("filters by owner/name", () => {
expect(matchRepos(repos, "").map((r) => r.repo.nameWithOwner)).toEqual(["me/app", "org/lib"]);
expect(matchRepos(repos, "olib").map((r) => r.repo.nameWithOwner)).toEqual(["org/lib"]);
});
it("lists the account's repositories; a click picks one, Enter and double-click clone it", async () => {
ghAccountRepos.mockResolvedValue({ status: "ok", data: { kind: "ready", login: "me", repos } });
const onUrl = vi.fn();
const onClone = vi.fn();
const { rerender } = render(<GitHubCloneSource url="" onUrl={onUrl} onClone={onClone} disabled={false} />);
await screen.findByText("me");
fireEvent.mouseDown(screen.getByRole("option", { name: /org\/lib/ }));
fireEvent.click(screen.getByRole("option", { name: /org\/lib/ }));
expect(onClone).not.toHaveBeenCalled();
expect(onUrl).toHaveBeenLastCalledWith("https://github.com/org/lib.git");
rerender(
<GitHubCloneSource url="https://github.com/org/lib.git" onUrl={onUrl} onClone={onClone} disabled={false} />,
);
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search repositories" }), { key: "ArrowUp" });
expect(onUrl).toHaveBeenLastCalledWith("https://github.com/me/app.git");
fireEvent.doubleClick(screen.getByText("me/app"));
expect(onClone).toHaveBeenCalledWith("https://github.com/me/app.git");
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search repositories" }), { key: "Enter" });
expect(onClone).toHaveBeenLastCalledWith("https://github.com/org/lib.git");
fireEvent.change(screen.getByRole("textbox", { name: "Search repositories" }), { target: { value: "zzz" } });
expect(screen.getByText("No matching repositories")).toBeInTheDocument();
});
it("says how to sign in when gh is not logged in, and retries", async () => {
ghAccountRepos.mockResolvedValueOnce({ status: "ok", data: { kind: "notLoggedIn", message: "x" } });
render(<GitHubCloneSource url="" onUrl={() => {}} onClone={() => {}} disabled={false} />);
expect(await screen.findByText("Not logged in to GitHub")).toBeInTheDocument();
ghAccountRepos.mockResolvedValueOnce({ status: "ok", data: { kind: "ready", login: "me", repos } });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.getByRole("option", { name: /me\/app/ })).toBeInTheDocument());
});
});
@@ -0,0 +1,176 @@
/** Clone Repository › GitHub: the repositories of the account gh is signed in to. */
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import type { GhAccountRepo, GhAccountRepos } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import {
Button,
EmptyState,
HighlightedText,
Icon,
List,
SearchField,
Spinner,
T,
fuzzyMatch,
listNavigate,
} from "@/ui";
import type { CloneSourceProps } from "@/workbench/contributions";
import { openInBrowser } from "../data/local";
import styles from "./GitHubCloneSource.module.css";
type Row = { repo: GhAccountRepo; indices: number[] | null };
/** Repositories matching `query` (fuzzy, on `owner/name`), in the account's order. */
export function matchRepos(repos: readonly GhAccountRepo[], query: string): Row[] {
const q = query.trim();
if (!q) return repos.map((repo) => ({ repo, indices: null }));
return repos.flatMap((repo) => {
const indices = fuzzyMatch(repo.nameWithOwner, q);
return indices ? [{ repo, indices }] : [];
});
}
export function GitHubCloneSource({ url, onUrl, onClone, disabled }: CloneSourceProps) {
const [state, setState] = useState<GhAccountRepos | null>(null);
const [query, setQuery] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let alive = true;
call(commands.ghAccountRepos())
.then((s) => alive && setState(s))
.catch((e) => alive && setState({ kind: "error", message: e instanceof Error ? e.message : String(e) }));
return () => {
alive = false;
};
}, [attempt]);
const rows = useMemo(() => (state?.kind === "ready" ? matchRepos(state.repos, query) : []), [state, query]);
const active = rows.findIndex((r) => r.repo.cloneUrl === url);
const retry = (
<Button
size="small"
onClick={() => {
setState(null);
setAttempt((n) => n + 1);
}}
>
<T>Retry</T>
</Button>
);
if (!state)
return (
<div className={styles.center}>
<Spinner />
<span className={styles.muted}>
<T>Loading repositories…</T>
</span>
</div>
);
if (state.kind === "notInstalled")
return (
<EmptyState
title="GitHub CLI is not installed"
actions={
<>
<Button size="small" onClick={() => void openInBrowser("https://cli.github.com")}>
<T>Install gh…</T>
</Button>
{retry}
</>
}
>
<T>GitHub repositories are listed with the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
<code>gh auth login</code>.
</EmptyState>
);
if (state.kind === "notLoggedIn")
return (
<EmptyState title="Not logged in to GitHub" actions={retry}>
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
</EmptyState>
);
if (state.kind === "error")
return (
<EmptyState title="Could not reach GitHub" actions={retry}>
{state.message}
</EmptyState>
);
const pick = (i: number) => {
const row = rows[i];
if (row) onUrl(row.repo.cloneUrl);
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && rows[active]) {
e.preventDefault();
onClone(rows[active].repo.cloneUrl);
return;
}
const next = listNavigate(e.key, { items: rows, active: Math.max(active, 0) });
if (next !== null) {
e.preventDefault();
pick(next);
}
};
return (
<div className={styles.pane}>
<div className={styles.header}>
<Icon name="github" size={16} />
<span className={styles.login}>{state.login}</span>
<SearchField
className={styles.search}
aria-label="Search repositories"
placeholder={tx("Search repositories")}
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
disabled={disabled}
data-autofocus
/>
</div>
<div className={styles.list}>
<List<Row>
aria-label="Repositories"
items={rows}
getKey={(r) => r.repo.nameWithOwner}
activeIndex={active}
onActiveChange={pick}
focusable={false}
rowHeight={26}
empty={
<span className={styles.muted}>
<T>{state.repos.length ? "No matching repositories" : "No repositories"}</T>
</span>
}
renderItem={(r) => (
<span
className={styles.row}
title={r.repo.description ?? undefined}
onDoubleClick={() => onClone(r.repo.cloneUrl)}
>
<Icon name={r.repo.private ? "lock" : "github"} size={14} className={styles.rowIcon} />
<span className={styles.name}>
{r.indices ? <HighlightedText text={r.repo.nameWithOwner} indices={r.indices} /> : r.repo.nameWithOwner}
</span>
{r.repo.fork && (
<span className={styles.tag}>
<T>fork</T>
</span>
)}
{r.repo.archived && (
<span className={styles.tag}>
<T>archived</T>
</span>
)}
{r.repo.description && <span className={styles.description}>{r.repo.description}</span>}
</span>
)}
/>
</div>
</div>
);
}
+14 -1
View File
@@ -13,7 +13,13 @@ import { registerActions, type Action } from "@/platform/actions";
import { getContext, setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { lazyComponent } from "@/ui";
import { maybeEditorService, registerEditorKind, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import {
maybeEditorService,
registerCloneSource,
registerEditorKind,
registerOverlay,
registerToolWindow,
} from "@/workbench/contributions";
import { activateReviewDiff, toggleReviewDiffViewed } from "./diff";
import { reloadList } from "./data/list";
import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local";
@@ -304,6 +310,13 @@ function actions(): Action[] {
export function activate(): () => void {
setContext("githubPrTab", "list");
const disposers = [
registerCloneSource({
id: "github",
label: "GitHub",
icon: "github",
order: 10,
component: lazyComponent(() => import("./clone/GitHubCloneSource").then((m) => m.GitHubCloneSource)),
}),
registerToolWindow({
id: WINDOW,
title: "Pull Requests",
+21
View File
@@ -155,3 +155,24 @@
padding-left: 98px;
}
}
.sidebar .sourceItem {
width: 100%;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: default;
}
.sidebar .sourceItem[data-selected] {
background: var(--bg-tree-selected);
}
.sidebar .sourceItem:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.sourcePane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
+76 -51
View File
@@ -5,7 +5,7 @@ import { tx } from "@/platform/i18n";
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { openProject } from "@/platform/project";
import { Checkbox, Dialog, Icon, IconButton, Input, ProgressBar, Select, T, showContextMenu } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { cloneSourceList, notifications, useCloneSources } from "@/workbench/contributions";
import { openDialog } from "./dialogs";
import { progressFraction } from "./model";
import logo from "../../../src-tauri/icons/128x128.png";
@@ -115,7 +115,11 @@ export function showCloneDialog(initialUrl = "") {
}
function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl: string }) {
const [url, setUrl] = useState(initialUrl);
/** Sidebar selection: "url" (Repository URL) or a contributed source's id; each keeps its own URL. */
const [sourceId, setSourceId] = useState("url");
const [urls, setUrls] = useState<Record<string, string>>({ url: initialUrl });
const url = urls[sourceId] ?? "";
const setUrl = (value: string) => setUrls((m) => ({ ...m, [sourceId]: value }));
const [parent, setParent] = useState("");
/** Directory edited by hand: stop deriving it from the URL. */
const [customDir, setCustomDir] = useState<string | null>(null);
@@ -127,6 +131,8 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
const request = useRef<number | null>(null);
const urlField = useRef<HTMLDivElement>(null);
const [recentUrls] = useState(readRecentUrls);
const sources = cloneSourceList(useCloneSources((s) => s.sources));
const source = sources.find((s) => s.id === sourceId);
useEffect(() => {
let alive = true;
@@ -168,9 +174,10 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
else onClose();
};
const clone = async () => {
if (invalid || busy) return;
const target = dir.trim();
const clone = async (picked?: string) => {
const cloneUrl = picked ?? url;
const target = (customDir ?? joinPath(parent, cloneDirName(cloneUrl))).trim();
if (validateClone(cloneUrl, target) || (shallow && depth === null) || busy) return;
setBusy(true);
setError(null);
const id = nextRequestId();
@@ -181,9 +188,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, depth, channel));
await call(commands.vcsClone(id, cloneUrl.trim(), target, depth, channel));
writeParent(parentOf(target));
rememberUrl(url.trim());
rememberUrl(cloneUrl.trim());
onClose();
try {
await openProject(target);
@@ -226,12 +233,22 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
>
<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>
{[{ id: "url", label: "Repository URL", icon: "branch" }, ...sources].map((s) => (
<button
key={s.id}
type="button"
className={styles.sourceItem}
data-selected={s.id === sourceId ? "" : undefined}
aria-pressed={s.id === sourceId}
disabled={busy}
onClick={() => setSourceId(s.id)}
>
<Icon name={s.icon} size={22} />
<span className={styles.sourceName}>
<T>{s.label}</T>
</span>
</button>
))}
</aside>
<form
@@ -241,45 +258,53 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
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}
/>
{source ? (
<div className={styles.sourcePane}>
<source.component url={url} onUrl={setUrl} onClone={(u) => void clone(u)} disabled={busy} />
</div>
</div>
) : (
<>
<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">
+9 -1
View File
@@ -5804,5 +5804,13 @@
"Pin": "固定",
"Unpin": "取消固定",
"Project default": "项目默认",
"The node executable this script runs on; empty uses Settings › Languages & Frameworks › Node.js.": "运行此脚本的 node 可执行文件;留空则使用“设置 › 语言和框架 › Node.js”中的解释器。"
"The node executable this script runs on; empty uses Settings › Languages & Frameworks › Node.js.": "运行此脚本的 node 可执行文件;留空则使用“设置 › 语言和框架 › Node.js”中的解释器。",
"Loading repositories…": "正在加载仓库…",
"GitHub repositories are listed with the": "GitHub 仓库列表需要",
"Search repositories": "搜索仓库",
"Repositories": "仓库",
"No matching repositories": "没有匹配的仓库",
"No repositories": "没有仓库",
"fork": "复刻",
"archived": "已归档"
}
+4
View File
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#161514" fill-rule="evenodd" d="M7.99707713,1 C4.13339149,1 1,4.1329619 1,7.9979363 C1,11.0896581 3.00487223,13.7122732 5.78557461,14.6375989 C6.13568621,14.7020366 6.2632729,14.4859554 6.2632729,14.3003748 C6.2632729,14.1345551 6.25725871,13.6942307 6.25382203,13.1104249 C4.30737333,13.5331364 3.89669027,12.1722117 3.89669027,12.1722117 C3.57836793,11.3637332 3.11957137,11.1485112 3.11957137,11.1485112 C2.48421546,10.7146305 3.16768487,10.7232222 3.16768487,10.7232222 C3.87005601,10.7726245 4.23949893,11.4444951 4.23949893,11.4444951 C4.86368564,12.5137317 5.87750575,12.2048602 6.27616044,12.0257233 C6.33973899,11.5738001 6.52059419,11.2653582 6.72035112,11.0905172 C5.16654292,10.9139579 3.53283195,10.3133983 3.53283195,7.63193005 C3.53283195,6.86812829 3.80561829,6.24308241 4.25324565,5.75421492 C4.1810754,5.57722598 3.9409375,4.86540398 4.32197921,3.90227487 C4.32197921,3.90227487 4.90922163,3.71411673 6.24608951,4.61968148 C6.80412015,4.46417178 7.40296136,4.38684652 7.9979363,4.38383942 C8.59248165,4.38684652 9.19089327,4.46417178 9.74978309,4.61968148 C11.0857918,3.71411673 11.672175,3.90227487 11.672175,3.90227487 C12.0540759,4.86540398 11.813938,5.57722598 11.7421974,5.75421492 C12.1906839,6.24308241 12.4613223,6.86812829 12.4613223,7.63193005 C12.4613223,10.3202717 10.8250338,10.91181 9.26650019,11.0849326 C9.51737771,11.3010138 9.74119139,11.7280211 9.74119139,12.38099 C9.74119139,13.316196 9.7325997,14.0709765 9.7325997,14.3003748 C9.7325997,14.4876738 9.85889763,14.7054733 10.2137347,14.6371693 C12.9922891,13.7096957 14.995443,11.0887989 14.995443,7.9979363 C14.995443,4.1329619 11.8620515,1 7.99707713,1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+4
View File
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#FFFFFE" fill-rule="evenodd" d="M7.99707713,1 C4.13339149,1 1,4.1329619 1,7.9979363 C1,11.0896581 3.00487223,13.7122732 5.78557461,14.6375989 C6.13568621,14.7020366 6.2632729,14.4859554 6.2632729,14.3003748 C6.2632729,14.1341255 6.25725871,13.6942307 6.25382203,13.1104249 C4.30737333,13.5331364 3.89669027,12.1722117 3.89669027,12.1722117 C3.57836793,11.3637332 3.11957137,11.1485112 3.11957137,11.1485112 C2.48421546,10.7146305 3.16768487,10.7232222 3.16768487,10.7232222 C3.87005601,10.7726245 4.23949893,11.4444951 4.23949893,11.4444951 C4.86368564,12.5137317 5.87750575,12.2048602 6.27616044,12.0257233 C6.33973899,11.5738001 6.52059419,11.2653582 6.72035112,11.0905172 C5.16654292,10.9139579 3.53283195,10.3133983 3.53283195,7.63193005 C3.53283195,6.86812829 3.80561829,6.24308241 4.25324565,5.75421492 C4.1810754,5.57722598 3.9409375,4.86540398 4.32197921,3.90227487 C4.32197921,3.90227487 4.90922163,3.71411673 6.24608951,4.61968148 C6.80412015,4.46417178 7.40296136,4.38684652 7.9979363,4.38383942 C8.59248165,4.38684652 9.19089327,4.46417178 9.74978309,4.61968148 C11.0857918,3.71411673 11.672175,3.90227487 11.672175,3.90227487 C12.0540759,4.86540398 11.813938,5.57722598 11.7421974,5.75421492 C12.1906839,6.24308241 12.4613223,6.86812829 12.4613223,7.63193005 C12.4613223,10.3202717 10.8250338,10.91181 9.26650019,11.0849326 C9.51737771,11.3010138 9.74119139,11.7280211 9.74119139,12.3805604 C9.74119139,13.316196 9.7325997,14.0709765 9.7325997,14.3003748 C9.7325997,14.4876738 9.85889763,14.7054733 10.2137347,14.6371693 C12.9922891,13.7096957 14.995443,11.0887989 14.995443,7.9979363 C14.995443,4.1329619 11.8620515,1 7.99707713,1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -435,6 +435,7 @@ const ICONS = {
// Code review / GitHub pull requests (collaboration-tools, GitHub plugin, DVCS)
"branch-label": "vcs/branchLabel",
"current-branch-label": "vcs/currentBranchLabel",
github: "vcs/vendors/github",
"github-pr-open": "github/pullRequestOpen",
"github-pr-closed": "github/pullRequestClosed",
"github-pr-draft": "github/pullRequestDraft",
+43
View File
@@ -1451,3 +1451,46 @@ export const useDebugRunSessions = create<{
serviceSessions: DebugServiceSession[];
Console: ComponentType<{ sessionId: number }> | null;
}>(() => ({ sessions: [], serviceSessions: [], Console: null }));
// ---------------------------------------------------------------------------
// "Get from Version Control" sources besides a plain URL (IDEA: GitHub, GitLab…),
// listed in the Clone Repository dialog's sidebar.
export interface CloneSourceProps {
/** The repository URL the dialog clones. */
url: string;
/** Picks a repository: the dialog clones `url` (and derives its directory from it). */
onUrl(url: string): void;
/** Picks a repository and clones it right away (double-click, Enter). */
onClone(url: string): void;
disabled: boolean;
}
export interface CloneSourceContribution {
id: string;
label: string;
icon: string;
/** Lower first; "Repository URL" always comes first. */
order?: number;
component: ComponentType<CloneSourceProps>;
}
export const useCloneSources = create<{ sources: ReadonlyMap<string, CloneSourceContribution> }>(() => ({
sources: new Map(),
}));
export function registerCloneSource(source: CloneSourceContribution): () => void {
useCloneSources.setState((s) => ({ sources: new Map(s.sources).set(source.id, source) }));
return () =>
useCloneSources.setState((s) => {
if (s.sources.get(source.id) !== source) return s;
const sources = new Map(s.sources);
sources.delete(source.id);
return { sources };
});
}
/** Registered clone sources in order. */
export function cloneSourceList(sources = useCloneSources.getState().sources): CloneSourceContribution[] {
return [...sources.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
}