mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
- Split cli-status-section.tsx into two separate files: - claude-cli-status.tsx (ClaudeCliStatus component) - codex-cli-status.tsx (CodexCliStatus component) - Move both components into cli-status/ folder - Update settings-view.tsx to import from new locations - Update type imports to use ../types - All TypeScript diagnostics passing - Improves modularity and follows one-component-per-file pattern
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useAppStore } from "@/store/app-store";
|
|
import { useSetupStore } from "@/store/setup-store";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
Settings,
|
|
Key,
|
|
Keyboard,
|
|
Trash2,
|
|
Folder,
|
|
Terminal,
|
|
Atom,
|
|
Palette,
|
|
LayoutGrid,
|
|
Settings2,
|
|
FlaskConical,
|
|
} from "lucide-react";
|
|
import { getElectronAPI } from "@/lib/electron";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map";
|
|
// Import extracted sections
|
|
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
|
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
|
import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
|
|
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
|
import { KanbanDisplaySection } from "./settings-view/kanban-display-section";
|
|
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts-section";
|
|
import { FeatureDefaultsSection } from "./settings-view/feature-defaults-section";
|
|
import { DangerZoneSection } from "./settings-view/danger-zone-section";
|
|
|
|
// Navigation items for the side panel
|
|
const NAV_ITEMS = [
|
|
{ id: "api-keys", label: "API Keys", icon: Key },
|
|
{ id: "claude", label: "Claude", icon: Terminal },
|
|
{ id: "codex", label: "Codex", icon: Atom },
|
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
|
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
|
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
|
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
|
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
|
];
|
|
|
|
export function SettingsView() {
|
|
const {
|
|
setCurrentView,
|
|
theme,
|
|
setTheme,
|
|
setProjectTheme,
|
|
kanbanCardDetailLevel,
|
|
setKanbanCardDetailLevel,
|
|
defaultSkipTests,
|
|
setDefaultSkipTests,
|
|
useWorktrees,
|
|
setUseWorktrees,
|
|
showProfilesOnly,
|
|
setShowProfilesOnly,
|
|
currentProject,
|
|
moveProjectToTrash,
|
|
} = useAppStore();
|
|
|
|
const { claudeAuthStatus, codexAuthStatus, setClaudeAuthStatus, setCodexAuthStatus } =
|
|
useSetupStore();
|
|
|
|
// Compute the effective theme for the current project
|
|
const effectiveTheme = currentProject?.theme || theme;
|
|
|
|
// Handler to set theme - saves to project if one is selected, otherwise to global
|
|
const handleSetTheme = (newTheme: typeof theme) => {
|
|
if (currentProject) {
|
|
setProjectTheme(currentProject.id, newTheme);
|
|
} else {
|
|
setTheme(newTheme);
|
|
}
|
|
};
|
|
|
|
const [claudeCliStatus, setClaudeCliStatus] = useState<{
|
|
success: boolean;
|
|
status?: string;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
recommendation?: string;
|
|
installCommands?: {
|
|
macos?: string;
|
|
windows?: string;
|
|
linux?: string;
|
|
npm?: string;
|
|
};
|
|
error?: string;
|
|
} | null>(null);
|
|
|
|
const [codexCliStatus, setCodexCliStatus] = useState<{
|
|
success: boolean;
|
|
status?: string;
|
|
method?: string;
|
|
version?: string;
|
|
path?: string;
|
|
hasApiKey?: boolean;
|
|
recommendation?: string;
|
|
installCommands?: {
|
|
macos?: string;
|
|
windows?: string;
|
|
linux?: string;
|
|
npm?: string;
|
|
};
|
|
error?: string;
|
|
} | null>(null);
|
|
|
|
const [activeSection, setActiveSection] = useState("api-keys");
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
|
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
|
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const checkCliStatus = async () => {
|
|
const api = getElectronAPI();
|
|
if (api?.checkClaudeCli) {
|
|
try {
|
|
const status = await api.checkClaudeCli();
|
|
setClaudeCliStatus(status);
|
|
} catch (error) {
|
|
console.error("Failed to check Claude CLI status:", error);
|
|
}
|
|
}
|
|
if (api?.checkCodexCli) {
|
|
try {
|
|
const status = await api.checkCodexCli();
|
|
setCodexCliStatus(status);
|
|
} catch (error) {
|
|
console.error("Failed to check Codex CLI status:", error);
|
|
}
|
|
}
|
|
|
|
// Check Claude auth status (re-fetch on mount to ensure persistence)
|
|
if (api?.setup?.getClaudeStatus) {
|
|
try {
|
|
const result = await api.setup.getClaudeStatus();
|
|
if (result.success && result.auth) {
|
|
const auth = result.auth;
|
|
const authStatus = {
|
|
authenticated: auth.authenticated,
|
|
method:
|
|
auth.method === "oauth_token"
|
|
? "oauth" as const
|
|
: auth.method?.includes("api_key")
|
|
? "api_key" as const
|
|
: "none" as const,
|
|
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
|
oauthTokenValid: auth.hasStoredOAuthToken,
|
|
apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey,
|
|
};
|
|
setClaudeAuthStatus(authStatus);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check Claude auth status:", error);
|
|
}
|
|
}
|
|
|
|
// Check Codex auth status (re-fetch on mount to ensure persistence)
|
|
if (api?.setup?.getCodexStatus) {
|
|
try {
|
|
const result = await api.setup.getCodexStatus();
|
|
if (result.success && result.auth) {
|
|
const auth = result.auth;
|
|
// Determine method - prioritize cli_verified and cli_tokens over auth_file
|
|
const method =
|
|
auth.method === "cli_verified" || auth.method === "cli_tokens"
|
|
? auth.method === "cli_verified"
|
|
? "cli_verified" as const
|
|
: "cli_tokens" as const
|
|
: auth.method === "auth_file"
|
|
? "api_key" as const
|
|
: auth.method === "env_var"
|
|
? "env" as const
|
|
: "none" as const;
|
|
|
|
const authStatus = {
|
|
authenticated: auth.authenticated,
|
|
method,
|
|
// Only set apiKeyValid for actual API key methods, not CLI login
|
|
apiKeyValid:
|
|
method === "cli_verified" || method === "cli_tokens"
|
|
? undefined
|
|
: auth.hasAuthFile || auth.hasEnvKey,
|
|
};
|
|
setCodexAuthStatus(authStatus);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check Codex auth status:", error);
|
|
}
|
|
}
|
|
};
|
|
checkCliStatus();
|
|
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
|
|
|
// Track scroll position to highlight active nav item
|
|
useEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const handleScroll = () => {
|
|
const sections = NAV_ITEMS.filter(
|
|
(item) => item.id !== "danger" || currentProject
|
|
)
|
|
.map((item) => ({
|
|
id: item.id,
|
|
element: document.getElementById(item.id),
|
|
}))
|
|
.filter((s) => s.element);
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const scrollTop = container.scrollTop;
|
|
const scrollHeight = container.scrollHeight;
|
|
const clientHeight = container.clientHeight;
|
|
|
|
// Check if scrolled to bottom (within a small threshold)
|
|
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;
|
|
|
|
if (isAtBottom && sections.length > 0) {
|
|
// If at bottom, highlight the last visible section
|
|
setActiveSection(sections[sections.length - 1].id);
|
|
return;
|
|
}
|
|
|
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
const section = sections[i];
|
|
if (section.element) {
|
|
const rect = section.element.getBoundingClientRect();
|
|
const relativeTop = rect.top - containerRect.top + scrollTop;
|
|
if (scrollTop >= relativeTop - 100) {
|
|
setActiveSection(section.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
container.addEventListener("scroll", handleScroll);
|
|
return () => container.removeEventListener("scroll", handleScroll);
|
|
}, [currentProject]);
|
|
|
|
const scrollToSection = useCallback((sectionId: string) => {
|
|
const element = document.getElementById(sectionId);
|
|
if (element && scrollContainerRef.current) {
|
|
const container = scrollContainerRef.current;
|
|
const containerRect = container.getBoundingClientRect();
|
|
const elementRect = element.getBoundingClientRect();
|
|
const relativeTop =
|
|
elementRect.top - containerRect.top + container.scrollTop;
|
|
|
|
container.scrollTo({
|
|
top: relativeTop - 24,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const handleRefreshClaudeCli = useCallback(async () => {
|
|
setIsCheckingClaudeCli(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (api?.checkClaudeCli) {
|
|
const status = await api.checkClaudeCli();
|
|
setClaudeCliStatus(status);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to refresh Claude CLI status:", error);
|
|
} finally {
|
|
setIsCheckingClaudeCli(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleRefreshCodexCli = useCallback(async () => {
|
|
setIsCheckingCodexCli(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
if (api?.checkCodexCli) {
|
|
const status = await api.checkCodexCli();
|
|
setCodexCliStatus(status);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to refresh Codex CLI status:", error);
|
|
} finally {
|
|
setIsCheckingCodexCli(false);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
data-testid="settings-view"
|
|
>
|
|
{/* Header Section */}
|
|
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
|
<div className="px-8 py-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
|
<Settings className="w-5 h-5 text-primary-foreground" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure your API keys and preferences
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area with Sidebar */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Sticky Side Navigation */}
|
|
<nav className="hidden lg:block w-48 shrink-0 border-r border-border bg-card/50 backdrop-blur-sm">
|
|
<div className="sticky top-0 p-4 space-y-1">
|
|
{NAV_ITEMS.filter(
|
|
(item) => item.id !== "danger" || currentProject
|
|
).map((item) => {
|
|
const Icon = item.icon;
|
|
const isActive = activeSection === item.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => scrollToSection(item.id)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all text-left",
|
|
isActive
|
|
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"w-4 h-4 shrink-0",
|
|
isActive ? "text-brand-500" : ""
|
|
)}
|
|
/>
|
|
<span className="truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Scrollable Content */}
|
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
|
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
|
{/* API Keys Section */}
|
|
<ApiKeysSection />
|
|
|
|
{/* Claude CLI Status Section */}
|
|
{claudeCliStatus && (
|
|
<ClaudeCliStatus
|
|
status={claudeCliStatus}
|
|
isChecking={isCheckingClaudeCli}
|
|
onRefresh={handleRefreshClaudeCli}
|
|
/>
|
|
)}
|
|
|
|
{/* Codex CLI Status Section */}
|
|
{codexCliStatus && (
|
|
<CodexCliStatus
|
|
status={codexCliStatus}
|
|
isChecking={isCheckingCodexCli}
|
|
onRefresh={handleRefreshCodexCli}
|
|
/>
|
|
)}
|
|
|
|
{/* Appearance Section */}
|
|
<AppearanceSection
|
|
effectiveTheme={effectiveTheme}
|
|
currentProject={currentProject}
|
|
onThemeChange={handleSetTheme}
|
|
/>
|
|
|
|
{/* Kanban Card Display Section */}
|
|
<KanbanDisplaySection
|
|
detailLevel={kanbanCardDetailLevel}
|
|
onChange={setKanbanCardDetailLevel}
|
|
/>
|
|
|
|
{/* Keyboard Shortcuts Section */}
|
|
<KeyboardShortcutsSection
|
|
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
|
/>
|
|
|
|
{/* Feature Defaults Section */}
|
|
<FeatureDefaultsSection
|
|
showProfilesOnly={showProfilesOnly}
|
|
defaultSkipTests={defaultSkipTests}
|
|
useWorktrees={useWorktrees}
|
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
|
onUseWorktreesChange={setUseWorktrees}
|
|
/>
|
|
|
|
{/* Danger Zone Section - Only show when a project is selected */}
|
|
<DangerZoneSection
|
|
project={currentProject}
|
|
onDeleteClick={() => setShowDeleteDialog(true)}
|
|
/>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setCurrentView("welcome")}
|
|
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
|
|
data-testid="back-to-home"
|
|
>
|
|
Back to Home
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Keyboard Map Dialog */}
|
|
<Dialog
|
|
open={showKeyboardMapDialog}
|
|
onOpenChange={setShowKeyboardMapDialog}
|
|
>
|
|
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Keyboard className="w-5 h-5 text-brand-500" />
|
|
Keyboard Shortcut Map
|
|
</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground">
|
|
Visual overview of all keyboard shortcuts. Keys in color are bound
|
|
to shortcuts. Click on any shortcut below to edit it.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-6 py-4 pl-3 pr-6 pb-6">
|
|
{/* Visual Keyboard Map */}
|
|
<KeyboardMap />
|
|
|
|
{/* Shortcut Reference - Editable */}
|
|
<div className="border-t border-border pt-4">
|
|
<h3 className="text-sm font-semibold text-foreground mb-4">
|
|
All Shortcuts Reference (Click to Edit)
|
|
</h3>
|
|
<ShortcutReferencePanel editable />
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Project Confirmation Dialog */}
|
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<DialogContent className="bg-popover border-border max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Trash2 className="w-5 h-5 text-destructive" />
|
|
Delete Project
|
|
</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground">
|
|
Are you sure you want to move this project to Trash?
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{currentProject && (
|
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
|
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
|
<Folder className="w-5 h-5 text-brand-500" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-foreground truncate">
|
|
{currentProject.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{currentProject.path}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
The folder will remain on disk until you permanently delete it from
|
|
Trash.
|
|
</p>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="ghost" onClick={() => setShowDeleteDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
if (currentProject) {
|
|
moveProjectToTrash(currentProject.id);
|
|
setShowDeleteDialog(false);
|
|
}
|
|
}}
|
|
data-testid="confirm-delete-project"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Move to Trash
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|