diff --git a/app/src/App.tsx b/app/src/App.tsx index e251e8c..2c922b1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; import Sidebar from "./components/layout/Sidebar"; import TopBar from "./components/layout/TopBar"; import StatusBar from "./components/layout/StatusBar"; @@ -12,13 +13,16 @@ export default function App() { const { checkDocker, checkImage } = useDocker(); const { checkApiKey, loadSettings } = useSettings(); const { refresh } = useProjects(); - const { sessions, activeSessionId } = useAppState(); + const { sessions, activeSessionId } = useAppState( + useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId })) + ); // Initialize on mount useEffect(() => { loadSettings(); - checkDocker(); - checkImage(); + checkDocker().then((available) => { + if (available) checkImage(); + }); checkApiKey(); refresh(); }, []); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 5a63ebc..78c7997 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -1,9 +1,12 @@ +import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../../store/appState"; import ProjectList from "../projects/ProjectList"; import SettingsPanel from "../settings/SettingsPanel"; export default function Sidebar() { - const { sidebarView, setSidebarView } = useAppState(); + const { sidebarView, setSidebarView } = useAppState( + useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView })) + ); return (
diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index 9d978cd..01e915a 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -1,7 +1,10 @@ +import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../../store/appState"; export default function StatusBar() { - const { projects, sessions } = useAppState(); + const { projects, sessions } = useAppState( + useShallow(s => ({ projects: s.projects, sessions: s.sessions })) + ); const running = projects.filter((p) => p.status === "running").length; return ( diff --git a/app/src/components/layout/TopBar.tsx b/app/src/components/layout/TopBar.tsx index 7f79ff3..33b8e4e 100644 --- a/app/src/components/layout/TopBar.tsx +++ b/app/src/components/layout/TopBar.tsx @@ -1,8 +1,11 @@ +import { useShallow } from "zustand/react/shallow"; import TerminalTabs from "../terminal/TerminalTabs"; import { useAppState } from "../../store/appState"; export default function TopBar() { - const { dockerAvailable, imageExists } = useAppState(); + const { dockerAvailable, imageExists } = useAppState( + useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists })) + ); return (
diff --git a/app/src/components/projects/AddProjectDialog.tsx b/app/src/components/projects/AddProjectDialog.tsx index f08b3a1..9b4719a 100644 --- a/app/src/components/projects/AddProjectDialog.tsx +++ b/app/src/components/projects/AddProjectDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useProjects } from "../../hooks/useProjects"; @@ -12,6 +12,34 @@ export default function AddProjectDialog({ onClose }: Props) { const [path, setPath] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const nameInputRef = useRef(null); + const overlayRef = useRef(null); + + // Auto-focus the first input when the dialog opens + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Close on click outside (click on overlay backdrop) + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) { + onClose(); + } + }, + [onClose], + ); const handleBrowse = async () => { const selected = await open({ directory: true, multiple: false }); @@ -24,7 +52,8 @@ export default function AddProjectDialog({ onClose }: Props) { } }; - const handleSubmit = async () => { + const handleSubmit = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); if (!name.trim() || !path.trim()) { setError("Name and path are required"); return; @@ -34,65 +63,74 @@ export default function AddProjectDialog({ onClose }: Props) { try { await add(name.trim(), path.trim()); onClose(); - } catch (e) { - setError(String(e)); + } catch (err) { + setError(String(err)); } finally { setLoading(false); } }; return ( -
+

Add Project

- - setName(e.target.value)} - placeholder="my-project" - className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" - /> - - -
+
+ setPath(e.target.value)} - placeholder="/path/to/project" - className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" + ref={nameInputRef} + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="my-project" + className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" /> - -
- {error && ( -
{error}
- )} + +
+ setPath(e.target.value)} + placeholder="/path/to/project" + className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" + /> + +
-
- - -
+ {error && ( +
{error}
+ )} + +
+ + +
+
); diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 33b0787..ef6c56d 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; @@ -10,7 +10,8 @@ interface Props { } export default function ProjectCard({ project }: Props) { - const { selectedProjectId, setSelectedProject } = useAppState(); + const selectedProjectId = useAppState(s => s.selectedProjectId); + const setSelectedProject = useAppState(s => s.setSelectedProject); const { start, stop, rebuild, remove, update } = useProjects(); const { open: openTerminal } = useTerminal(); const [loading, setLoading] = useState(false); @@ -19,6 +20,40 @@ export default function ProjectCard({ project }: Props) { const isSelected = selectedProjectId === project.id; const isStopped = project.status === "stopped" || project.status === "error"; + // Local state for text fields (save on blur, not on every keystroke) + const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? ""); + const [gitName, setGitName] = useState(project.git_user_name ?? ""); + const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); + const [gitToken, setGitToken] = useState(project.git_token ?? ""); + const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? ""); + const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []); + + // Bedrock local state for text fields + const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1"); + const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? ""); + const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? ""); + const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? ""); + const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? ""); + const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? ""); + const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? ""); + + // Sync local state when project prop changes (e.g., after save or external update) + useEffect(() => { + setSshKeyPath(project.ssh_key_path ?? ""); + setGitName(project.git_user_name ?? ""); + setGitEmail(project.git_user_email ?? ""); + setGitToken(project.git_token ?? ""); + setClaudeInstructions(project.claude_instructions ?? ""); + setEnvVars(project.custom_env_vars ?? []); + setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1"); + setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? ""); + setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? ""); + setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? ""); + setBedrockProfile(project.bedrock_config?.aws_profile ?? ""); + setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? ""); + setBedrockModelId(project.bedrock_config?.model_id ?? ""); + }, [project]); + const handleStart = async () => { setLoading(true); setError(null); @@ -79,7 +114,9 @@ export default function ProjectCard({ project }: Props) { try { const current = project.bedrock_config ?? defaultBedrockConfig; await update({ ...project, bedrock_config: { ...current, ...patch } }); - } catch {} + } catch (err) { + console.error("Failed to update Bedrock config:", err); + } }; const handleBrowseSSH = async () => { @@ -93,6 +130,118 @@ export default function ProjectCard({ project }: Props) { } }; + // Blur handlers for text fields + const handleSshKeyPathBlur = async () => { + try { + await update({ ...project, ssh_key_path: sshKeyPath || null }); + } catch (err) { + console.error("Failed to update SSH key path:", err); + } + }; + + const handleGitNameBlur = async () => { + try { + await update({ ...project, git_user_name: gitName || null }); + } catch (err) { + console.error("Failed to update Git name:", err); + } + }; + + const handleGitEmailBlur = async () => { + try { + await update({ ...project, git_user_email: gitEmail || null }); + } catch (err) { + console.error("Failed to update Git email:", err); + } + }; + + const handleGitTokenBlur = async () => { + try { + await update({ ...project, git_token: gitToken || null }); + } catch (err) { + console.error("Failed to update Git token:", err); + } + }; + + const handleClaudeInstructionsBlur = async () => { + try { + await update({ ...project, claude_instructions: claudeInstructions || null }); + } catch (err) { + console.error("Failed to update Claude instructions:", err); + } + }; + + const handleEnvVarBlur = async () => { + try { + await update({ ...project, custom_env_vars: envVars }); + } catch (err) { + console.error("Failed to update environment variables:", err); + } + }; + + const handleBedrockRegionBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_region: bedrockRegion } }); + } catch (err) { + console.error("Failed to update Bedrock region:", err); + } + }; + + const handleBedrockAccessKeyIdBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_access_key_id: bedrockAccessKeyId || null } }); + } catch (err) { + console.error("Failed to update Bedrock access key:", err); + } + }; + + const handleBedrockSecretKeyBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_secret_access_key: bedrockSecretKey || null } }); + } catch (err) { + console.error("Failed to update Bedrock secret key:", err); + } + }; + + const handleBedrockSessionTokenBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_session_token: bedrockSessionToken || null } }); + } catch (err) { + console.error("Failed to update Bedrock session token:", err); + } + }; + + const handleBedrockProfileBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_profile: bedrockProfile || null } }); + } catch (err) { + console.error("Failed to update Bedrock profile:", err); + } + }; + + const handleBedrockBearerTokenBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, aws_bearer_token: bedrockBearerToken || null } }); + } catch (err) { + console.error("Failed to update Bedrock bearer token:", err); + } + }; + + const handleBedrockModelIdBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + await update({ ...project, bedrock_config: { ...current, model_id: bedrockModelId || null } }); + } catch (err) { + console.error("Failed to update Bedrock model ID:", err); + } + }; + const statusColor = { stopped: "bg-[var(--text-secondary)]", starting: "bg-[var(--warning)]", @@ -208,10 +357,9 @@ export default function ProjectCard({ project }: Props) {
{ - try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {} - }} + value={sshKeyPath} + onChange={(e) => setSshKeyPath(e.target.value)} + onBlur={handleSshKeyPathBlur} placeholder="~/.ssh" disabled={!isStopped} className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" @@ -230,10 +378,9 @@ export default function ProjectCard({ project }: Props) {
{ - try { await update({ ...project, git_user_name: e.target.value || null }); } catch {} - }} + value={gitName} + onChange={(e) => setGitName(e.target.value)} + onBlur={handleGitNameBlur} placeholder="Your Name" disabled={!isStopped} className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" @@ -244,10 +391,9 @@ export default function ProjectCard({ project }: Props) {
{ - try { await update({ ...project, git_user_email: e.target.value || null }); } catch {} - }} + value={gitEmail} + onChange={(e) => setGitEmail(e.target.value)} + onBlur={handleGitEmailBlur} placeholder="you@example.com" disabled={!isStopped} className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" @@ -259,10 +405,9 @@ export default function ProjectCard({ project }: Props) { { - try { await update({ ...project, git_token: e.target.value || null }); } catch {} - }} + value={gitToken} + onChange={(e) => setGitToken(e.target.value)} + onBlur={handleGitTokenBlur} placeholder="ghp_..." disabled={!isStopped} className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50" @@ -274,7 +419,9 @@ export default function ProjectCard({ project }: Props) {