GitHub workflows (#428)

Fixes #348 
Fixes #274 
Fixes #149 

- Connect to existing repos
- Push to other branches on GitHub besides main
- Allows force push (with confirmation) dialog

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Will Chen
2025-06-17 16:59:26 -07:00
committed by GitHub
parent 9694e4a2e8
commit bd809a010d
24 changed files with 2686 additions and 237 deletions

View File

@@ -1,19 +1,281 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Github, Clipboard, Check } from "lucide-react";
import {
Github,
Clipboard,
Check,
AlertTriangle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useLoadApp } from "@/hooks/useLoadApp";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface GitHubConnectorProps {
appId: number | null;
folderName: string;
}
export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
interface GitHubRepo {
name: string;
full_name: string;
private: boolean;
}
interface GitHubBranch {
name: string;
commit: { sha: string };
}
interface ConnectedGitHubConnectorProps {
appId: number;
app: any;
refreshApp: () => void;
}
interface UnconnectedGitHubConnectorProps {
appId: number | null;
folderName: string;
settings: any;
refreshSettings: () => void;
refreshApp: () => void;
}
function ConnectedGitHubConnector({
appId,
app,
refreshApp,
}: ConnectedGitHubConnectorProps) {
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
const [showForceDialog, setShowForceDialog] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const handleDisconnectRepo = async () => {
setIsDisconnecting(true);
setDisconnectError(null);
try {
await IpcClient.getInstance().disconnectGithubRepo(appId);
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
} finally {
setIsDisconnecting(false);
}
};
const handleSyncToGithub = async (force: boolean = false) => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
setShowForceDialog(false);
try {
const result = await IpcClient.getInstance().syncGithubRepo(appId, force);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
// If it's a push rejection error, show the force dialog
if (
result.error?.includes("rejected") ||
result.error?.includes("non-fast-forward")
) {
// Don't show force dialog immediately, let user see the error first
}
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
} finally {
setIsSyncing(false);
}
};
return (
<div
className="mt-4 w-full border border-gray-200 rounded-md p-4"
data-testid="github-connected-repo"
>
<p>Connected to GitHub Repo:</p>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://github.com/${app.githubOrg}/${app.githubRepo}`,
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{app.githubOrg}/{app.githubRepo}
</a>
{app.githubBranch && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
Branch: <span className="font-mono">{app.githubBranch}</span>
</p>
)}
<div className="mt-2 flex gap-2">
<Button onClick={() => handleSyncToGithub(false)} disabled={isSyncing}>
{isSyncing ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Syncing...
</>
) : (
"Sync to GitHub"
)}
</Button>
<Button
onClick={handleDisconnectRepo}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
</Button>
</div>
{syncError && (
<div className="mt-2">
<p className="text-red-600">
{syncError}{" "}
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
See troubleshooting guide
</a>
</p>
{(syncError.includes("rejected") ||
syncError.includes("non-fast-forward")) && (
<Button
onClick={() => setShowForceDialog(true)}
variant="outline"
size="sm"
className="mt-2 text-orange-600 border-orange-600 hover:bg-orange-50"
>
<AlertTriangle className="h-4 w-4 mr-2" />
Force Push (Dangerous)
</Button>
)}
</div>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
{/* Force Push Warning Dialog */}
<Dialog open={showForceDialog} onOpenChange={setShowForceDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
Force Push Warning
</DialogTitle>
<DialogDescription>
<div className="space-y-3">
<p>
You are about to perform a <strong>force push</strong> to your
GitHub repository.
</p>
<div className="bg-orange-50 dark:bg-orange-900/20 p-3 rounded-md border border-orange-200 dark:border-orange-800">
<p className="text-sm text-orange-800 dark:text-orange-200">
<strong>
This is dangerous and non-reversible and will:
</strong>
</p>
<ul className="text-sm text-orange-700 dark:text-orange-300 list-disc list-inside mt-2 space-y-1">
<li>Overwrite the remote repository history</li>
<li>
Permanently delete commits that exist on the remote but
not locally
</li>
</ul>
</div>
<p className="text-sm">
Only proceed if you're certain this is what you want to do.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForceDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => handleSyncToGithub(true)}
disabled={isSyncing}
>
{isSyncing ? "Force Pushing..." : "Force Push"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function UnconnectedGitHubConnector({
appId,
folderName,
settings,
refreshSettings,
refreshApp,
}: UnconnectedGitHubConnectorProps) {
// --- Collapsible State ---
const [isExpanded, setIsExpanded] = useState(false);
// --- GitHub Device Flow State ---
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
const [githubVerificationUri, setGithubVerificationUri] = useState<
string | null
@@ -24,7 +286,37 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
null,
);
const [codeCopied, setCodeCopied] = useState(false);
// --- ---
// --- Repo Setup State ---
const [repoSetupMode, setRepoSetupMode] = useState<"create" | "existing">(
"create",
);
const [availableRepos, setAvailableRepos] = useState<GitHubRepo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [availableBranches, setAvailableBranches] = useState<GitHubBranch[]>(
[],
);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [selectedBranch, setSelectedBranch] = useState<string>("main");
const [branchInputMode, setBranchInputMode] = useState<"select" | "custom">(
"select",
);
const [customBranchName, setCustomBranchName] = useState<string>("");
// Create new repo state
const [repoName, setRepoName] = useState(folderName);
const [repoAvailable, setRepoAvailable] = useState<boolean | null>(null);
const [repoCheckError, setRepoCheckError] = useState<string | null>(null);
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
const [createRepoError, setCreateRepoError] = useState<string | null>(null);
const [createRepoSuccess, setCreateRepoSuccess] = useState<boolean>(false);
// Assume org is the authenticated user for now (could add org input later)
const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user)
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleConnectToGithub = async () => {
if (!appId) return;
@@ -78,7 +370,7 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
setGithubError(null);
setIsConnectingToGithub(false);
refreshSettings();
// TODO: Maybe update parent UI to show "Connected" state or trigger next action
setIsExpanded(true);
});
cleanupFunctions.push(removeSuccessListener);
@@ -98,9 +390,6 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
// Cleanup function to remove all listeners when component unmounts or appId changes
return () => {
cleanupFunctions.forEach((cleanup) => cleanup());
// Optional: Send a message to main process to cancel polling if component unmounts
// Only cancel if we were actually connecting for this specific appId
// IpcClient.getInstance().cancelGithubDeviceFlow(appId);
// Reset state when appId changes or component unmounts
setGithubUserCode(null);
setGithubVerificationUri(null);
@@ -110,23 +399,58 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
};
}, [appId]); // Re-run effect if appId changes
// --- Create Repo State ---
const [repoName, setRepoName] = useState(folderName);
const [repoAvailable, setRepoAvailable] = useState<boolean | null>(null);
const [repoCheckError, setRepoCheckError] = useState<string | null>(null);
const [isCheckingRepo, setIsCheckingRepo] = useState(false);
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
const [createRepoError, setCreateRepoError] = useState<string | null>(null);
const [createRepoSuccess, setCreateRepoSuccess] = useState<boolean>(false);
// --- Sync to GitHub State ---
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
// Assume org is the authenticated user for now (could add org input later)
// TODO: After device flow, fetch and store the GitHub username/org in settings for use here
const githubOrg = ""; // Use empty string for now (GitHub API will default to the authenticated user)
// Load available repos when GitHub is connected
useEffect(() => {
if (settings?.githubAccessToken && repoSetupMode === "existing") {
loadAvailableRepos();
}
}, [settings?.githubAccessToken, repoSetupMode]);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const loadAvailableRepos = async () => {
setIsLoadingRepos(true);
try {
const repos = await IpcClient.getInstance().listGithubRepos();
setAvailableRepos(repos);
} catch (error) {
console.error("Failed to load GitHub repos:", error);
} finally {
setIsLoadingRepos(false);
}
};
// Load branches when a repo is selected
useEffect(() => {
if (selectedRepo && repoSetupMode === "existing") {
loadRepoBranches();
}
}, [selectedRepo, repoSetupMode]);
const loadRepoBranches = async () => {
if (!selectedRepo) return;
setIsLoadingBranches(true);
setBranchInputMode("select"); // Reset to select mode when loading new repo
setCustomBranchName(""); // Clear custom branch name
try {
const [owner, repo] = selectedRepo.split("/");
const branches = await IpcClient.getInstance().getGithubRepoBranches(
owner,
repo,
);
setAvailableBranches(branches);
// Default to main if available, otherwise first branch
const defaultBranch =
branches.find((b) => b.name === "main" || b.name === "master") ||
branches[0];
if (defaultBranch) {
setSelectedBranch(defaultBranch.name);
}
} catch (error) {
console.error("Failed to load repo branches:", error);
} finally {
setIsLoadingBranches(false);
}
};
const checkRepoAvailability = useCallback(
async (name: string) => {
@@ -166,48 +490,49 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
[checkRepoAvailability],
);
const handleCreateRepo = async (e: React.FormEvent) => {
const handleSetupRepo = async (e: React.FormEvent) => {
e.preventDefault();
if (!appId) return;
setCreateRepoError(null);
setIsCreatingRepo(true);
setCreateRepoSuccess(false);
try {
await IpcClient.getInstance().createGithubRepo(
githubOrg,
repoName,
appId!,
);
if (repoSetupMode === "create") {
await IpcClient.getInstance().createGithubRepo(
githubOrg,
repoName,
appId,
selectedBranch,
);
} else {
const [owner, repo] = selectedRepo.split("/");
const branchToUse =
branchInputMode === "custom" ? customBranchName : selectedBranch;
await IpcClient.getInstance().connectToExistingGithubRepo(
owner,
repo,
branchToUse,
appId,
);
}
setCreateRepoSuccess(true);
setRepoCheckError(null);
refreshApp();
} catch (err: any) {
setCreateRepoError(err.message || "Failed to create repository.");
setCreateRepoError(
err.message ||
`Failed to ${repoSetupMode === "create" ? "create" : "connect to"} repository.`,
);
} finally {
setIsCreatingRepo(false);
}
};
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const handleDisconnectRepo = async () => {
if (!appId) return;
setIsDisconnecting(true);
setDisconnectError(null);
try {
await IpcClient.getInstance().disconnectGithubRepo(appId);
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
} finally {
setIsDisconnecting(false);
}
};
if (!settings?.githubAccessToken) {
return (
<div className="mt-1 w-full">
{" "}
<div className="mt-1 w-full" data-testid="github-unconnected-repo">
<Button
onClick={handleConnectToGithub}
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
@@ -310,149 +635,271 @@ export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
);
}
if (app?.githubOrg && app?.githubRepo) {
const handleSyncToGithub = async () => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
try {
const result = await IpcClient.getInstance().syncGithubRepo(appId!);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
} finally {
setIsSyncing(false);
}
};
return (
<div
className="mt-4 w-full border border-gray-200 rounded-md"
data-testid="github-setup-repo"
>
{/* Collapsible Header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={`cursor-pointer w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
!isExpanded ? "hover:bg-gray-50 dark:hover:bg-gray-800/50" : ""
}`}
>
<span className="font-medium">Set up your GitHub repo</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
return (
<div className="mt-4 w-full border border-gray-200 rounded-md p-4">
<p>Connected to GitHub Repo:</p>
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
`https://github.com/${app.githubOrg}/${app.githubRepo}`,
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
>
{app.githubOrg}/{app.githubRepo}
</a>
<div className="mt-2 flex gap-2">
<Button onClick={handleSyncToGithub} disabled={isSyncing}>
{isSyncing ? (
{/* Collapsible Content */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isExpanded ? "max-h-[800px] opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="p-4 pt-0 space-y-4">
{/* Mode Selection */}
<div>
<div className="flex rounded-md border border-gray-200 dark:border-gray-700">
<Button
type="button"
variant={repoSetupMode === "create" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-l-md border-0 ${
repoSetupMode === "create"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("create");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Create new repo
</Button>
<Button
type="button"
variant={repoSetupMode === "existing" ? "default" : "ghost"}
className={`flex-1 rounded-none rounded-r-md border-0 border-l border-gray-200 dark:border-gray-700 ${
repoSetupMode === "existing"
? "bg-primary text-primary-foreground"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => {
setRepoSetupMode("existing");
setCreateRepoError(null);
setCreateRepoSuccess(false);
}}
>
Connect to existing repo
</Button>
</div>
</div>
<form className="space-y-4" onSubmit={handleSetupRepo}>
{repoSetupMode === "create" ? (
<>
<svg
className="animate-spin h-5 w-5 mr-2 inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ display: "inline" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Syncing...
<div>
<Label className="block text-sm font-medium">
Repository Name
</Label>
<Input
data-testid="github-create-repo-name-input"
className="w-full mt-1"
value={repoName}
onChange={(e) => {
const newValue = e.target.value;
setRepoName(newValue);
setRepoAvailable(null);
setRepoCheckError(null);
debouncedCheckRepoAvailability(newValue);
}}
disabled={isCreatingRepo}
/>
{isCheckingRepo && (
<p className="text-xs text-gray-500 mt-1">
Checking availability...
</p>
)}
{repoAvailable === true && (
<p className="text-xs text-green-600 mt-1">
Repository name is available!
</p>
)}
{repoAvailable === false && (
<p className="text-xs text-red-600 mt-1">
{repoCheckError}
</p>
)}
</div>
</>
) : (
"Sync to GitHub"
<>
<div>
<Label className="block text-sm font-medium">
Select Repository
</Label>
<Select
value={selectedRepo}
onValueChange={setSelectedRepo}
disabled={isLoadingRepos}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-repo-select"
>
<SelectValue
placeholder={
isLoadingRepos
? "Loading repositories..."
: "Select a repository"
}
/>
</SelectTrigger>
<SelectContent>
{availableRepos.map((repo) => (
<SelectItem key={repo.full_name} value={repo.full_name}>
{repo.full_name} {repo.private && "(private)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</Button>
<Button
onClick={handleDisconnectRepo}
disabled={isDisconnecting}
variant="outline"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from repo"}
</Button>
</div>
{syncError && (
<p className="text-red-600 mt-2">
{syncError}{" "}
<a
onClick={(e) => {
e.preventDefault();
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/integrations/github#troubleshooting",
);
}}
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noopener noreferrer"
{/* Branch Selection */}
<div>
<Label className="block text-sm font-medium">Branch</Label>
{repoSetupMode === "existing" && selectedRepo ? (
<div className="space-y-2">
<Select
value={
branchInputMode === "select" ? selectedBranch : "custom"
}
onValueChange={(value) => {
if (value === "custom") {
setBranchInputMode("custom");
setCustomBranchName("");
} else {
setBranchInputMode("select");
setSelectedBranch(value);
}
}}
disabled={isLoadingBranches}
>
<SelectTrigger
className="w-full mt-1"
data-testid="github-branch-select"
>
<SelectValue
placeholder={
isLoadingBranches
? "Loading branches..."
: "Select a branch"
}
/>
</SelectTrigger>
<SelectContent>
{availableBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
<SelectItem value="custom">
<span className="font-medium">
Type custom branch name
</span>
</SelectItem>
</SelectContent>
</Select>
{branchInputMode === "custom" && (
<Input
data-testid="github-custom-branch-input"
className="w-full"
value={customBranchName}
onChange={(e) => setCustomBranchName(e.target.value)}
placeholder="Enter branch name (e.g., feature/new-feature)"
disabled={isCreatingRepo}
/>
)}
</div>
) : (
<Input
className="w-full mt-1"
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
placeholder="main"
disabled={isCreatingRepo}
data-testid="github-new-repo-branch-input"
/>
)}
</div>
<Button
type="submit"
disabled={
isCreatingRepo ||
(repoSetupMode === "create" &&
(repoAvailable === false || !repoName)) ||
(repoSetupMode === "existing" &&
(!selectedRepo ||
!selectedBranch ||
(branchInputMode === "custom" && !customBranchName.trim())))
}
>
See troubleshooting guide
</a>
</p>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
{disconnectError && (
<p className="text-red-600 mt-2">{disconnectError}</p>
)}
{isCreatingRepo
? repoSetupMode === "create"
? "Creating..."
: "Connecting..."
: repoSetupMode === "create"
? "Create Repo"
: "Connect to Repo"}
</Button>
</form>
{createRepoError && (
<p className="text-red-600 mt-2">{createRepoError}</p>
)}
{createRepoSuccess && (
<p className="text-green-600 mt-2">
{repoSetupMode === "create"
? "Repository created and linked!"
: "Connected to repository!"}
</p>
)}
</div>
</div>
</div>
);
}
export function GitHubConnector({ appId, folderName }: GitHubConnectorProps) {
const { app, refreshApp } = useLoadApp(appId);
const { settings, refreshSettings } = useSettings();
if (app?.githubOrg && app?.githubRepo && appId) {
return (
<ConnectedGitHubConnector
appId={appId}
app={app}
refreshApp={refreshApp}
/>
);
} else {
return (
<div className="mt-4 w-full border border-gray-200 rounded-md p-4">
<p>Set up your GitHub repo</p>
<form className="mt-4 space-y-2" onSubmit={handleCreateRepo}>
<label className="block text-sm font-medium">Repository Name</label>
<input
className="w-full border rounded px-2 py-1"
value={repoName}
onChange={(e) => {
const newValue = e.target.value;
setRepoName(newValue);
setRepoAvailable(null);
setRepoCheckError(null);
debouncedCheckRepoAvailability(newValue);
}}
disabled={isCreatingRepo}
/>
{isCheckingRepo && (
<p className="text-xs text-gray-500">Checking availability...</p>
)}
{repoAvailable === true && (
<p className="text-xs text-green-600">
Repository name is available!
</p>
)}
{repoAvailable === false && (
<p className="text-xs text-red-600">{repoCheckError}</p>
)}
<Button
type="submit"
disabled={isCreatingRepo || repoAvailable === false || !repoName}
>
{isCreatingRepo ? "Creating..." : "Create Repo"}
</Button>
</form>
{createRepoError && (
<p className="text-red-600 mt-2">{createRepoError}</p>
)}
{createRepoSuccess && (
<p className="text-green-600 mt-2">Repository created and linked!</p>
)}
</div>
<UnconnectedGitHubConnector
appId={appId}
folderName={folderName}
settings={settings}
refreshSettings={refreshSettings}
refreshApp={refreshApp}
/>
);
}
}