feat: add GitHub setup step and enhance setup flow

- Introduced a new GitHubSetupStep component for GitHub CLI configuration during the setup process.
- Updated SetupView to include the GitHub step in the setup flow, allowing users to skip or proceed based on their GitHub CLI status.
- Enhanced state management to track GitHub CLI installation and authentication status.
- Added logging for transitions between setup steps to improve user feedback.
- Updated related files to ensure cross-platform path normalization and compatibility.
This commit is contained in:
Cody Seibert
2025-12-16 13:56:53 -05:00
parent 8482cdab87
commit 8c24381759
26 changed files with 1302 additions and 466 deletions

View File

@@ -34,7 +34,7 @@ import {
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { cn, pathsEqual, normalizePath } from "@/lib/utils";
import { toast } from "sonner";
interface WorktreeInfo {
@@ -225,10 +225,10 @@ export function WorktreeSelector({
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
// Update running servers map
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(targetPath, {
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
@@ -260,10 +260,10 @@ export function WorktreeSelector({
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
// Update running servers map
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(targetPath);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
@@ -285,8 +285,10 @@ export function WorktreeSelector({
};
// Helper to get the path key for a worktree (for looking up in runningDevServers)
// Normalizes path for cross-platform compatibility
const getWorktreeKey = (worktree: WorktreeInfo) => {
return worktree.isMain ? projectPath : worktree.path;
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
};
// Helper to check if a worktree has running features
@@ -301,12 +303,13 @@ export function WorktreeSelector({
if (!feature) return false;
// For main worktree, check features with no worktreePath or matching projectPath
// Use pathsEqual for cross-platform compatibility (Windows uses backslashes)
if (worktree.isMain) {
return !feature.worktreePath || feature.worktreePath === projectPath;
return !feature.worktreePath || pathsEqual(feature.worktreePath, projectPath);
}
// For other worktrees, check if worktreePath matches
return feature.worktreePath === worktreeKey;
return pathsEqual(feature.worktreePath, worktreeKey);
});
};
@@ -459,7 +462,7 @@ export function WorktreeSelector({
// currentWorktree.path is null for main, or the worktree path for others
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => w.path === currentWorktreePath)
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
@@ -469,7 +472,7 @@ export function WorktreeSelector({
// Default to main selected if currentWorktree is null/undefined or path is null
const isSelected = worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
: worktree.path === currentWorktreePath;
: pathsEqual(worktree.path, currentWorktreePath);
const isRunning = hasRunningFeatures(worktree);

View File

@@ -47,6 +47,8 @@ export function CreatePRDialog({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
@@ -58,6 +60,8 @@ export function CreatePRDialog({
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}
}, [open, worktree?.path]);
@@ -93,14 +97,26 @@ export function CreatePRDialog({
});
onCreated();
} else {
// Branch was pushed successfully
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
if (!result.result.prCreated) {
// Show the specific error if available
// Check if we should show browser fallback
if (!result.result.prCreated && result.result.browserUrl) {
const prError = result.result.prError;
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl);
setShowBrowserFallback(true);
onCreated();
return; // Don't close dialog, show browser fallback UI
}
// gh CLI is available but failed - show error with browser option
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
@@ -111,16 +127,25 @@ export function CreatePRDialog({
} else if (prError.includes("not logged in") || prError.includes("auth")) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl);
setShowBrowserFallback(true);
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
});
} else {
toast.info("PR not created", {
description: "GitHub CLI (gh) may not be installed or authenticated",
});
onCreated();
return;
}
}
// No browser URL available, just close
if (!result.result.prCreated) {
toast.info("PR not created", {
description: "GitHub CLI (gh) may not be installed or authenticated",
});
}
onCreated();
onOpenChange(false);
}
@@ -145,6 +170,8 @@ export function CreatePRDialog({
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}, 200);
};
@@ -185,6 +212,32 @@ export function CreatePRDialog({
View Pull Request
</Button>
</div>
) : showBrowserFallback && browserUrl ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
<GitPullRequest className="w-8 h-8 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your changes have been pushed to GitHub.
<br />
Click below to create a pull request in your browser.
</p>
</div>
<div className="space-y-2">
<Button
onClick={() => window.open(browserUrl, "_blank")}
className="gap-2 w-full"
>
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
</p>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-4">

View File

@@ -1,5 +1,6 @@
import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { pathsEqual } from "@/lib/utils";
type ColumnId = Feature["status"];
@@ -65,8 +66,8 @@ export function useBoardColumnFeatures({
// No worktree or branch assigned - show only on main
matchesWorktree = !currentWorktreePath;
} else if (f.worktreePath) {
// Has worktreePath - match by path
matchesWorktree = f.worktreePath === effectiveWorktreePath;
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
} else if (effectiveBranch === null) {
// We're selecting a non-main worktree but can't determine its branch yet
// (worktrees haven't loaded). Don't show branch-only features until we know.

View File

@@ -7,6 +7,7 @@ import {
WelcomeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
} from "./setup-view/steps";
// Main Setup View
@@ -19,12 +20,13 @@ export function SetupView() {
} = useSetupStore();
const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "complete"] as const;
const steps = ["welcome", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "github") return "github";
return "complete";
};
const currentIndex = steps.indexOf(getStepName());
@@ -42,6 +44,10 @@ export function SetupView() {
setCurrentStep("claude_detect");
break;
case "claude":
console.log("[Setup Flow] Moving to github step");
setCurrentStep("github");
break;
case "github":
console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete");
break;
@@ -54,12 +60,20 @@ export function SetupView() {
case "claude":
setCurrentStep("welcome");
break;
case "github":
setCurrentStep("claude_detect");
break;
}
};
const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true);
setCurrentStep("github");
};
const handleSkipGithub = () => {
console.log("[Setup Flow] Skipping GitHub setup");
setCurrentStep("complete");
};
@@ -110,6 +124,14 @@ export function SetupView() {
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}

View File

@@ -0,0 +1,333 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import {
CheckCircle2,
Loader2,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
RefreshCw,
AlertTriangle,
Github,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "../components";
interface GitHubSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function GitHubSetupStep({
onNext,
onBack,
onSkip,
}: GitHubSetupStepProps) {
const { ghCliStatus, setGhCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getGhStatus) {
return;
}
const result = await api.setup.getGhStatus();
if (result.success) {
setGhCliStatus({
installed: result.installed,
authenticated: result.authenticated,
version: result.version,
path: result.path,
user: result.user,
});
}
} catch (error) {
console.error("Failed to check gh status:", error);
} finally {
setIsChecking(false);
}
}, [setGhCliStatus]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
};
const isReady = ghCliStatus?.installed && ghCliStatus?.authenticated;
const getStatusBadge = () => {
if (isChecking) {
return <StatusBadge status="checking" label="Checking..." />;
}
if (ghCliStatus?.authenticated) {
return <StatusBadge status="authenticated" label="Ready" />;
}
if (ghCliStatus?.installed) {
return <StatusBadge status="unverified" label="Not Logged In" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Github className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
GitHub CLI Setup
</h2>
<p className="text-muted-foreground">
Optional - Used for creating pull requests
</p>
</div>
{/* Info Banner */}
<Card className="bg-amber-500/10 border-amber-500/20">
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-foreground">
This step is optional
</p>
<p className="text-sm text-muted-foreground mt-1">
The GitHub CLI allows you to create pull requests directly from
the app. Without it, you can still create PRs manually in your
browser.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Github className="w-5 h-5" />
GitHub CLI Status
</CardTitle>
<div className="flex items-center gap-2">
{getStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
<CardDescription>
{ghCliStatus?.installed
? ghCliStatus.authenticated
? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}`
: "Installed but not logged in"
: "Not installed on your system"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State */}
{isReady && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
GitHub CLI is ready!
</p>
<p className="text-sm text-muted-foreground">
You can create pull requests directly from the app.
{ghCliStatus?.version && (
<span className="ml-1">Version: {ghCliStatus.version}</span>
)}
</p>
</div>
</div>
)}
{/* Not Installed */}
{!ghCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not found
</p>
<p className="text-sm text-muted-foreground mt-1">
Install the GitHub CLI to enable PR creation from the app.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">
Installation Commands:
</p>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">macOS (Homebrew)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
brew install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("brew install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Windows (winget)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
winget install GitHub.cli
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("winget install GitHub.cli")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Linux (apt)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
sudo apt install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("sudo apt install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<a
href="https://cli.github.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
>
View all installation options
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{/* Installed but not authenticated */}
{ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not logged in
</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate with GitHub.
</p>
</div>
</div>
<div className="space-y-2 p-4 rounded-lg bg-muted/30 border border-border">
<p className="text-sm text-muted-foreground">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
gh auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("gh auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Checking GitHub CLI status...
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
{isReady ? "Skip" : "Skip for now"}
</Button>
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="github-next-button"
>
{isReady ? "Continue" : "Continue without GitHub CLI"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,3 +2,4 @@
export { WelcomeStep } from "./welcome-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";