mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user