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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 08:40:06 +08:00
1 джерело 9988ce4113
коміт a3bbbf90a4
16 files changed
+668 -55

No files matched your search

+21
Переглянути файл
@@ -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
+75
Переглянути файл
@@ -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);
}
+67
Переглянути файл
@@ -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());
});
});
+176
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 1.8 KiB

+4
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 1.8 KiB

+1
Переглянути файл
@@ -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
Переглянути файл
@@ -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));
}