|
|
|
|
@@ -5,8 +5,6 @@ import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod }
|
|
|
|
|
import { useProjects } from "../../hooks/useProjects";
|
|
|
|
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
|
|
|
import { useTerminal } from "../../hooks/useTerminal";
|
|
|
|
|
import { useSettings } from "../../hooks/useSettings";
|
|
|
|
|
import { useVoice } from "../../hooks/useVoice";
|
|
|
|
|
import { useAppState } from "../../store/appState";
|
|
|
|
|
import EnvVarsModal from "./EnvVarsModal";
|
|
|
|
|
import PortMappingsModal from "./PortMappingsModal";
|
|
|
|
|
@@ -23,15 +21,6 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
const { start, stop, rebuild, remove, update } = useProjects();
|
|
|
|
|
const { mcpServers } = useMcpServers();
|
|
|
|
|
const { open: openTerminal } = useTerminal();
|
|
|
|
|
const { appSettings } = useSettings();
|
|
|
|
|
const sessions = useAppState(s => s.sessions);
|
|
|
|
|
const activeSessionId = useAppState(s => s.activeSessionId);
|
|
|
|
|
|
|
|
|
|
// Find the active terminal session for this project (prefer the currently viewed one)
|
|
|
|
|
const projectSession = sessions.find(s => s.projectId === project.id && s.id === activeSessionId)
|
|
|
|
|
?? sessions.find(s => s.projectId === project.id);
|
|
|
|
|
const voice = useVoice(projectSession?.id ?? "", appSettings?.default_microphone);
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [showConfig, setShowConfig] = useState(false);
|
|
|
|
|
@@ -41,6 +30,9 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
|
|
|
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
|
|
|
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
|
|
|
|
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
|
|
|
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
|
|
|
const [editName, setEditName] = useState(project.name);
|
|
|
|
|
const isSelected = selectedProjectId === project.id;
|
|
|
|
|
const isStopped = project.status === "stopped" || project.status === "error";
|
|
|
|
|
|
|
|
|
|
@@ -65,6 +57,7 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
|
|
|
|
|
// Sync local state when project prop changes (e.g., after save or external update)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setEditName(project.name);
|
|
|
|
|
setPaths(project.paths ?? []);
|
|
|
|
|
setSshKeyPath(project.ssh_key_path ?? "");
|
|
|
|
|
setGitName(project.git_user_name ?? "");
|
|
|
|
|
@@ -320,7 +313,40 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
|
|
|
|
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
|
|
|
|
{isEditingName ? (
|
|
|
|
|
<input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={editName}
|
|
|
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
|
|
|
onBlur={async () => {
|
|
|
|
|
setIsEditingName(false);
|
|
|
|
|
const trimmed = editName.trim();
|
|
|
|
|
if (trimmed && trimmed !== project.name) {
|
|
|
|
|
try {
|
|
|
|
|
await update({ ...project, name: trimmed });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Failed to rename project:", err);
|
|
|
|
|
setEditName(project.name);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setEditName(project.name);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
|
|
|
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span
|
|
|
|
|
className="text-sm font-medium truncate flex-1 cursor-text"
|
|
|
|
|
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
|
|
|
|
>
|
|
|
|
|
{project.name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
|
|
|
|
{project.paths.map((pp, i) => (
|
|
|
|
|
@@ -382,9 +408,6 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
<>
|
|
|
|
|
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
|
|
|
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
|
|
|
|
{projectSession && (
|
|
|
|
|
<MicButton voice={voice} />
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
@@ -399,16 +422,34 @@ export default function ProjectCard({ project }: Props) {
|
|
|
|
|
disabled={false}
|
|
|
|
|
label={showConfig ? "Hide" : "Config"}
|
|
|
|
|
/>
|
|
|
|
|
<ActionButton
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (confirm(`Remove project "${project.name}"?`)) {
|
|
|
|
|
await remove(project.id);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
label="Remove"
|
|
|
|
|
danger
|
|
|
|
|
/>
|
|
|
|
|
{showRemoveConfirm ? (
|
|
|
|
|
<span className="inline-flex items-center gap-1 text-xs">
|
|
|
|
|
<span className="text-[var(--text-secondary)]">Remove?</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={async (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setShowRemoveConfirm(false);
|
|
|
|
|
await remove(project.id);
|
|
|
|
|
}}
|
|
|
|
|
className="px-1.5 py-0.5 rounded text-white bg-[var(--error)] hover:opacity-80 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Yes
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); setShowRemoveConfirm(false); }}
|
|
|
|
|
className="px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
|
|
|
|
>
|
|
|
|
|
No
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<ActionButton
|
|
|
|
|
onClick={() => setShowRemoveConfirm(true)}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
label="Remove"
|
|
|
|
|
danger
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Config panel */}
|
|
|
|
|
@@ -884,34 +925,3 @@ function ActionButton({
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MicButton({ voice }: { voice: ReturnType<typeof useVoice> }) {
|
|
|
|
|
const color =
|
|
|
|
|
voice.state === "active"
|
|
|
|
|
? "text-[var(--success)] hover:text-[var(--success)]"
|
|
|
|
|
: voice.state === "starting"
|
|
|
|
|
? "text-[var(--warning)] opacity-75"
|
|
|
|
|
: voice.state === "error"
|
|
|
|
|
? "text-[var(--error)] hover:text-[var(--error)]"
|
|
|
|
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); voice.toggle(); }}
|
|
|
|
|
disabled={voice.state === "starting"}
|
|
|
|
|
title={
|
|
|
|
|
voice.state === "active"
|
|
|
|
|
? "Voice active — click to stop"
|
|
|
|
|
: voice.error
|
|
|
|
|
? `Voice error: ${voice.error}`
|
|
|
|
|
: "Enable voice input for /voice mode"
|
|
|
|
|
}
|
|
|
|
|
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
|
|
|
|
>
|
|
|
|
|
{voice.state === "active"
|
|
|
|
|
? "Mic On"
|
|
|
|
|
: voice.state === "starting"
|
|
|
|
|
? "Mic..."
|
|
|
|
|
: "Mic Off"}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|