diff --git a/crates/ide-github/src/account.rs b/crates/ide-github/src/account.rs new file mode 100644 index 00000000..5cf5fe88 --- /dev/null +++ b/crates/ide-github/src/account.rs @@ -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, + 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, + }, + /// 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, + #[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, serde_json::Error> { + let pages: Vec> = 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 { + 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()); + } +} diff --git a/crates/ide-github/src/lib.rs b/crates/ide-github/src/lib.rs index 77d27131..72049083 100644 --- a/crates/ide-github/src/lib.rs +++ b/crates/ide-github/src/lib.rs @@ -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}; diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 951d62e6..3ce0aa77 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -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 { + blocking(|| Ok(ide_github::account_repositories())).await +} + #[tauri::command] #[specta::specta] pub async fn gh_availability( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a5d1e271..176d7e27 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -474,6 +474,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index 6f8c3abb..9e45fe83 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__TAURI_INVOKE("gh_account_repos")), ghAvailability: (repo: string | null) => typedError(__TAURI_INVOKE("gh_availability", { repo })), /** A GitHub GraphQL / REST call through `gh api`; returns the response body. */ ghApi: (repo: string | null, request: GhApiRequest) => typedError(__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 diff --git a/src/features/github/clone/GitHubCloneSource.module.css b/src/features/github/clone/GitHubCloneSource.module.css new file mode 100644 index 00000000..6540447d --- /dev/null +++ b/src/features/github/clone/GitHubCloneSource.module.css @@ -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); +} diff --git a/src/features/github/clone/GitHubCloneSource.test.tsx b/src/features/github/clone/GitHubCloneSource.test.tsx new file mode 100644 index 00000000..b8b6f181 --- /dev/null +++ b/src/features/github/clone/GitHubCloneSource.test.tsx @@ -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()), + isTauri: () => true, + commands: { ghAccountRepos: () => ghAccountRepos() }, +})); + +import type { GhAccountRepo } from "@/bindings"; +import { GitHubCloneSource, matchRepos } from "./GitHubCloneSource"; + +const repo = (nameWithOwner: string, extra: Partial = {}): 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(); + 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( + , + ); + 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( {}} 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()); + }); +}); diff --git a/src/features/github/clone/GitHubCloneSource.tsx b/src/features/github/clone/GitHubCloneSource.tsx new file mode 100644 index 00000000..81844e86 --- /dev/null +++ b/src/features/github/clone/GitHubCloneSource.tsx @@ -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(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 = ( + + ); + + if (!state) + return ( +
+ + + Loading repositories… + +
+ ); + if (state.kind === "notInstalled") + return ( + + + {retry} + + } + > + GitHub repositories are listed with the gh command line tool. Install it, then run{" "} + gh auth login. + + ); + if (state.kind === "notLoggedIn") + return ( + + Run gh auth login in a terminal, then retry. + + ); + if (state.kind === "error") + return ( + + {state.message} + + ); + + const pick = (i: number) => { + const row = rows[i]; + if (row) onUrl(row.repo.cloneUrl); + }; + const onKeyDown = (e: KeyboardEvent) => { + 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 ( +
+
+ + {state.login} + +
+
+ + aria-label="Repositories" + items={rows} + getKey={(r) => r.repo.nameWithOwner} + activeIndex={active} + onActiveChange={pick} + focusable={false} + rowHeight={26} + empty={ + + {state.repos.length ? "No matching repositories" : "No repositories"} + + } + renderItem={(r) => ( + onClone(r.repo.cloneUrl)} + > + + + {r.indices ? : r.repo.nameWithOwner} + + {r.repo.fork && ( + + fork + + )} + {r.repo.archived && ( + + archived + + )} + {r.repo.description && {r.repo.description}} + + )} + /> +
+
+ ); +} diff --git a/src/features/github/index.ts b/src/features/github/index.ts index 387e6b7f..526c0763 100644 --- a/src/features/github/index.ts +++ b/src/features/github/index.ts @@ -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", diff --git a/src/features/vcs/clone.module.css b/src/features/vcs/clone.module.css index f7222250..9bd65dfb 100644 --- a/src/features/vcs/clone.module.css +++ b/src/features/vcs/clone.module.css @@ -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; +} diff --git a/src/features/vcs/clone.tsx b/src/features/vcs/clone.tsx index 7c80230a..cae48f2b 100644 --- a/src/features/vcs/clone.tsx +++ b/src/features/vcs/clone.tsx @@ -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>({ 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(null); @@ -127,6 +131,8 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl: const request = useRef(null); const urlField = useRef(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: >
void; initialUrl: void clone(); }} > -
- - setUrl(e.target.value)} - /> - + {source ? ( +
+ void clone(u)} disabled={busy} />
-
+ ) : ( + <> +
+ + setUrl(e.target.value)} + /> + +
+
+ + )}