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

+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">