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 authored and GitHub committed 2026-11-04 08:40:06 +08:00
1 parent 9988ce4113
commit a3bbbf90a4
16 files changed
+668 -55

No files matched your search

@@ -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",