944 lines
32 KiB
TypeScript
944 lines
32 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Github,
|
||
Clipboard,
|
||
Check,
|
||
AlertTriangle,
|
||
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;
|
||
expanded?: boolean;
|
||
}
|
||
|
||
interface GitHubRepo {
|
||
name: string;
|
||
full_name: string;
|
||
private: boolean;
|
||
}
|
||
|
||
interface GitHubBranch {
|
||
name: string;
|
||
commit: { sha: string };
|
||
}
|
||
|
||
interface ConnectedGitHubConnectorProps {
|
||
appId: number;
|
||
app: any;
|
||
refreshApp: () => void;
|
||
triggerAutoSync?: boolean;
|
||
onAutoSyncComplete?: () => void;
|
||
}
|
||
|
||
interface UnconnectedGitHubConnectorProps {
|
||
appId: number | null;
|
||
folderName: string;
|
||
settings: any;
|
||
refreshSettings: () => void;
|
||
handleRepoSetupComplete: () => void;
|
||
expanded?: boolean;
|
||
}
|
||
|
||
function ConnectedGitHubConnector({
|
||
appId,
|
||
app,
|
||
refreshApp,
|
||
triggerAutoSync,
|
||
onAutoSyncComplete,
|
||
}: 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 autoSyncTriggeredRef = useRef(false);
|
||
|
||
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 = useCallback(
|
||
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);
|
||
}
|
||
},
|
||
[appId],
|
||
);
|
||
|
||
// Auto-sync when triggerAutoSync prop is true
|
||
useEffect(() => {
|
||
if (triggerAutoSync && !autoSyncTriggeredRef.current) {
|
||
autoSyncTriggeredRef.current = true;
|
||
handleSyncToGithub(false).finally(() => {
|
||
onAutoSyncComplete?.();
|
||
});
|
||
} else if (!triggerAutoSync) {
|
||
// Reset the ref when triggerAutoSync becomes false
|
||
autoSyncTriggeredRef.current = false;
|
||
}
|
||
}, [triggerAutoSync]); // Only depend on triggerAutoSync to avoid unnecessary re-runs
|
||
|
||
return (
|
||
<div className="w-full" 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,
|
||
handleRepoSetupComplete,
|
||
expanded,
|
||
}: UnconnectedGitHubConnectorProps) {
|
||
// --- Collapsible State ---
|
||
const [isExpanded, setIsExpanded] = useState(expanded || false);
|
||
|
||
// --- GitHub Device Flow State ---
|
||
const [githubUserCode, setGithubUserCode] = useState<string | null>(null);
|
||
const [githubVerificationUri, setGithubVerificationUri] = useState<
|
||
string | null
|
||
>(null);
|
||
const [githubError, setGithubError] = useState<string | null>(null);
|
||
const [isConnectingToGithub, setIsConnectingToGithub] = useState(false);
|
||
const [githubStatusMessage, setGithubStatusMessage] = useState<string | null>(
|
||
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;
|
||
setIsConnectingToGithub(true);
|
||
setGithubError(null);
|
||
setGithubUserCode(null);
|
||
setGithubVerificationUri(null);
|
||
setGithubStatusMessage("Requesting device code from GitHub...");
|
||
|
||
// Send IPC message to main process to start the flow
|
||
IpcClient.getInstance().startGithubDeviceFlow(appId);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!appId) return; // Don't set up listeners if appId is null initially
|
||
|
||
const cleanupFunctions: (() => void)[] = [];
|
||
|
||
// Listener for updates (user code, verification uri, status messages)
|
||
const removeUpdateListener =
|
||
IpcClient.getInstance().onGithubDeviceFlowUpdate((data) => {
|
||
console.log("Received github:flow-update", data);
|
||
if (data.userCode) {
|
||
setGithubUserCode(data.userCode);
|
||
}
|
||
if (data.verificationUri) {
|
||
setGithubVerificationUri(data.verificationUri);
|
||
}
|
||
if (data.message) {
|
||
setGithubStatusMessage(data.message);
|
||
}
|
||
|
||
setGithubError(null); // Clear previous errors on new update
|
||
if (!data.userCode && !data.verificationUri && data.message) {
|
||
// Likely just a status message, keep connecting state
|
||
setIsConnectingToGithub(true);
|
||
}
|
||
if (data.userCode && data.verificationUri) {
|
||
setIsConnectingToGithub(true); // Still connecting until success/error
|
||
}
|
||
});
|
||
cleanupFunctions.push(removeUpdateListener);
|
||
|
||
// Listener for success
|
||
const removeSuccessListener =
|
||
IpcClient.getInstance().onGithubDeviceFlowSuccess((data) => {
|
||
console.log("Received github:flow-success", data);
|
||
setGithubStatusMessage("Successfully connected to GitHub!");
|
||
setGithubUserCode(null); // Clear user-facing info
|
||
setGithubVerificationUri(null);
|
||
setGithubError(null);
|
||
setIsConnectingToGithub(false);
|
||
refreshSettings();
|
||
setIsExpanded(true);
|
||
});
|
||
cleanupFunctions.push(removeSuccessListener);
|
||
|
||
// Listener for errors
|
||
const removeErrorListener = IpcClient.getInstance().onGithubDeviceFlowError(
|
||
(data) => {
|
||
console.log("Received github:flow-error", data);
|
||
setGithubError(data.error || "An unknown error occurred.");
|
||
setGithubStatusMessage(null);
|
||
setGithubUserCode(null);
|
||
setGithubVerificationUri(null);
|
||
setIsConnectingToGithub(false);
|
||
},
|
||
);
|
||
cleanupFunctions.push(removeErrorListener);
|
||
|
||
// Cleanup function to remove all listeners when component unmounts or appId changes
|
||
return () => {
|
||
cleanupFunctions.forEach((cleanup) => cleanup());
|
||
// Reset state when appId changes or component unmounts
|
||
setGithubUserCode(null);
|
||
setGithubVerificationUri(null);
|
||
setGithubError(null);
|
||
setIsConnectingToGithub(false);
|
||
setGithubStatusMessage(null);
|
||
};
|
||
}, [appId]); // Re-run effect if appId changes
|
||
|
||
// Load available repos when GitHub is connected
|
||
useEffect(() => {
|
||
if (settings?.githubAccessToken && repoSetupMode === "existing") {
|
||
loadAvailableRepos();
|
||
}
|
||
}, [settings?.githubAccessToken, repoSetupMode]);
|
||
|
||
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) => {
|
||
setRepoCheckError(null);
|
||
setRepoAvailable(null);
|
||
if (!name) return;
|
||
setIsCheckingRepo(true);
|
||
try {
|
||
const result = await IpcClient.getInstance().checkGithubRepoAvailable(
|
||
githubOrg,
|
||
name,
|
||
);
|
||
setRepoAvailable(result.available);
|
||
if (!result.available) {
|
||
setRepoCheckError(
|
||
result.error || "Repository name is not available.",
|
||
);
|
||
}
|
||
} catch (err: any) {
|
||
setRepoCheckError(err.message || "Failed to check repo availability.");
|
||
} finally {
|
||
setIsCheckingRepo(false);
|
||
}
|
||
},
|
||
[githubOrg],
|
||
);
|
||
|
||
const debouncedCheckRepoAvailability = useCallback(
|
||
(name: string) => {
|
||
if (debounceTimeoutRef.current) {
|
||
clearTimeout(debounceTimeoutRef.current);
|
||
}
|
||
debounceTimeoutRef.current = setTimeout(() => {
|
||
checkRepoAvailability(name);
|
||
}, 500);
|
||
},
|
||
[checkRepoAvailability],
|
||
);
|
||
|
||
const handleSetupRepo = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!appId) return;
|
||
|
||
setCreateRepoError(null);
|
||
setIsCreatingRepo(true);
|
||
setCreateRepoSuccess(false);
|
||
|
||
try {
|
||
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);
|
||
handleRepoSetupComplete();
|
||
} catch (err: any) {
|
||
setCreateRepoError(
|
||
err.message ||
|
||
`Failed to ${repoSetupMode === "create" ? "create" : "connect to"} repository.`,
|
||
);
|
||
} finally {
|
||
setIsCreatingRepo(false);
|
||
}
|
||
};
|
||
|
||
if (!settings?.githubAccessToken) {
|
||
return (
|
||
<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"
|
||
size="lg"
|
||
variant="outline"
|
||
disabled={isConnectingToGithub || !appId} // Also disable if appId is null
|
||
>
|
||
Connect to GitHub
|
||
<Github className="h-5 w-5" />
|
||
{isConnectingToGithub && (
|
||
<svg
|
||
className="animate-spin h-5 w-5 ml-2"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<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>
|
||
)}
|
||
</Button>
|
||
{/* GitHub Connection Status/Instructions */}
|
||
{(githubUserCode || githubStatusMessage || githubError) && (
|
||
<div className="mt-6 p-4 border rounded-md bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600">
|
||
<h4 className="font-medium mb-2">GitHub Connection</h4>
|
||
{githubError && (
|
||
<p className="text-red-600 dark:text-red-400 mb-2">
|
||
Error: {githubError}
|
||
</p>
|
||
)}
|
||
{githubUserCode && githubVerificationUri && (
|
||
<div className="mb-2">
|
||
<p>
|
||
1. Go to:
|
||
<a
|
||
href={githubVerificationUri} // Make it a direct link
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
IpcClient.getInstance().openExternalUrl(
|
||
githubVerificationUri,
|
||
);
|
||
}}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="ml-1 text-blue-600 hover:underline dark:text-blue-400"
|
||
>
|
||
{githubVerificationUri}
|
||
</a>
|
||
</p>
|
||
<p>
|
||
2. Enter code:
|
||
<strong className="ml-1 font-mono text-lg tracking-wider bg-gray-200 dark:bg-gray-600 px-2 py-0.5 rounded">
|
||
{githubUserCode}
|
||
</strong>
|
||
<button
|
||
className="ml-2 p-1 rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 focus:outline-none"
|
||
onClick={() => {
|
||
if (githubUserCode) {
|
||
navigator.clipboard
|
||
.writeText(githubUserCode)
|
||
.then(() => {
|
||
setCodeCopied(true);
|
||
setTimeout(() => setCodeCopied(false), 2000);
|
||
})
|
||
.catch((err) =>
|
||
console.error("Failed to copy code:", err),
|
||
);
|
||
}
|
||
}}
|
||
title="Copy to clipboard"
|
||
>
|
||
{codeCopied ? (
|
||
<Check className="h-4 w-4 text-green-500" />
|
||
) : (
|
||
<Clipboard className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
</p>
|
||
</div>
|
||
)}
|
||
{githubStatusMessage && (
|
||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||
{githubStatusMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="w-full" data-testid="github-setup-repo">
|
||
{/* Collapsible Header */}
|
||
<button
|
||
type="button"
|
||
onClick={!isExpanded ? () => setIsExpanded(true) : undefined}
|
||
className={`w-full p-4 text-left transition-colors rounded-md flex items-center justify-between ${
|
||
!isExpanded
|
||
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||
: ""
|
||
}`}
|
||
>
|
||
<span className="font-medium">Set up your GitHub repo</span>
|
||
{isExpanded ? undefined : (
|
||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||
)}
|
||
</button>
|
||
|
||
{/* 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" ? (
|
||
<>
|
||
<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>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
{/* 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())))
|
||
}
|
||
>
|
||
{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,
|
||
expanded,
|
||
}: GitHubConnectorProps) {
|
||
const { app, refreshApp } = useLoadApp(appId);
|
||
const { settings, refreshSettings } = useSettings();
|
||
const [pendingAutoSync, setPendingAutoSync] = useState(false);
|
||
|
||
const handleRepoSetupComplete = useCallback(() => {
|
||
setPendingAutoSync(true);
|
||
refreshApp();
|
||
}, [refreshApp]);
|
||
|
||
const handleAutoSyncComplete = useCallback(() => {
|
||
setPendingAutoSync(false);
|
||
}, []);
|
||
|
||
if (app?.githubOrg && app?.githubRepo && appId) {
|
||
return (
|
||
<ConnectedGitHubConnector
|
||
appId={appId}
|
||
app={app}
|
||
refreshApp={refreshApp}
|
||
triggerAutoSync={pendingAutoSync}
|
||
onAutoSyncComplete={handleAutoSyncComplete}
|
||
/>
|
||
);
|
||
} else {
|
||
return (
|
||
<UnconnectedGitHubConnector
|
||
appId={appId}
|
||
folderName={folderName}
|
||
settings={settings}
|
||
refreshSettings={refreshSettings}
|
||
handleRepoSetupComplete={handleRepoSetupComplete}
|
||
expanded={expanded}
|
||
/>
|
||
);
|
||
}
|
||
}
|