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(null); const [syncSuccess, setSyncSuccess] = useState(false); const [showForceDialog, setShowForceDialog] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); const [disconnectError, setDisconnectError] = useState(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 (

Connected to GitHub Repo:

{ 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} {app.githubBranch && (

Branch: {app.githubBranch}

)}
{syncError && (

{syncError}{" "} { 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

{(syncError.includes("rejected") || syncError.includes("non-fast-forward")) && ( )}
)} {syncSuccess && (

Successfully pushed to GitHub!

)} {disconnectError && (

{disconnectError}

)} {/* Force Push Warning Dialog */} Force Push Warning

You are about to perform a force push to your GitHub repository.

This is dangerous and non-reversible and will:

  • Overwrite the remote repository history
  • Permanently delete commits that exist on the remote but not locally

Only proceed if you're certain this is what you want to do.

); } 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(null); const [githubVerificationUri, setGithubVerificationUri] = useState< string | null >(null); const [githubError, setGithubError] = useState(null); const [isConnectingToGithub, setIsConnectingToGithub] = useState(false); const [githubStatusMessage, setGithubStatusMessage] = useState( null, ); const [codeCopied, setCodeCopied] = useState(false); // --- Repo Setup State --- const [repoSetupMode, setRepoSetupMode] = useState<"create" | "existing">( "create", ); const [availableRepos, setAvailableRepos] = useState([]); const [isLoadingRepos, setIsLoadingRepos] = useState(false); const [selectedRepo, setSelectedRepo] = useState(""); const [availableBranches, setAvailableBranches] = useState( [], ); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [selectedBranch, setSelectedBranch] = useState("main"); const [branchInputMode, setBranchInputMode] = useState<"select" | "custom">( "select", ); const [customBranchName, setCustomBranchName] = useState(""); // Create new repo state const [repoName, setRepoName] = useState(folderName); const [repoAvailable, setRepoAvailable] = useState(null); const [repoCheckError, setRepoCheckError] = useState(null); const [isCheckingRepo, setIsCheckingRepo] = useState(false); const [isCreatingRepo, setIsCreatingRepo] = useState(false); const [createRepoError, setCreateRepoError] = useState(null); const [createRepoSuccess, setCreateRepoSuccess] = useState(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(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 (
{/* GitHub Connection Status/Instructions */} {(githubUserCode || githubStatusMessage || githubError) && (

GitHub Connection

{githubError && (

Error: {githubError}

)} {githubUserCode && githubVerificationUri && (

1. Go to: { e.preventDefault(); IpcClient.getInstance().openExternalUrl( githubVerificationUri, ); }} target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-600 hover:underline dark:text-blue-400" > {githubVerificationUri}

2. Enter code: {githubUserCode}

)} {githubStatusMessage && (

{githubStatusMessage}

)}
)}
); } return (
{/* Collapsible Header */} {/* Collapsible Content */}
{/* Mode Selection */}
{repoSetupMode === "create" ? ( <>
{ const newValue = e.target.value; setRepoName(newValue); setRepoAvailable(null); setRepoCheckError(null); debouncedCheckRepoAvailability(newValue); }} disabled={isCreatingRepo} /> {isCheckingRepo && (

Checking availability...

)} {repoAvailable === true && (

Repository name is available!

)} {repoAvailable === false && (

{repoCheckError}

)}
) : ( <>
)} {/* Branch Selection */}
{repoSetupMode === "existing" && selectedRepo ? (
{branchInputMode === "custom" && ( setCustomBranchName(e.target.value)} placeholder="Enter branch name (e.g., feature/new-feature)" disabled={isCreatingRepo} /> )}
) : ( setSelectedBranch(e.target.value)} placeholder="main" disabled={isCreatingRepo} data-testid="github-new-repo-branch-input" /> )}
{createRepoError && (

{createRepoError}

)} {createRepoSuccess && (

{repoSetupMode === "create" ? "Repository created and linked!" : "Connected to repository!"}

)}
); } 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 ( ); } else { return ( ); } }