feat(github): live repo search with /user/repos pagination and connection gear UI
- searchGithubRepositories action fetches up to 300 repos via paginated /user/repos, filters by name, upserts results into gitRepositories so createProjectFromRepository works unchanged; token never leaves backend - connectGithub now persists grantedScopesJson from getAccessToken().scopes - listProviderAccounts and list queries return grantedScopesJson - Gear/settings popover on connected GitHub chip shows scopes and triggers linkSocial with expanded scope set [repo, read:org, read:user, user:email] - RepositorySelector merges debounced live search results with synced snapshot - Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -19,6 +20,9 @@ export const AddProjectPanel = ({
|
||||
}) => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const githubConnectionId = accounts?.find(
|
||||
(account) => account.provider === "github" && account.status === "active"
|
||||
)?.id as Id<"gitConnections"> | undefined;
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
dialog?.showModal();
|
||||
@@ -59,6 +63,7 @@ export const AddProjectPanel = ({
|
||||
|
||||
<RepositorySelector
|
||||
existingSourceUrls={existingSourceUrls}
|
||||
githubConnectionId={githubConnectionId}
|
||||
onProjectCreated={onProjectCreated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Expanded scope set for repository + organization visibility. */
|
||||
@@ -155,9 +150,9 @@ export const GithubConnectionSettings = ({
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs leading-5 text-[#68665e]">
|
||||
Reauthorization refreshes GitHub scopes and re-syncs your
|
||||
accessible repositories, including organization repos GitHub
|
||||
returns for your account.
|
||||
Reauthorization refreshes GitHub scopes and re-syncs your accessible
|
||||
repositories, including organization repos GitHub returns for your
|
||||
account.
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
|
||||
|
||||
@@ -126,7 +126,11 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
|
||||
};
|
||||
|
||||
const accountsLoading = accounts === undefined;
|
||||
const githubConnected =
|
||||
githubAccount !== undefined && isHealthy(githubAccount.status);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ProviderChip
|
||||
|
||||
@@ -4,17 +4,20 @@ import { useMutation, useQuery } from "convex/react";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
LoaderCircle,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
LoaderCircle,
|
||||
Lock,
|
||||
Search,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
|
||||
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
|
||||
import { useRepositoryPicker } from "@/hooks/use-repository-picker";
|
||||
import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
|
||||
|
||||
@@ -42,9 +45,11 @@ const providerLabels = {
|
||||
|
||||
export const RepositorySelector = ({
|
||||
existingSourceUrls,
|
||||
githubConnectionId,
|
||||
onProjectCreated,
|
||||
}: {
|
||||
readonly existingSourceUrls: readonly string[];
|
||||
readonly githubConnectionId: Id<"gitConnections"> | undefined;
|
||||
readonly onProjectCreated: (projectId: string) => void;
|
||||
}) => {
|
||||
const repositories = useQuery(listRepositoriesRef, {});
|
||||
@@ -64,6 +69,56 @@ export const RepositorySelector = ({
|
||||
repositories,
|
||||
});
|
||||
|
||||
// Live GitHub search: active when GitHub provider is selectable (filter is
|
||||
// "all" or "github") and a GitHub connection exists. The search action uses
|
||||
// the server-side encrypted credential — the token never reaches the browser.
|
||||
const githubSearchEnabled =
|
||||
githubConnectionId !== undefined &&
|
||||
(providerFilter === "all" || providerFilter === "github");
|
||||
const githubSearch = useGithubRepoSearch({
|
||||
connectionId: githubConnectionId,
|
||||
enabled: githubSearchEnabled,
|
||||
query: search,
|
||||
});
|
||||
|
||||
// Merge live search results with the synced snapshot. Live results take
|
||||
// priority by webUrl to avoid duplicate rows.
|
||||
const liveResults = useMemo<readonly GithubRepositorySearchResult[]>(() => {
|
||||
if (githubSearch.kind !== "results") {
|
||||
return [];
|
||||
}
|
||||
return githubSearch.results;
|
||||
}, [githubSearch]);
|
||||
|
||||
const displayRepositories = useMemo<readonly GitRepositoryOption[]>(() => {
|
||||
const base = availableRepositories ?? [];
|
||||
if (liveResults.length === 0) {
|
||||
return base;
|
||||
}
|
||||
const existingUrls = new Set(base.map((repo) => repo.webUrl));
|
||||
const merged: GitRepositoryOption[] = [...base];
|
||||
for (const result of liveResults) {
|
||||
if (!result.gitRepositoryId || existingUrls.has(result.webUrl)) {
|
||||
continue;
|
||||
}
|
||||
existingUrls.add(result.webUrl);
|
||||
merged.push({
|
||||
cloneUrl: result.cloneUrl,
|
||||
defaultBranch: result.defaultBranch,
|
||||
fullName: result.fullName,
|
||||
id: result.gitRepositoryId,
|
||||
name: result.name,
|
||||
owner: result.owner,
|
||||
privacy: result.privacy,
|
||||
provider: result.provider,
|
||||
webUrl: result.webUrl,
|
||||
});
|
||||
}
|
||||
return merged.toSorted((left, right) =>
|
||||
left.fullName.localeCompare(right.fullName)
|
||||
);
|
||||
}, [availableRepositories, liveResults]);
|
||||
|
||||
const create = async (repository: GitRepositoryOption) => {
|
||||
if (pending) {
|
||||
return;
|
||||
@@ -191,9 +246,27 @@ export const RepositorySelector = ({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availableRepositories.length > 0 ? (
|
||||
{githubSearch.kind === "loading" ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-[#747168]">
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
Searching GitHub…
|
||||
</p>
|
||||
) : null}
|
||||
{githubSearch.kind === "error" ? (
|
||||
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
|
||||
{githubSearch.message}
|
||||
</p>
|
||||
) : null}
|
||||
{githubSearch.kind === "reauth" ? (
|
||||
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
|
||||
GitHub access needs reauthorization. Use the settings control on the
|
||||
GitHub chip to reconnect.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{displayRepositories.length > 0 ? (
|
||||
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
|
||||
{availableRepositories.map((repository) => {
|
||||
{displayRepositories.map((repository) => {
|
||||
const ProviderIcon =
|
||||
repository.provider === "github" ? GitBranch : Server;
|
||||
const VisibilityIcon =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type {
|
||||
GithubRepositorySearchResult,
|
||||
} from "@code/backend/convex/gitConnections";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -31,7 +30,7 @@ export const useGithubRepoSearch = ({
|
||||
enabled,
|
||||
query,
|
||||
}: {
|
||||
readonly connectionId: string | undefined;
|
||||
readonly connectionId: Id<"gitConnections"> | undefined;
|
||||
readonly enabled: boolean;
|
||||
readonly query: string;
|
||||
}): GithubSearchState => {
|
||||
|
||||
Reference in New Issue
Block a user